diff --git a/Client/favicon.ico b/Client/favicon.ico new file mode 100644 index 0000000..bbe20ae Binary files /dev/null and b/Client/favicon.ico differ diff --git a/Client/images/placeholder.png b/Client/images/placeholder.png new file mode 100644 index 0000000..a684493 Binary files /dev/null and b/Client/images/placeholder.png differ diff --git a/Client/images/play-pause.png b/Client/images/play-pause.png new file mode 100644 index 0000000..3796891 Binary files /dev/null and b/Client/images/play-pause.png differ diff --git a/Client/images/playlist.png b/Client/images/playlist.png new file mode 100644 index 0000000..e9ff72a Binary files /dev/null and b/Client/images/playlist.png differ diff --git a/Client/images/search.png b/Client/images/search.png new file mode 100644 index 0000000..b3f699a Binary files /dev/null and b/Client/images/search.png differ diff --git a/Client/images/settings.png b/Client/images/settings.png new file mode 100644 index 0000000..bb23a10 Binary files /dev/null and b/Client/images/settings.png differ diff --git a/Client/images/skip.png b/Client/images/skip.png new file mode 100644 index 0000000..032beda Binary files /dev/null and b/Client/images/skip.png differ diff --git a/Client/index.html b/Client/index.html new file mode 100644 index 0000000..e9aee2f --- /dev/null +++ b/Client/index.html @@ -0,0 +1,90 @@ + + + + Jukebox Controller + + + + + + + + +
+

Jukebox Remote

+

Add songs to the shared playlist below!

+
+

+ +
+
+ +
+
+

Search to find songs!

+ + +
+
+ +
+
+

+ +
+
+ +
+
+

Client Settings (Saved to device)

+
+

Server IP:

+

IP of the device running the song server

+ +
+
+

Alert Time:

+

How long alerts stay on screen for (seconds)

+ +
+

Server Settings (Saved to server)

+
+

Party mode:

+

Add random songs to the queue when it is about to be empty

+ +
+
+

Volume:

+

Volume of the music

+ +
+
+
+ + settings +
+ playlist + play pause + skip + search + +
+ + + \ No newline at end of file diff --git a/Client/manifest.json b/Client/manifest.json new file mode 100644 index 0000000..7ab15d3 --- /dev/null +++ b/Client/manifest.json @@ -0,0 +1,15 @@ +{ + "name": "Jukebox Remote", + "short_name": "Jukebox Remote", + "start_url": "index.html", + "display": "standalone", + "background_color": "#eeeeee", + "theme_color": "#eeeeee", + "orientation": "portrait-primary", + "icons": [ + { + "src": "/favicon.ico", + "type": "image/ico", "sizes": "100x100" + } + ] + } \ No newline at end of file diff --git a/Client/scripts.js b/Client/scripts.js new file mode 100644 index 0000000..3aafbff --- /dev/null +++ b/Client/scripts.js @@ -0,0 +1,329 @@ +let ip +let alertTime = 2 +async function alertText(text="Song Added!") { + alertbox = document.getElementById("alert"); + alertbox.innerHTML = text; + await new Promise(r => setTimeout(r, alertTime*1000)); + alertbox.innerHTML = "" +} +// 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="") { + try{ + const response = await fetch("http://" + ip+"/"+source, { + method: "POST", + body: JSON.stringify(bodyInfo), + headers: { + "Content-type": "application/json; charset=UTF-8" + } + }); + const data = await response.json(); + return await data; + } catch(e) { + if (e == "TypeError: Failed to fetch"){ + alertText("error: NoConnect to Server (is the ip set?)") + } else { + alertText("error: " + e) + } + const response=null; + return response; + } +} + + +//cookie reader is taken from internet because cookies ae too complicated for me +//i still understand how it works though promise just i see no reason to write this from scratch +function getCookie(cname) { + let name = cname + "="; + let decodedCookie = decodeURIComponent(document.cookie); + let ca = decodedCookie.split(';'); + for(let i = 0; i "; + document.getElementById("playlist-mode").style.display = "block"; + document.getElementById("songlist-mode").style.display = "none"; + document.getElementById("settings-mode").style.display = "none"; + generateVisualPlaylist(); + } else if (buttonType == "se") { + 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") { + 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") { + await getFromServer({setting: "partymode-toggle"}, "settings") + checkSettings(true) + } + + +} + +function searchSongsEnter(e) { + if (e.keyCode == 13) { + searchSongs(document.getElementById("songsearch").value) + } +} + +async function searchSongs(searchTerm){ + let optionslist = [] + + document.getElementById("songlist").innerHTML = "" + searchResults = await getFromServer({search:searchTerm},"search").then() + for (let index in searchResults) { + optionslist.push([index,searchResults[index][0],searchResults[index][1],searchResults[index][2]]); + } + //generate the visual song list + for(let i = 0; i < optionslist.length; i++) { + let newItem = document.createElement("div"); + newItem.className = "item"; + newItem.id = optionslist[i][3]; + let image = document.createElement("img"); + try { + if (optionslist[i][2] == null) { + throw "no image lolz" + } + image.src = optionslist[i][2]; + } catch(err){ + image.src = "./images/placeholder.png"; + } + image.id = String(optionslist[i][3])+" image"; + let head3 = document.createElement("h3"); + head3.innerText = optionslist[i][0]; + let head4 = document.createElement("h4"); + head4.innerText=optionslist[i][1]; + newItem.appendChild(image); + newItem.appendChild(head3); + newItem.appendChild(head4); + document.getElementById("songlist").appendChild(newItem); + //display error if no results + + } + if (optionslist.length == 0) { + document.getElementById("songlist").innerHTML = "

