diff --git a/Client/index.html b/Client/index.html index a45c6b1..4fa3aac 100644 --- a/Client/index.html +++ b/Client/index.html @@ -6,6 +6,9 @@ + + + @@ -101,7 +104,7 @@ changes visibility with JS-->


-
+
diff --git a/Client/scripts.js b/Client/scripts.js index dbf3382..a330653 100644 --- a/Client/scripts.js +++ b/Client/scripts.js @@ -2,9 +2,16 @@ let ip; let alertTime = 2; let adminPass = ""; +let justSkipped = false; +let justChangedSetting = false; const ERR_NO_ADMIN = 401; const VALID_FILE_EXT = ["mp3","flac","wav"]; +let playlistTimeTimer=null; +let playlistElapsedSeconds=0; +let playlistSongLength=-1; +let currentlyPlaying = false; + const params = new URLSearchParams(location.search); let darkmodetemp = getCookie("darkmode"); @@ -35,13 +42,19 @@ async function alertText(text="Song Added!") { } // a lot of this is kinda waffly because i was trying to get // it to return the right stuff and javascript is asyrcronouse (boo) -async function getFromServer(bodyInfo, source="",password=adminPass) { +async function getFromServer(bodyInfo, source="", secure=false, password=adminPass) { try{ if (bodyInfo != null) { // the currently set password is always included in every request bodyInfo["password"] = password; } - const response = await fetch("http://"+ip+"/"+source, { + let href = ""; + if(secure) { + href = "https://"+ip+"/" + source; + } else { + href = "http://"+ip+"/" + source; + } + const response = await fetch(href, { method: "POST", body: JSON.stringify(bodyInfo), headers: { @@ -54,9 +67,9 @@ async function getFromServer(bodyInfo, source="",password=adminPass) { // im suprised i didn't comment on this already but this is kinda lame desing // its not wrong but you know // it is easy which i like - // and it overrides any other non-async alerts which is nice alertText("Error: Admin restricted action") } else if(!response.ok){ + throw new Error(data.error); alertText("Error: "+data.error); } // we add some information from the response just in case it is needed @@ -67,7 +80,7 @@ async function getFromServer(bodyInfo, source="",password=adminPass) { } catch(e) { // console.log("error print here:"); // console.log(e); - if (e.toString().contains("TypeError: Failed to fetch")){ + if (e.toString().includes("TypeError: Failed to fetch")){ alertText("Error: Can't Connect to Server (is the ip set?)") } else { alertText("Error: " + e); @@ -98,15 +111,22 @@ function getCookie(cname) { // also someone who likes things not being dumb more than me would have separated the client and server buttons async function controlButton(buttonType) { if (buttonType == "pp") { // Play-Pause button - getFromServer({control: "play-pause"}, "controls") + let result = await getFromServer({control: "play-pause"}, "controls"); + // console.log(result); + currentlyPlaying = result["data"]["playingState"]; } else if (buttonType == "sk") { // Skip button - let returnCode = getFromServer({control: "skip"}, "controls"); + // clearInterval(playlistTimeTimer); + let returnCode = await getFromServer({control: "skip"}, "controls"); + // console.log(returnCode["ok"]) if(returnCode["ok"]) { if (document.getElementById("playlist-mode").style.display == "block") { - generateVisualPlaylist("skip-button"); + skipInPlaylist(); + playlistElapsedSeconds = 0; + justSkipped = true; } } } else if (buttonType == "pl") { // Playlist button + clearInterval(playlistTimeTimer); document.getElementById("songlist").innerHTML = ""; document.getElementById("playlist").innerHTML = "

"; document.getElementById("playlist-mode").style.display = "block"; @@ -114,21 +134,30 @@ async function controlButton(buttonType) { document.getElementById("settings-mode").style.display = "none"; generateVisualPlaylist(); } else if (buttonType == "se") { //SearchMode button + clearInterval(playlistTimeTimer); document.getElementById("songlist").innerHTML = "

Search to find songs!

"; document.getElementById("playlist").innerHTML = ""; document.getElementById("playlist-mode").style.display = "none"; document.getElementById("songlist-mode").style.display = "block"; document.getElementById("settings-mode").style.display = "none"; } else if (buttonType == "st") { //Settings button + clearInterval(playlistTimeTimer); document.getElementById("songlist").innerHTML = ""; document.getElementById("playlist").innerHTML = ""; document.getElementById("playlist-mode").style.display = "none"; document.getElementById("songlist-mode").style.display = "none"; document.getElementById("settings-mode").style.display = "block"; checkSettings() - } else if (buttonType = "pm") { //Partymode toggle (in settings) - await getFromServer({setting: "partymode-toggle"}, "settings") - checkSettings(true) + } else if (buttonType == "pm") { //Partymode toggle (in settings) + let response = await getFromServer({setting: "partymode-toggle"}, "settings") + if(response.ok) { + justChangedSetting = true; + checkSettings(); + } else { + // dont think anything is needed here + } + } else { + alertText("Error: You pushed a button that does not exist"); } @@ -245,6 +274,47 @@ function qrCodeGenerate() { }); } +async function displayElapsedPlaylistTime(elapsed=0,length=-1) { + if(currentlyPlaying) { + if(Math.floor(elapsed) > Math.floor(length) && typeof length === "number" && typeof elapsed === "number"){ + // console.log("somethingShouldBeHappening") + playlistElapsedSeconds = 0; + generateVisualPlaylist(); + } + let mins = Math.floor(elapsed/60); + let secs = Math.floor(elapsed%60); + let durMins = Math.floor(length/60); + let durSecs = Math.floor(length%60); + let timeLeft = document.getElementById("elapsed-time-display"); + if(mins > durMins) { + mins = durMins; + if(secs > durSecs) { + secs = durSecs; + } + } + + timeLeft.innerHTML = mins.toString() +":"+ secs.toLocaleString('en-US', {minimumIntegerDigits: 2,useGrouping: false}) + "/"+ durMins.toString()+":"+durSecs.toLocaleString('en-US', {minimumIntegerDigits: 2,useGrouping: false}); + playlistElapsedSeconds++; + } +} + +async function updateSingleSetting(data) { + let toBeChanged = data["settingToChange"]; + if (toBeChanged === "partymode") { + document.getElementById("partymode-button").textContent = data["newData"]; + } else if (toBeChanged === "perms") { + let currentAdminPerms = data["newData"]; + document.getElementById("addsongsettingcheckbox").checked = currentAdminPerms["AS"]; + document.getElementById("skipsongsettingcheckbox").checked = currentAdminPerms["SK"]; + document.getElementById("playpausesettingcheckbox").checked = currentAdminPerms["PP"]; + document.getElementById("partymodesettingcheckbox").checked = currentAdminPerms["PM"]; + document.getElementById("volumechangesettingcheckbox").checked = currentAdminPerms["VOL"]; + document.getElementById("duplicateallowesettingcheckbox").checked = currentAdminPerms["DUP"]; + } else if (toBeChanged === "volume") { + document.getElementById("volumerange").value = data["newData"]; + } +} + async function checkSettings(skipServer=false) { //check client stuff first so if the server doesn't exist it can still be changed and seen if (ip.slice(-5)=="19054") { @@ -289,19 +359,91 @@ async function checkSettings(skipServer=false) { document.getElementById("duplicateallowesettingcheckbox").checked = currentAdminPerms["DUP"]; } +async function addToPlaylist(songObject) { + i = document.getElementById("playlist").children.length-1 + let newItem = document.createElement("div"); + newItem.className = "item"; + newItem.id = Object.keys(songObject)[0]; + newItem.tabIndex = 0; + let image = document.createElement("img"); + try { + if (songObject[newItem.id]["art"] == null) { + throw "no image lolz" + } + image.src = songObject[newItem.id]["art"]; + } catch(err){ + image.src = "./images/placeholder.png"; + } + image.id = String(songObject[newItem.id])+" image"; + let head3 = document.createElement("h3"); + head3.innerText = songObject[newItem.id]["title"]; + let head4 = document.createElement("h4"); + head4.innerText= songObject[newItem.id]["artist"]; + let head5 = document.createElement("h5"); + let timeLeft =document.createElement("h5"); + timeLeft.style.fontWeight = 100; + if(i==0) { + // they can all have the text, doesn't really matter, but only the first one + // should get the ids since its the one we want to mess with + head5.id = "playing-indicator-text"; + timeLeft.id = "elapsed-time-display"; + } + let textdiv = document.createElement("div") + textdiv.className="text" + newItem.appendChild(image); + textdiv.appendChild(head3); + textdiv.appendChild(head4); + textdiv.appendChild(timeLeft); + textdiv.appendChild(head5); + newItem.appendChild(textdiv); + document.getElementById("playlist").appendChild(newItem); + try { + if (i == 0) { // Only the first song in the loop gets a time + head5.innerHTML="Playing"; + playlistElapsedSeconds = playlist[0]["time"]; + playlistSongLength = playlist[0]["length"]; + displayElapsedPlaylistTime(playlistElapsedSeconds,playlistSongLength); + clearInterval(playlistTimeTimer); + } + } catch(e) { + console.log("I dunno something bad happened:"+e); + } +} + +async function skipInPlaylist() { + playlistElapsedSeconds = 0; + let playlistChildren = document.getElementById("playlist").children; + if(playlistChildren[1].nodeName === "DIV") { + playlistChildren[1].remove(); + } + playlistChildren = document.getElementById("playlist").children; + if(playlistChildren.length === 1) { + playlistChildren[0].innerText = "Nothing's Queued..." + } else { + let firstElementTextChildren = playlistChildren[1].children[1].children + // console.log(firstElementTextChildren); + firstElementTextChildren[2].id = "elapsed-time-display"; + firstElementTextChildren[3].id = "playing-indicator-text"; + firstElementTextChildren[3].textContent = "Playing"; + } + displayElapsedPlaylistTime(playlistElapsedSeconds,playlistSongLength); +} + async function generateVisualPlaylist(conditions="") { document.getElementById("playlist").innerHTML = "