We might not have that one...

"; + } +} +function alertTimeEnter(e){ + if (e.key == "Enter") { + e.preventDefault(); + alertTimeSet(document.getElementById("alerttimetextbox").value); + } +} + +function alertTimeSet(time) { + alertTime = time; + document.cookie = "alertTime="+alertTime+"; path=/;" + alertText("Alerts stay on screen for " + alertTime.toString() + " seconds") +} + +function ipSetEnter(e){ + if (e.key == "Enter") { + e.preventDefault(); + ipSetter(document.getElementById("iptextbox").value) + } +} + +function ipSetter(){ + ipBox = document.getElementById("iptextbox").value + if (ipBox == "") { + alertText("Your IP is set to "+ip) + } else { + if (ipBox.includes(":")) { + port = ipBox.slice(ipBox.indexOf(":")+1) + ip = ipBox; + document.cookie = "ip="+ip+"; path=/;" + alertText("Your IP is now set to "+ip.slice(0, ipBox.indexOf(":"))+" at port "+port) + } else { + ip = ipBox + ":19054" + document.cookie = "ip="+ip+"; path=/;" + alertText("Your IP is now set to "+ipBox+" at port 19054 (Default)") + } + } + +} + +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") { + document.getElementById("iptextbox").value = ip.slice(0,-6) + } else { + document.getElementById("iptextbox").value = ip; + } + document.getElementById("alerttimetextbox").value = alertTime + partyButtonState = document.getElementById("partymode-button").innerHTML; + x = await getFromServer({setting: "getsettings"}, "settings"); + if (!(skipServer) || partyButtonState=="N/A") { + if (x["partymode"] == false) { + document.getElementById("partymode-button").innerHTML = "Off"; + } else { + document.getElementById("partymode-button").innerHTML = "On"; + } + } else if (document.getElementById("partymode-button").innerHTML == "Off") { + document.getElementById("partymode-button").innerHTML = "On"; + } else { + document.getElementById("partymode-button").innerHTML = "Off"; + } + document.getElementById("volumerange").value = parseInt(x["volume"]) +} + +async function generateVisualPlaylist(conditions="") { + document.getElementById("playlist").innerHTML = "

"; + playlist = await getFromServer(null, "playlist"); + if (playlist.length==0){ + document.getElementById("playlist-alert").innerHTML = "Nothing's Queued..." + } else { + if (conditions=="skip-button") { + playlist.shift() + if (playlist.length==0){ + document.getElementById("playlist-alert").innerHTML = "Nothing's Queued..." + } + } + for (i in playlist) { + let newItem = document.createElement("div"); + newItem.className = "item"; + newItem.id = playlist[i]["file"]; + let image = document.createElement("img"); + try { + if (playlist[i]["art"] == null) { + throw "no image lolz" + } + image.src = playlist[i]["art"]; + } catch(err){ + image.src = "./images/placeholder.png"; + } + image.id = String(playlist[i]["file"])+" image"; + let head3 = document.createElement("h3"); + head3.innerText = playlist[i]["title"]; + let head4 = document.createElement("h4"); + head4.innerText=playlist[i]["artist"]; + let head5 = document.createElement("h5"); + let timeLeft =document.createElement("h5"); + timeLeft.style.fontWeight = 100; + try { + if (i == 0) { + 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){ + console.log(err) + } + 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); + } + } +} + +async function submitSong(songid) { + getFromServer({song: songid}, "songadd") + alertText("Added to Queue") +} +function checkWhatSongWasClicked(e) { + itemId = e.srcElement.id; + if ((itemId.length-itemId.lastIndexOf("image") == 5) && itemId.lastIndexOf("image")!=-1) { + itemId = itemId.slice(0,-6) + } + //i feel like later dylan won't apreciate this + //one of my files was "file.MP3" so it didn't work + if (itemId.slice(-4).toLowerCase() == ".mp3") { + submitSong(itemId); + } +} + +let optionslist = [] + +//sets all de stuff for buttons +document.addEventListener('keydown', function(e){ + if (e.key == "/"){ + document.getElementById("title").scrollIntoView(); + document.getElementById("songsearch").select(); + e.preventDefault() +}}) +document.getElementById("playlist-mode").style.display = "none"; +document.getElementById("settings-mode").style.display = "none"; +//.ontouch for mobile?? +document.getElementById("volumerange").onchange = function() { + getFromServer({setting:"volume",level:this.value}, "settings") + if (this.value == 0) { + alertText("The volume is now set to 0 (Pause?)") + } else { + alertText("The volume is now set to " + this.value.toString()) + } + +} +document.getElementById("settings-button").addEventListener('click',function(){controlButton("st")}); +document.getElementById("play-pause-button").addEventListener('click', function(){controlButton("pp")}); +document.getElementById("playlist-button").addEventListener('click', function(){controlButton("pl")}); +document.getElementById("search-button").addEventListener('click', function(){controlButton("se")}); +document.getElementById("skip-button").addEventListener('click',function(){controlButton("sk")}); +document.getElementById("go-search").addEventListener('click', function(){searchSongs(document.getElementById("songsearch").value)}) +document.getElementById("songsearch").addEventListener('keydown', function(e){searchSongsEnter(e)}); +document.getElementById("iptextbox").addEventListener('keydown', function(e){ipSetEnter(e)}); +document.getElementById("alerttimetextbox").addEventListener('keydown', function(e){alertTimeEnter(e)}); +document.getElementById("partymode-button").addEventListener('click',function(){controlButton("pm")}) +//sets the fact that clicking a song needs to return its id to the function to find it +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"; +//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 +//example (http://192.168.1.100:8000/?ip=192.168.1.100:19054 sets the ip to the same host at the default port) +//the port must be set manually using this method, but only has to be done once for the url that ends up being shared +let params = new URLSearchParams(location.search); +//tries the url first, then the cookie, then the default +ip = params.get("ip") +if (ip == null || ip=="") { + ip=getCookie("ip") +} +console.log(ip) +if (ip==null || ip==""){ + ip = "" +} +document.cookie = "ip="+ip+"; path=/;" + + +alertTime = getCookie("alertTime") +document.getElementById("alerttimetextbox").value = alertTime +if (alertTime == "") { + alertTime = 2; + document.cookie = "alertTime="+alertTime+"; path=/;" +} \ No newline at end of file diff --git a/Client/styles.css b/Client/styles.css new file mode 100644 index 0000000..71db5a7 --- /dev/null +++ b/Client/styles.css @@ -0,0 +1,170 @@ +/* testing */ + +/* Things that are always visible */ + +body { + background-color: #EEEEEE; +} +* { + font-family: 'arial'; +} +.italic { + font-style: italic; +} +h4 { + font-weight: 100; +} +.clear{ + clear: both; + display: block; + content: ""; + width: 100%; + } +.controls { + max-width: 550px; + min-width: 300px; + position: fixed; + width:100%; + left: 50%; + bottom: 0; + margin: 0 auto; + background-color:inherit; +} + +.alert { + position: fixed; + bottom: 10%; + width: 100%; + text-align: center; + z-index: 1000; + background-color: #EEEEEEd6; +} + +.settings-button { + width: 15%; + max-width: 90px; + position:fixed; + top:0; + right:0; + margin: 3px; + background:inherit; + /* This is a circle background for the circle settings button + So it can display over other text and such */ + border-radius: 50%; +} + +.control-button{ + width:20%; + max-width: 110px; + margin: auto 2%; +} + +.intro { + width: 300px; + margin: auto; + text-align: center; +} + +/* Songlist stuff */ +.songlist { + width: 60%; + min-width: 300px; + margin:auto auto 150px; + display: flex; + flex-wrap: wrap; +} + +.songlist > .item{ + border: 1px solid #333333; + width:30%; + max-width: 200px; + margin: 5px auto; + min-width: 100px; + background-color: inherit; +} + +.songlist > .item > img{ + max-width:200px; + width:100% +} + +.songlist > .item > h3, .songlist > .item > h4{ + margin-left: 2px; + margin-right: 2px; +} + +.searchbox-holder { + width: 20%; + margin: 20px auto 0; + min-width: 250px; + +} +.searchbox { + width: 65%; + margin: 1px; +} +.go-search { + width: 20%; + min-width: 50px; +} +/* playlist mode stuff */ + +.playlist { + width: 60%; + min-width: 300px; + margin:auto auto 150px; +} + +.playlist > .item{ + border: 1px solid #333333; + display: flex; + max-width: 50em; + min-width: 200px; + margin: 5px auto; + height: auto; +} + +.playlist > .item > .text { + display: inline-block; + margin: 0px 3px; +} + +.playlist > .item > img { + display: inline-flex; + max-width: 100px; + width:30%; + margin: 0; + aspect-ratio: 1/1; +} + +.playlist > .item > .text > * { + margin:5% 2px; +} + +/* settings stuff */ + +.settings { + width: 95%; + margin: auto auto 150px; + max-width: 600px; +} + +.settings > .item { + margin-left: 10%; + width:fit-content; + +} + +.settings > .item:not(:last-child) { + padding-bottom: 10px; + border-bottom: 1px solid #333333; +} + +.settings > .lastSet1 { + border-bottom: 0; +} + +#volumerange { + background-color: #4477AA; + color: #4477ff; +} \ No newline at end of file diff --git a/Server/databaseGenerator.py b/Server/databaseGenerator.py new file mode 100644 index 0000000..fac4221 --- /dev/null +++ b/Server/databaseGenerator.py @@ -0,0 +1,78 @@ +import os +from mutagen.easyid3 import EasyID3 +from mutagen.mp3 import MP3 +import requests, ast, time, math, argparse, json + +loading = ["-","\\","|","/"] + +apikeylastfm="YourLastfmKeyHere" + +songFiles = os.listdir(r'./sound') +parser=argparse.ArgumentParser(description="Options for the generation of the song database") +parser.add_argument('-m', '--mode', help='new/update: Remake database or update current', default= "update") +parser.add_argument('-a', '--art', help="True/False: Add art to the database using LastFm (takes minimum 0.25s per song)", default="True") +args = parser.parse_args() +if args.mode == "update": + try: + with open('songDatabase.json', 'r') as handle: + songDatabaseList = json.load(handle) + except: + songDatabaseList=[] + + for i in songDatabaseList: + try: + songFiles.index(i["file"]) != -1 + except: + print("deleted: " + i["file"] + " from database") + songDatabaseList.pop(songDatabaseList.index(i)) + + for i in songDatabaseList: + songFiles.pop(songFiles.index(i["file"])) + print("new songs: " + str(songFiles)) +elif args.mode=="new": + songDatabaseList = [] + +if args.art.lower() == "true": + x = len(songFiles)*0.25 + if x > 60: + print("ETA "+ str(x/60) + " minutes") + else: + print("ETA "+ str(x) + " seconds") + +for i in songFiles: + try: + song = EasyID3("sound/"+i) + title = song['title'][0] + artist = song['artist'][0] + except: + try: + song = i.split("_") + title = song[0] + artist = song[1].split(".")[0] + except: + title = i + artist = None + if args.art.lower() == "true": + try: + image = ast.literal_eval(requests.post(url="http://ws.audioscrobbler.com/2.0/?method=track.getInfo&api_key="+apikeylastfm+"&artist="+artist+"&track="+title+"&format=json").text)["track"]["album"]["image"][1]["#text"] + if image == "": + image = ast.literal_eval(requests.post(url="http://ws.audioscrobbler.com/2.0/?method=track.getInfo&api_key="+apikeylastfm+"&artist="+artist+"&track="+title+"&format=json").text)["track"]["album"]["image"][2]["#text"] + if image == "": + image = None + time.sleep(0.25) + except: + image=None + else: + image=None + try: + length = math.ceil(MP3("sound/"+i).info.length) + except: + length = 0 + if len(songFiles) != 1: + index = (songFiles.index(i))%4 + print("\r" + str(loading[index] + str(math.floor((songFiles.index(i)/(len(songFiles)-1))*100))+ "%"), end='', flush=True) + # each "song" is stored as a dictionary containing the below stuff, and each dictionary is put into a list + songDatabaseList.append({"file":i,"title":title,"artist":artist,"art":image,"length":length}) + +with open('songDatabase.json', 'w') as handle: + json.dump(songDatabaseList, handle) diff --git a/Server/webbyBits.py b/Server/webbyBits.py new file mode 100644 index 0000000..c14db2b --- /dev/null +++ b/Server/webbyBits.py @@ -0,0 +1,128 @@ +from flask import Flask +from flask import request +from flask_cors import CORS +import json,vlc,csv,threading,time,random +random.seed() +global partyMode +global skipNow +global songNext +partyMode = False +songNext = None +skipNow = False +playlist = [] +playlistLock = threading.Lock() +fakeplayer = vlc.Instance() +player = fakeplayer.media_player_new() +# for client side volume to work as well as possible, set system volume to 100 and control in app +player.audio_set_volume(100) +app = Flask(__name__) +CORS(app) +with open('songDatabase.json', 'r') as handle: + songDatabaseList = json.load(handle) + +def queueSong(song): + with playlistLock: + playlist.append(song) +# this is a loop that plays the songs and checks for playlist changes, skips, ect. +def playQueuedSongs(): + global skipNow + global songNext + global partyMode + while True: + with playlistLock: + z = str(player.get_state()) + + if playlist and (z == "State.Ended" or z== "State.Stopped" or z == "State.NothingSpecial" or skipNow == True): + player.stop() + skipNow = False + songNext = playlist.pop(0) + media = fakeplayer.media_new("sound/"+songNext) + player.set_media(media) + player.play() + elif (len(playlist) == 0) and skipNow==True: + skipNow=False + songNext = None + player.stop() + elif (len(playlist) == 0) and (z == "State.Ended" or z == "State.NothingSpecial" or z=="State.Stopped"): + songNext = None + elif (len(playlist)<1) and (partyMode == True): + playlist.append(random.choice(songDatabaseList)["file"]) + time.sleep(1) + +queueThread = threading.Thread(target=playQueuedSongs) +queueThread.daemon = True +queueThread.start() + +@app.route("/controls", methods=['POST']) +def playerControls(): + global skipNow + global media + global partyMode + recieveData=request.get_json(force=True) + if recieveData["control"] != None: + if recieveData["control"] == "play-pause": + player.pause() + return "200" + elif recieveData["control"] == "skip": + skipNow = True + # print(str(player.get_state())) + return "200" + else: + return "400" + +@app.route("/settings", methods=['POST']) +def settingsControl(): + global partyMode + recieveData = request.get_json(force=True) + if recieveData["setting"] == "volume": + player.audio_set_volume(int(recieveData["level"])) + return "200" + elif recieveData["setting"] == "getsettings": + x = {"partymode":partyMode,"volume":player.audio_get_volume()} + return x + elif recieveData["setting"] == "partymode-toggle": + partyMode = not(partyMode) + return "200" + else: + return "400" +@app.route("/search", methods=['POST']) +def searchSongDB(): + recieveData=request.get_json(force=True) + # the way i put the data in a list was really dumb looking back, i could and should have used a list of dictioaries like i was before + # i might try to change it but this layout is embedded deep in the client + tempData = {} + for i in songDatabaseList: + if ((i["title"].lower().find(recieveData['search'].lower())) > -1) or (recieveData['search'] == ""): + tempData[i["title"]] = [i["artist"],i["art"],i["file"]] + try: + if (i["artist"].lower().find(recieveData['search'].lower()) > -1): + tempData[i["title"]] = [i["artist"],i["art"],i["file"]] + except: + pass + + return tempData + +@app.route("/songadd", methods=["POST"]) +def songadd(): + recieveData=request.get_json(force=True) + queueSong(recieveData['song']) + return "200" +@app.route("/playlist", methods=["POST"]) +def getPlaylist(): + global songNext + tempPlaylist = [] + for k in songDatabaseList: + if k["file"] == songNext: + temp = k.copy() + temp["playing"] = True + temp["time"] = player.get_time()/1000 + tempPlaylist.append(temp) + for i in playlist: + for j in songDatabaseList: + if j["file"] == i: + tempPlaylist.append(j) + return tempPlaylist + +if __name__ == "__main__": + app.run(host='0.0.0.0', port='25565') + \ No newline at end of file