"; data = await getFromServer(null, "playlist"); - playlist = data["data"]; + playlist = data["data"]["playlist"]; + currentlyPlaying = data["data"]["playingState"] playlist = Object.values(playlist).map(obj => { const filename = Object.keys(obj)[0]; // Get the filename const songData = obj[filename]; // Get the song metadata return { filename, ...songData }; // Merge filename with song data }); if (playlist.length==0){ + clearInterval(playlistTimeTimer); document.getElementById("playlist-alert").innerHTML = "Nothing's Queued..." } else { - if (conditions=="skip-button") { + if (conditions==="skip-button") { playlist.shift() if (playlist.length==0){ document.getElementById("playlist-alert").innerHTML = "Nothing's Queued..." @@ -330,20 +472,11 @@ async function generateVisualPlaylist(conditions="") { let head5 = document.createElement("h5"); let timeLeft =document.createElement("h5"); timeLeft.style.fontWeight = 100; - try { - if (i == 0) { // Only the first song in the loop gets a time - head5.innerHTML="Playing"; - if ((conditions != "skip-button")) { - let mins = Math.floor(playlist[i]["time"]/60); - let secs = Math.floor(playlist[i]["time"]%60); - let durMins = Math.floor(playlist[i]["length"]/60); - let durSecs = Math.floor(playlist[i]["length"]%60); - timeLeft.innerHTML = mins.toString() +":"+ secs.toLocaleString('en-US', {minimumIntegerDigits: 2,useGrouping: false}) + "/"+ durMins.toString()+":"+durSecs.toLocaleString('en-US', {minimumIntegerDigits: 2,useGrouping: false}); - } - } - }catch(err){ - // i dont know why there's a try catch here but i'm leaving it i dont want to break something - console.error(err) + if(i== 0) { + // they can all have the text, doesn't really matter, but only the first one + // should get the ids since its the one we want to mess with + head5.id = "playing-indicator-text"; + timeLeft.id = "elapsed-time-display"; } let textdiv = document.createElement("div") textdiv.className="text" @@ -354,13 +487,30 @@ async function generateVisualPlaylist(conditions="") { textdiv.appendChild(head5); newItem.appendChild(textdiv); document.getElementById("playlist").appendChild(newItem); + try { + if (i == 0) { // Only the first song in the loop gets a time + head5.innerHTML="Playing"; + if ((conditions != "skip-button")) { + playlistElapsedSeconds = playlist[0]["time"]; + playlistSongLength = playlist[0]["length"]; + displayElapsedPlaylistTime(playlistElapsedSeconds,playlistSongLength); + clearInterval(playlistTimeTimer); + } + } + }catch(err){ + // i dont know why there's a try catch here but i'm leaving it i dont want to break something + console.error(err) + } } - } + playlistTimeTimer = setInterval(() => { + displayElapsedPlaylistTime(playlistElapsedSeconds,playlistSongLength); + },1000) + } } async function submitSong(songid) { let returncode = await getFromServer({song: songid}, "songadd"); - if(returncode == ERR_NO_ADMIN) { + if(returncode["status"] === ERR_NO_ADMIN) { // right now the error is alerted in getFromServer, maybe will change that } else if(returncode["status"]!==200) { alertText("That song's already in the queue! Hang on!") @@ -398,19 +548,19 @@ function toggleDark(e) { qrCodeGenerate(); } -async function sha256(message) { - // Encode the message as UTF-8 - const msgBuffer = new TextEncoder().encode(message); +// async function sha256(message) { +// // Encode the message as UTF-8 +// const msgBuffer = new TextEncoder().encode(message); - // Hash the message - const hashBuffer = await crypto.subtle.digest('SHA-256', msgBuffer); +// // Hash the message +// const hashBuffer = await crypto.subtle.digest('SHA-256', msgBuffer); - // Convert ArrayBuffer to hex string - const hashArray = Array.from(new Uint8Array(hashBuffer)); - const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join(''); +// // Convert ArrayBuffer to hex string +// const hashArray = Array.from(new Uint8Array(hashBuffer)); +// const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join(''); - return hashHex; -} +// return hashHex; +// } async function adminPassEnter(e) { if (e.key == "Enter") { @@ -440,6 +590,8 @@ async function submitPerms(e) { // its not perfect if you spam click, but it gets the point across to the user let clickedBox = e.srcElement; clickedBox.checked = !clickedBox.checked; + } else { + justChangedSetting = true; } } @@ -464,10 +616,10 @@ document.addEventListener('keydown', function(e){ }}) document.getElementById("playlist-mode").style.display = "none"; document.getElementById("settings-mode").style.display = "none"; -document.getElementById("volumerange").onchange = async function() { +document.getElementById("volumerange").onchange = async function(e) { // there is no reason for this not to be a defined function // FIX THIS - let returnValue = await getFromServer({setting:"volume",level:this.value}, "settings") + let returnValue = await getFromServer({setting:"volume",level:e.target.value}, "settings") if (returnValue["status"] == ERR_NO_ADMIN) { // alertText("Error: Admin restricted action"); // there's an admin restrict alert built into getFromServer @@ -508,8 +660,9 @@ document.getElementById("songlist").addEventListener('keydown', function(e){chec document.getElementById("songlist").addEventListener('click', function(e){checkWhatSongWasClicked(e)}); //makes the controls look mostly normal on all screens, best solution i could find, idk man -let tempWidth = document.getElementById('controls').clientWidth; -document.getElementById("controls").style.marginLeft = "-"+String(parseInt(tempWidth/2))+"px"; +// replaced this with "transform" css stuff +// let tempWidth = document.getElementById('controls').clientWidth; +// document.getElementById("controls").style.marginLeft = "-"+String(parseInt(tempWidth/2))+"px"; //for my use case (my immediate family), they dont know how to set an ip //using this allows the creator of the link for, a qr code for example, to set the ip before distributing the code, and it would all work smoothly @@ -535,4 +688,43 @@ if (alertTime == "") { document.cookie = "alertTime="+alertTime+"; path=/;" } // this is the code that makes the qr code at the very start -qrCodeGenerate() \ No newline at end of file +qrCodeGenerate() + +// socket testing stuff + +socket = io("http://"+ip,{ + reconnectionAttemps: 5, + timeout: 10000, +}); + +socket.on("songAdd", function(data) { + // console.log("recieved data from songAdd"); + // console.log(data); + addToPlaylist(data); +}) + +socket.on("timeUpdate", function(data) { + // console.log("recieved data from timeUpdate"); + // console.log(data); + playlistElapsedSeconds = data["elapsedTime"]; + currentlyPlaying = data["playingState"] +}); + +socket.on("skipSong",() => { + if(justSkipped === false) { + skipInPlaylist(); + } else { + justSkipped = false; + } +}) + +socket.on("settingsChange",(data) => { + // console.log(data); + if(justChangedSetting) { + // console.log("working"); + justChangedSetting = false; + } else { + // checkSettings(); + updateSingleSetting(data); + } +}); \ No newline at end of file diff --git a/Client/styles.css b/Client/styles.css index 2c2f4c9..70bd991 100644 --- a/Client/styles.css +++ b/Client/styles.css @@ -62,6 +62,7 @@ h4 { left: 50%; bottom: 0; margin: 0 auto; + transform: translateX(-50%); background-color:var(--bg-main); } diff --git a/Server/webbyBits.py b/Server/webbyBits.py index ea876d8..25e245b 100644 --- a/Server/webbyBits.py +++ b/Server/webbyBits.py @@ -1,6 +1,7 @@ from flask import Flask from flask import request from flask_cors import CORS +from flask_socketio import SocketIO import sqlite3 as sql import vlc,threading,time,random,argparse,dotenv,os,hashlib,string # Argparse Stuff @@ -64,18 +65,45 @@ player.audio_set_volume(100) app = Flask(__name__) # because you are POSTing from another domain to this one, you need CORS CORS(app) +# Replace the star with the frontend domain if you dislike being hacked +socketio = SocketIO(app, cors_allowed_origins="*") def queueSong(song): with playlistLock: playlist.append(song) + socketio.emit("songAdd",getSongInfo(song)) + +def getSongInfo(song): + fileofDB = sql.connect("songDatabase.db") + songDatabase = fileofDB.cursor() + songDatabase.execute("SELECT * FROM songs WHERE filename = ?",[song]) + result = songDatabase.fetchall()[0] + # again, this is still using the old JSON format to avoid client changes + k = { + "title": result[1], + "artist": result[2], + "art": result[3], + "length": result[4] + } + fileofDB.close() + return {song:k} # this is a loop that plays the songs and checks for playlist changes, skips, ect. +counter = 0 +isPlaying = False def playQueuedSongs(): global skipNow global songNext global partyMode + global counter + global isPlaying while True: with playlistLock: + counter+=1 + if(counter > 2): + playingState = str(player.get_state()) == "State.Playing" + socketio.emit('timeUpdate',{"elapsedTime":player.get_time()/1000,"playingState":playingState}) + counter = 0 playerState = str(player.get_state()) endStates = ["State.Ended","State.Stopped","State.NothingSpecial"] if playlist and (playerState in endStates or skipNow == True): @@ -86,7 +114,13 @@ def playQueuedSongs(): media = vlcInstance.media_new(soundLocation+songNext) player.set_media(media) player.play() + isPlaying = True + socketio.emit("skipSong",None) elif (skipNow==True or (playerState in endStates)): + if(isPlaying): + socketio.emit("skipSong",None) + isPlaying = False + # print(playerState) # skip was pressed and there are no new songs skipNow=False songNext = None @@ -99,11 +133,16 @@ def playQueuedSongs(): # adds the random songs for party mode # the above 2 means this only applies if (a song is playing or paused) and (the queue is empty) playlist.append(result[0][0]) + socketio.emit('songAdd',getSongInfo(result[0][0])) # check for new songs every second # I just didn't want to eat too much processing looping # this also has another useful affect that skips get "queued" to only 1 per second, that way somebody usually can't skip twice accidentally time.sleep(1) +@socketio.on("connect") +def handleConnect(): + pass + @app.route("/controls", methods=['POST']) def playerControls(): # recieve control inputs (play/pause and skip) from the webUI @@ -113,10 +152,12 @@ def playerControls(): try: if recieveData["control"] == "play-pause": if ADMIN_PASS == recieveData['password'] or controlPerms["PP"]: + playingState = str(player.get_state())=="State.Playing" player.pause() - return ERR_200 + return {"error":"ok","data":{"playingState":not(playingState)}},200 else: - return ERR_NO_ADMIN + playingState = str(player.get_state())=="State.Playing" + return {"error":"Admin Restricted Action","data":{"playingState":playingState}},401 elif recieveData["control"] == "skip": if ADMIN_PASS == recieveData['password'] or controlPerms["SK"]: skipNow = True @@ -149,6 +190,9 @@ def settingsControl(): volumeLevel = int(recieveData["level"]) if(volumeLevel <= 100 and volumeLevel >= 0): volumePassed = player.audio_set_volume(volumeLevel) + if(volumePassed == 0): + # only emit a signal i the volume really changed + socketio.emit("settingsChange",{"settingToChange":"volume","newData":volumeLevel}) return {"error":"ok","data":{"volumePassed":volumePassed}},200 else: return {"error":"Invalid volume level","data":None},422 @@ -157,13 +201,16 @@ def settingsControl(): elif recieveData["setting"] == "partymode-toggle": if ADMIN_PASS == recieveData['password'] or controlPerms["PM"]: partyMode = not(partyMode) + partyModeStr = "On" if partyMode else "Off" + socketio.emit("settingsChange",{"settingToChange":"partymode","newData":partyModeStr}) return ERR_200 else: return ERR_NO_ADMIN elif recieveData["setting"] == "perms": if ADMIN_PASS == recieveData["password"]: controlPerms = recieveData["admin"] - print(recieveData["admin"]) + # print(recieveData["admin"]) + socketio.emit("settingsChange",{"settingToChange":"perms","newData":controlPerms}) return ERR_200 else: return ERR_NO_ADMIN @@ -261,8 +308,11 @@ def getPlaylist(): } tempPlaylist.append({i:k}) fileofDB.close() - - return {"error":"ok","data":tempPlaylist} + playingState = False + if(str(player.get_state())=="State.Playing"): + playingState = True + # print(playingState) + return {"error":"ok","data":{"playlist":tempPlaylist,"playingState":playingState}},200 if __name__ == "__main__": # There's not really a whole lot of point to a main function for something like this, you'd never use any of these methods @@ -271,5 +321,5 @@ if __name__ == "__main__": queueThread = threading.Thread(target=playQueuedSongs) queueThread.daemon = True queueThread.start() - app.run(host='0.0.0.0', port=portTheUserPicked) + socketio.run(app=app,host='0.0.0.0', port=portTheUserPicked) \ No newline at end of file diff --git a/readme.md b/readme.md index 60d24d4..2589f25 100644 --- a/readme.md +++ b/readme.md @@ -14,7 +14,7 @@ The client is a web application that can be hosted on any server, it need not be ### Server Setup: **Pre-setup:** If you want the songs to have art associated with them, it is all hosted on and retrieved from LastFM, and you will need to sign up for a developer app, and put your key in the database generator \ \ -The server side consists of 3 files: +The server side consists of 3 files and a directory: ``` sound/ @@ -60,7 +60,6 @@ These are specific details on each section of the app, and how to use them - Accepts Play-Pause and Skip commands - Uses port 19054 by default - Can be changed in the `.env` file - - The default port can be changed in the file - Running with `--admin (admin password)` sets an admin password for moderation on the client - ***Note: Do not reuse a password, the password is hashed before being sent over the network, but I still wouldn't bet my house on it, no security is guaranteed*** - Anyone who knows the admin password can enter it on the client and change the abilities of any non-admin users (for example to limit skipping) @@ -70,6 +69,9 @@ These are specific details on each section of the app, and how to use them - Add track to queue - Partymode toggle - Change volume + - Add duplicate track to queue + - This is a seperate toggle, but is based on the setting "Add track to queue" + - Basically if you can't add at all, you can't add a duplicate either (obviously) - When this argument is left out a random string is generated and printed in console to be the admin password (so keep the console window hidden if you don't want to use it) ### Client: @@ -89,6 +91,7 @@ From left to right: - Volume controls the VLC volume of the connected server - *Because the volume can be controlled in the client, for best usage set your device volume as high as possible and turn it down using this slider* - QR code to allow others to connect to and use the Remote + - Admin password can be set to restrict actions for general users, or avoid the set restrictions ### A quick note on the password feature diff --git a/requirements.txt b/requirements.txt index ca37329..3153761 100644 Binary files a/requirements.txt and b/requirements.txt differ diff --git a/wishlist.md b/wishlist.md index 9fb170c..e561f40 100644 --- a/wishlist.md +++ b/wishlist.md @@ -1,7 +1,6 @@ ## Wishlist *Features I would like to add, will be completed in any order* -- [x] Admin password - * Allows restricting certain features and changing permissions on the fly on the client +- [ ] Loading indicator while awaiting server stuff - [ ] Refactoring existing code - [x] Remove old comments - [ ] Update the SQL -> Server -> Client pipeline when searching and building playlist @@ -23,7 +22,10 @@ - Without a login system there's no easy way to give credits to specific clients (and a login is beyond scope of what I want to do) - Potentially a "redemption code" system, which can be tracked client side - All of this is very hackable without a server-side login. -- [ ] Websockets / some method of updating the time remaining to any client on the playlist screen - * currently the screen just grabs the "elapsed time" once when it is loaded - * websockets can re-update clients - * not actually sure if i can CORS-socket but we're sure gonna try \ No newline at end of file +- [x] Websockets / some method of updating the time remaining to any client on the playlist screen + - [x] Set a timeout to change the time (to start) + - [x] Send updates to the playlist in real time when songs are added + - [x] Update the playlist's html without destroying it (create 1 new element) + - [x] Tell clients looking at the playlist when the song has been paused (so they can pause the local timers) + - [x] Settings updates + - [x] Without re-posting the server (contain update data in websocket ping) \ No newline at end of file