diff --git a/README.md b/README.md index 65f5fc8..84edd62 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,40 @@ -# team-1 +# ChillBox -Test +> *A project by Pausetta.org, Simone Tesini, Francesco De Carlo, Leonardo Segala, Matteo Peretto* + +**ChillBox** is a web app that lets you create a shared radio station with a democratic voting system, so everyone gets to enjoy their favorite music together. +Perfect for venues like swimming pools, cafΓ©s, or even lively parties. + +--- + +## 🎡 Voting System + +Joining a ChillBox room is easy: users can either scan the QR code displayed on the host screen or use GPS to find nearby rooms. +Hosts can set a location range, ensuring only people physically present can add or vote for songs. + +--- + +## πŸ“Š Ranking Algorithm + +ChillBox uses a smart ranking algorithm to decide what plays next. The score of each song is based on: + +* Votes from users +* How recently similar songs (same genre or artist) have been played (less = better) +* A bit of randomness to keep things interesting +* A strong penalty for songs played too recently + +--- + +## πŸ‘ Hands-Off Experience + +ChillBox is designed to be almost entirely hands-free. +Once the host sets up a room and optionally connects a screen or projector +(to show the current track, QR code, etc.), ChillBox takes care of the rest. + +ChillBox comes with built-in automatic moderation to keep the music fair and on-theme. + +* Users can’t vote for the same song multiple times. +* A cooldown prevents users from spamming song requests. +* Hosts can define preferred genres and overall mood, so no one can hijack your chill beach vibes with unexpected death metal. + +That said, hosts still have access to essential controls, like pause and skip, if needed. diff --git a/SPEECH.md b/SPEECH.md new file mode 100644 index 0000000..2ab507f --- /dev/null +++ b/SPEECH.md @@ -0,0 +1,15 @@ +# speech + +## Home screen +We start here in the home page. +We can see this little radar animation, which means that the app is looking for nearby ChillBox rooms to join. +It uses GPS for this feature. + +## Join room +When we join a room, the server checks our location and checks if it's within a specified range. +That way, you must physically be in the location to actually be able to add new songs + +## Talk about the host +As you can see here (and hear) on the left, the host is already playing some music. +Now i will add a song on the client side and it will pop up in the list. + diff --git a/backend/Dockerfile b/backend/Dockerfile index c6a1412..3390030 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -12,5 +12,6 @@ RUN pip install --no-cache-dir -r requirements.txt COPY . . EXPOSE 5000 -CMD ["flask", "--app", "src.app", "run", "--debug", "--host=0.0.0.0"] +# CMD ["flask", "--app", "src.app", "run", "--debug", "--host=0.0.0.0"] +CMD ["python3", "src/app.py"] # flask --app src.app run --host=0.0.0.0 --port=5001 --debug diff --git a/backend/requirements.txt b/backend/requirements.txt index 57b0b9d..cb18a70 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -2,4 +2,7 @@ Flask==3.1.0 flask-cors flask-socketio dotenv -requests \ No newline at end of file +requests +qrcode +Pillow +eventlet>=0.33 diff --git a/backend/src/app.py b/backend/src/app.py index 96690e5..28c66e9 100644 --- a/backend/src/app.py +++ b/backend/src/app.py @@ -1,27 +1,44 @@ +import uuid +from dataclasses import asdict + import dotenv from connect import get_connection from flask import Flask, Response, jsonify, request from flask_cors import CORS -from flask_socketio import SocketIO, emit +from flask_socketio import SocketIO, join_room, leave_room +from gps import Coordinates, distance_between_coords +from qrcode_gen import generate_qr +from room import Room +from song import Song, add_song_in_db, get_song_by_title_artist, get_song_by_uuid, init_db +from song_fetch import query_search, yt_get_audio_url, yt_search_song from state import State -from .room import Room -from .song import init_db - -# from .song_fetch import * - dotenv.load_dotenv() app = Flask(__name__) app.config["SECRET_KEY"] = "your_secret_key" -socketio = SocketIO(app) +socketio = SocketIO(app, cors_allowed_origins="*", path="/ws") CORS(app) + db_conn = get_connection() -state = State(app, db_conn.cursor()) +state = State(app, socketio, db_conn.cursor()) init_db(state.db) +state.rooms[1000] = Room( + id=1000, + coord=Coordinates(46.6769043, 11.1851585), + name="Lido Scena", + pin=1234, + tags=set(["chill", "raggaetton", "spanish", "latino", "mexican", "rock"]), + range_size=150, + songs={}, + history=[], + playing=[], + playing_idx=0, +) + def error(msg: str, status: int = 400) -> Response: res = jsonify({"success": False, "error": msg}) @@ -29,10 +46,34 @@ def error(msg: str, status: int = 400) -> Response: return res +@socketio.on("connect") +def handle_connection(): + print("somebody connected to socket.io", flush=True) + + +@socketio.on("disconnect") +def handle_disconnection(): + print("somebody disconnected from socket.io", flush=True) + + +@socketio.on("join_room") +def on_join(data): + room = data["id"] + join_room(room) + print(f"somebody joined {room=}", flush=True) + + +@socketio.on("leave_room") +def on_leave(data): + room = data["id"] + leave_room(room) + print(f"somebody left {room=}", flush=True) + + @app.get("/api/join") def join(): room_id = request.args.get("room") - code = request.args.get("code") + code = request.args.get("pin") if room_id is None: return error("Missing room id") @@ -40,8 +81,22 @@ def join(): if (room := state.rooms.get(int(room_id))) is None: return error("Invalid room") - if room.pin is not None and room.pin != code: - return error("Invalid code") + if room.pin is not None: + if code is None: + return error("Missing code") + if int(room.pin) != int(code): + return error("Invalid code") + + distance = distance_between_coords( + lhs=room.coord, + rhs=Coordinates( + latitude=float(request.args["lat"]), + longitude=float(request.args["lon"]), + ), + ) + + if distance > room.range_size: + return error("You are not within the room range") return {"success": True, "ws": f"/ws/{room_id}"} @@ -54,7 +109,7 @@ def queue(): if (room := state.rooms.get(int(room_id))) is None: return error("Invalid room") - return {"success": True, "queue": room.playing} + return {"success": True, "queue": room.playing, "index": room.playing_idx} @app.post("/api/queue/next") @@ -69,13 +124,15 @@ def queue_next(): if room.playing_idx >= len(room.playing): ## queue ended + room.renew_queue() + ended = True + else: + ended = False - # room.renew_queue() - emit("update_songs", {"songs": [1, 2, 3]}, broadcast=True, namespace="/") + data = {"success": True, "ended": ended, "index": room.playing_idx, "queue": [asdict(s) for s in room.playing]} + state.socketio.emit("queue_update", data, to=str(room.id)) - return {"success": True, "ended": True, "index": room.playing_idx, "queue": room.playing} - - return {"success": True, "ended": False, "index": room.playing_idx} + return data @app.post("/api/room/new") @@ -86,6 +143,9 @@ def room_new(): if (room_cords := request.args.get("coords")) is None: return error("Missing room coords") + if (room_range := request.args.get("range")) is None: + return error("Missing room range") + if room_pin := request.args.get("pin"): room_pin = int(room_pin) else: @@ -94,8 +154,9 @@ def room_new(): lat, lon = room_cords.split(",") room = Room( - id=max(state.rooms or [0]) + 1, # - coord=(float(lat), float(lon)), + id=max(state.rooms or [0]) + 1, + coord=Coordinates(float(lat), float(lon)), + range_size=int(room_range), name=room_name, pin=room_pin, tags=set([tag for tag in request.args.get("tags", "").split(",") if tag]), @@ -112,16 +173,140 @@ def room_new(): @app.get("/api/room") def room(): + lat = request.args.get("lat") + lon = request.args.get("lon") + + if lat and lon: + user_coords = Coordinates(latitude=float(lat), longitude=float(lon)) + else: + return error("Missing user coordinates") + return [ { "id": room.id, "name": room.name, "private": room.pin is not None, "coords": room.coord, + "range": room.range_size, + "distance": d, } for room in state.rooms.values() + if (d := distance_between_coords(user_coords, room.coord)) <= room.range_size ] +@app.post("/api/addsong") +def add_song(): + if (room_id := request.args.get("room")) is None: + return error("Missing room id") + + if (room := state.rooms.get(int(room_id))) is None: + return error("Invalid room") + + if (query := request.args.get("query")) is None: + return error("Missing query") + + if (info := query_search(query)) is None: + return error("Search failed") + + if (song := get_song_by_title_artist(info.title, info.artist)) is None: + ## song not found, downolad from YT + yt_video_id = yt_search_song(info.title, info.artist) + + if yt_video_id is None: + return error("No video found on youtube") + + ## add in DB + song = Song( + uuid=str(uuid.uuid4()), + title=info.title, + artist=info.artist, + tags=info.tags, + image_id=info.img_id, + youtube_id=yt_video_id, + ) + + add_song_in_db(song) + + if len(room.tags) > 0: + tag_ok = False + for tag in song.tags: + if tag in room.tags: + tag_ok = True + + if not tag_ok: + return error("Song genre does not belong to this room") + + ## add the song in the room if does not exists + if song.uuid not in room.songs: + room.songs[song.uuid] = (song, 1) # start with one vote + socketio.emit("new_song", {"song": asdict(song) | {"upvote": 1}}, to=str(room.id)) + + return {"success": True, "song": song} + + +@app.get("/api/room/suggestions") +def get_room_suggestion(): + if (room_id := request.args.get("room")) is None: + return error("Missing room id") + + if (room := state.rooms.get(int(room_id))) is None: + return error("Invalid room id") + + return {"success": True, "songs": [asdict(song) | {"upvote": score} for song, score in room.songs.values()]} + + +@app.post("/api/song/voting") +def post_song_vote(): + if (room_id := request.args.get("room")) is None: + return error("Missing room id") + + if (room := state.rooms.get(int(room_id))) is None: + return error("Invalid room id") + + if (song_id := request.args.get("song")) is None: + return error("Missing song id") + + if (song_info := room.songs.get(song_id)) is None: + return error("Invalid song id") + + ## update the song + room.songs[song_id] = (song_info[0], song_info[1] + int(request.args["increment"])) + socketio.emit("new_vote", {"song": asdict(song_info[0]) | {"upvote": song_info[1]}}) + + return {"success": True} + + +@app.get("/api/room/qrcode") +def room_qrcode(): + if (room_id := request.args.get("room")) is None: + return error("Missing room id") + + if (pin := request.args.get("pin")) is not None: + pin = int(pin) + + stream = generate_qr( + base_uri="https://chillbox.leoinvents.com", + room_id=int(room_id), + pin=pin, + ) + + return Response(stream, content_type="image/jpeg") + + +@app.get("/api/song/audio") +def get_audio_url(): + if (song_uuid := request.args.get("song")) is None: + return error("Missing song id") + + if (song := get_song_by_uuid(song_uuid)) is None: + return error("Song not found") + + if (url := yt_get_audio_url(song.youtube_id)) is None: + return error("Cannot get audio url") + + return {"success": True, "url": url} + + if __name__ == "__main__": - socketio.run(app, debug=True) + socketio.run(app, host="0.0.0.0", port=5000, debug=True) diff --git a/backend/src/gps.py b/backend/src/gps.py new file mode 100644 index 0000000..98bda0c --- /dev/null +++ b/backend/src/gps.py @@ -0,0 +1,29 @@ +import math +from dataclasses import dataclass + + +@dataclass +class Coordinates: + latitude: float + longitude: float + + +def distance_between_coords(lhs: Coordinates, rhs: Coordinates) -> float: + R = 6371000 # Earth radius in meters + + def to_rad(deg): + return (deg * math.pi) / 180 + + d_lat = to_rad(rhs.latitude - lhs.latitude) + d_lon = to_rad(rhs.longitude - lhs.longitude) + + a = (d_lat / 2) ** 2 + (to_rad(lhs.latitude) * to_rad(rhs.latitude)) * (d_lon / 2) ** 2 + + c = 2 * (a**0.5) / ((1 - a) ** 0.5) + + return R * c # distance in meters + + +def is_within_range(my_coords: Coordinates, target_coords: Coordinates, max_range: float) -> bool: + distance = distance_between_coords(my_coords, target_coords) + return distance <= max_range diff --git a/backend/src/qrcode_gen.py b/backend/src/qrcode_gen.py new file mode 100644 index 0000000..e4caffe --- /dev/null +++ b/backend/src/qrcode_gen.py @@ -0,0 +1,32 @@ +import qrcode +import urllib.parse +from io import BytesIO + + +def create_login_url(base_uri: str, room_id: int, pin: int | None) -> str: + parsed = urllib.parse.urlparse(base_uri) + + params = { + "room": room_id, + } + + if pin is not None: + params["pin"] = pin + + parsed = parsed._replace(path="join", query=urllib.parse.urlencode(params)) + + return urllib.parse.urlunparse(parsed) + + +def generate_qr(base_uri: str, room_id: int, pin: int | None) -> BytesIO: + url = create_login_url(base_uri, room_id, pin) + + qr = qrcode.make(url) + + out = BytesIO() + + qr.save(out, format="jpeg") + + out.seek(0) + + return out diff --git a/backend/src/room.py b/backend/src/room.py index bee8b7f..cb775b6 100644 --- a/backend/src/room.py +++ b/backend/src/room.py @@ -1,7 +1,8 @@ import random from dataclasses import dataclass -from .song import Song +from gps import Coordinates +from song import Song USER_SCORE_WEIGHT = 0.7 ARTIST_WEIGHT = 0.1 @@ -30,15 +31,17 @@ class Rank: @dataclass class Room: id: int - coord: tuple[float, float] + coord: Coordinates name: str pin: int | None tags: set[str] + range_size: int # in meters ?? songs: dict[str, UserScoredSong] # all songs + user score (the playlist) history: list[Song] # all songs previously played - playing: list[Song] # queue + ## playing queue info + playing: list[Song] playing_idx: int def renew_queue(self): @@ -124,6 +127,7 @@ def test_algo(): "test", None, set(["rock", "rap"]), + 100, { "paulham": (songs[0], 7), "cisco": (songs[1], 5), diff --git a/backend/src/song.py b/backend/src/song.py index 21e6ef1..f95609b 100644 --- a/backend/src/song.py +++ b/backend/src/song.py @@ -1,13 +1,13 @@ from dataclasses import dataclass from sqlite3 import Cursor -from .connect import get_connection +from connect import get_connection def init_db(db: Cursor): db.execute(""" CREATE TABLE IF NOT EXISTS songs ( - mbid TEXT PRIMARY KEY, + uuid TEXT PRIMARY KEY, title TEXT NOT NULL, artist TEXT NOT NULL, tags TEXT NOT NULL, @@ -17,9 +17,9 @@ def init_db(db: Cursor): """) -@dataclass(frozen=True) +@dataclass(frozen=True, slots=True, kw_only=True) class Song: - mbid: str + uuid: str title: str artist: str tags: list[str] @@ -27,30 +27,45 @@ class Song: youtube_id: str -def get_song_by_mbid(mbid: str) -> Song | None: +def get_song_by_uuid(uuid: str) -> Song | None: conn = get_connection() cursor = conn.cursor() - cursor.execute("SELECT * FROM songs WHERE mbid = ?", (mbid,)) + cursor.execute("SELECT * FROM songs WHERE uuid = ?", (uuid,)) row = cursor.fetchone() conn.close() if row is None: return None - song = Song(mbid=row["mbid"], title=row["title"], artist=row["artist"], tags=row["tags"].split(","), image_id=row["lastfm_image_id"], youtube_id=row["youtube_id"]) + song = Song(uuid=row["uuid"], title=row["title"], artist=row["artist"], tags=list(filter(None, row["tags"].split(","))), image_id=row["lastfm_image_id"], youtube_id=row["youtube_id"]) return song -def add_song(song: Song): +def get_song_by_title_artist(title: str, artist: str) -> Song | None: + conn = get_connection() + cursor = conn.cursor() + cursor.execute("SELECT * FROM songs WHERE title = ? AND artist = ?", (title, artist)) + row = cursor.fetchone() + conn.close() + + if row is None: + return None + + song = Song(uuid=row["uuid"], title=row["title"], artist=row["artist"], tags=list(filter(None, row["tags"].split(","))), image_id=row["lastfm_image_id"], youtube_id=row["youtube_id"]) + return song + + +def add_song_in_db(song: Song) -> None: conn = get_connection() cursor = conn.cursor() cursor.execute( """ - INSERT OR REPLACE INTO songs (mbid, title, artist, tags, lastfm_image_id, youtube_id) + INSERT OR REPLACE INTO songs (uuid, title, artist, tags, lastfm_image_id, youtube_id) VALUES (?, ?, ?, ?, ?, ?) - """, - (song.mbid, song.title, song.artist, ",".join(song.tags), song.image_id, song.youtube_id), + """, + (song.uuid, song.title, song.artist, ",".join(song.tags), song.image_id, song.youtube_id), ) # Updates song info if it already exists + conn.commit() conn.close() diff --git a/backend/src/song_fetch.py b/backend/src/song_fetch.py index 7a1cad4..905d126 100644 --- a/backend/src/song_fetch.py +++ b/backend/src/song_fetch.py @@ -1,14 +1,24 @@ -import requests -import urllib.parse -import os.path import os +import os.path import sys +import urllib.parse +from dataclasses import dataclass + +import requests sys.path.append("/yt-dlp") import yt_dlp -def lastfm_search(query: str) -> tuple[str, str]: +@dataclass +class SongInfo: + artist: str + title: str + img_id: str + tags: list[str] + + +def _lastfm_search(query: str) -> tuple[str, str] | None: response = requests.get( url="https://ws.audioscrobbler.com/2.0/?method=track.search&format=json", params={"limit": 5, "track": query, "api_key": os.environ["LASTFM_API_KEY"]}, @@ -16,12 +26,15 @@ def lastfm_search(query: str) -> tuple[str, str]: assert response.status_code == 200 - track_info = response.json()["results"]["trackmatches"]["track"][0] + tracks = response.json()["results"]["trackmatches"]["track"] + if len(tracks) == 0: + return None + track_info = tracks[0] return track_info["name"], track_info["artist"] -def lastfm_getinfo(name: str, artist: str) -> tuple[str, str, str, str, list[str]]: # ( id, image_id, tags ) +def _lastfm_getinfo(name: str, artist: str) -> tuple[str, list[str]]: # ( image_id, tags ) response = requests.get( url="https://ws.audioscrobbler.com/2.0/?method=track.getInfo&format=json", params={ @@ -33,16 +46,48 @@ def lastfm_getinfo(name: str, artist: str) -> tuple[str, str, str, str, list[str track_info = response.json()["track"] - image_url = urllib.parse.urlparse(track_info["album"]["image"][0]["#text"]) + image_id = "" + if "album" in track_info: + image_url = urllib.parse.urlparse(track_info["album"]["image"][0]["#text"]) + image_id = os.path.splitext(os.path.basename(image_url.path))[0] + else: + print("this song haas no image", flush=True) return ( - track_info["mbid"], + # track_info["mbid"], + image_id, [t["name"] for t in track_info["toptags"]["tag"]], - os.path.splitext(os.path.basename(image_url.path))[0], ) -def download_song_mp3(name: str, artist: str) -> tuple[str, str] | None: # ( id, audio ) +def _yt_search(query: str) -> tuple[str, str]: + ydl_opts = { + "format": "bestaudio", + "default_search": "ytsearch1", + "outtmpl": "%(title)s.%(ext)s", + "skip_download": True, + } + + with yt_dlp.YoutubeDL(ydl_opts) as ydl: + info = ydl.extract_info(query, download=False) + + first = info["entries"][0] + + return first["track"], first["artists"][0] + + +def query_search(query: str) -> SongInfo | None: + res = _lastfm_search(query) + if res is None: + return None + + name, artist = res + img_id, tags = _lastfm_getinfo(name, artist) + + return SongInfo(artist=artist, title=name, img_id=img_id, tags=tags) + + +def yt_search_song(name: str, artist: str) -> str | None: # video id ydl_opts = { "format": "bestaudio", "default_search": "ytsearch1", @@ -53,12 +98,30 @@ def download_song_mp3(name: str, artist: str) -> tuple[str, str] | None: # ( id with yt_dlp.YoutubeDL(ydl_opts) as ydl: info = ydl.extract_info(f"{name!r} - {artist!r}", download=False) - first_entry = info["entries"][0] + if len(info["entries"]) == 0: + return None - video_id = first_entry["id"] + return info["entries"][0]["id"] + + +def yt_get_audio_url(video_id) -> str | None: # audio url + ydl_opts = { + "format": "bestaudio", + "default_search": "ytsearch1", + "outtmpl": "%(title)s.%(ext)s", + "skip_download": True, + } + + with yt_dlp.YoutubeDL(ydl_opts) as ydl: + info = ydl.extract_info(video_id, download=False) + + if "entries" not in info: + return info["url"] + + first_entry = info["entries"][0] for fmt in first_entry["formats"]: if "acodec" in fmt and fmt["acodec"] != "none": - return video_id, fmt["url"] + return fmt["url"] return None diff --git a/backend/src/state.py b/backend/src/state.py index 7778117..73d05d2 100644 --- a/backend/src/state.py +++ b/backend/src/state.py @@ -1,13 +1,15 @@ -from dataclasses import dataclass +from dataclasses import dataclass, field from sqlite3 import Cursor from flask import Flask +from flask_socketio import SocketIO -from .room import Room +from room import Room @dataclass class State: app: Flask + socketio: SocketIO db: Cursor - rooms: dict[int, Room] = {} # { room_id: room, ... } + rooms: dict[int, Room] = field(default_factory=dict) # { room_id: room, ... } diff --git a/docker-compose.yml b/docker-compose.yml index 3b05616..6e6c7ec 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,6 +8,8 @@ services: volumes: - ./backend:/app - ./.data:/app/.data + env_file: + - .env frontend: build: ./frontend diff --git a/frontend/package-lock.json b/frontend/package-lock.json index b3ecdec..a620e77 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -7,6 +7,11 @@ "": { "name": "frontend", "version": "0.0.1", + "dependencies": { + "@lucide/svelte": "^0.536.0", + "socket.io-client": "^4.8.1", + "zod": "^4.0.14" + }, "devDependencies": { "@sveltejs/adapter-auto": "^6.0.0", "@sveltejs/kit": "^2.22.0", @@ -26,7 +31,6 @@ "version": "2.3.0", "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", - "dev": true, "license": "Apache-2.0", "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", @@ -495,7 +499,6 @@ "version": "0.3.12", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.12.tgz", "integrity": "sha512-OuLGC46TjB5BbN1dH8JULVVZY4WTdkF7tV9Ys6wLL1rubZnCMstOhNHueU5bLCrnRuDhKPDM4g6sw4Bel5Gzqg==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", @@ -506,7 +509,6 @@ "version": "3.1.2", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, "license": "MIT", "engines": { "node": ">=6.0.0" @@ -516,20 +518,27 @@ "version": "1.5.4", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.4.tgz", "integrity": "sha512-VT2+G1VQs/9oz078bLrYbecdZKs912zQlkelYpuf+SXF+QvZDYJlbx/LSx+meSAwdDFnF8FVXW92AVjjkVmgFw==", - "dev": true, "license": "MIT" }, "node_modules/@jridgewell/trace-mapping": { "version": "0.3.29", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.29.tgz", "integrity": "sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, + "node_modules/@lucide/svelte": { + "version": "0.536.0", + "resolved": "https://registry.npmjs.org/@lucide/svelte/-/svelte-0.536.0.tgz", + "integrity": "sha512-YAeoWU+0B/RriFZZ3wHno1FMkbrVrFdityuo2B0YuphD0vtJWXStzZkWLGVhT3jMb7zhugmhayIg+gI4+AZu1g==", + "license": "ISC", + "peerDependencies": { + "svelte": "^5" + } + }, "node_modules/@polka/url": { "version": "1.0.0-next.29", "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", @@ -817,11 +826,16 @@ "win32" ] }, + "node_modules/@socket.io/component-emitter": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@socket.io/component-emitter/-/component-emitter-3.1.2.tgz", + "integrity": "sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==", + "license": "MIT" + }, "node_modules/@sveltejs/acorn-typescript": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.5.tgz", "integrity": "sha512-IwQk4yfwLdibDlrXVE04jTZYlLnwsTT2PIOQQGNLWfjavGifnk1JD1LcZjZaBTRcxZu2FfPfNLOE04DSu9lqtQ==", - "dev": true, "license": "MIT", "peerDependencies": { "acorn": "^8.9.0" @@ -1197,14 +1211,12 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true, "license": "MIT" }, "node_modules/acorn": { "version": "8.15.0", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", - "dev": true, "license": "MIT", "bin": { "acorn": "bin/acorn" @@ -1217,7 +1229,6 @@ "version": "5.3.2", "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", - "dev": true, "license": "Apache-2.0", "engines": { "node": ">= 0.4" @@ -1227,7 +1238,6 @@ "version": "4.1.0", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", - "dev": true, "license": "Apache-2.0", "engines": { "node": ">= 0.4" @@ -1263,7 +1273,6 @@ "version": "2.1.1", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -1324,6 +1333,45 @@ "dev": true, "license": "MIT" }, + "node_modules/engine.io-client": { + "version": "6.6.3", + "resolved": "https://registry.npmjs.org/engine.io-client/-/engine.io-client-6.6.3.tgz", + "integrity": "sha512-T0iLjnyNWahNyv/lcjS2y4oE358tVS/SYQNxYXGAJ9/GLgH4VCvOQ/mhTjqU88mLZCQgiG8RIegFHYCdVC+j5w==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.3.1", + "engine.io-parser": "~5.2.1", + "ws": "~8.17.1", + "xmlhttprequest-ssl": "~2.1.1" + } + }, + "node_modules/engine.io-client/node_modules/debug": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/engine.io-parser": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/engine.io-parser/-/engine.io-parser-5.2.3.tgz", + "integrity": "sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + } + }, "node_modules/enhanced-resolve": { "version": "5.18.2", "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.18.2.tgz", @@ -1384,14 +1432,12 @@ "version": "1.2.2", "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", - "dev": true, "license": "MIT" }, "node_modules/esrap": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.1.0.tgz", "integrity": "sha512-yzmPNpl7TBbMRC5Lj2JlJZNPml0tzqoqP5B1JXycNUwtqma9AKCO0M2wHrdgsHcy1WRW7S9rJknAMtByg3usgA==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.4.15" @@ -1438,7 +1484,6 @@ "version": "3.0.3", "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", - "dev": true, "license": "MIT", "dependencies": { "@types/estree": "^1.0.6" @@ -1707,14 +1752,12 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", - "dev": true, "license": "MIT" }, "node_modules/magic-string": { "version": "0.30.17", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz", "integrity": "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==", - "dev": true, "license": "MIT", "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0" @@ -1783,7 +1826,6 @@ "version": "2.1.3", "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, "license": "MIT" }, "node_modules/nanoid": { @@ -2057,6 +2099,68 @@ "node": ">=18" } }, + "node_modules/socket.io-client": { + "version": "4.8.1", + "resolved": "https://registry.npmjs.org/socket.io-client/-/socket.io-client-4.8.1.tgz", + "integrity": "sha512-hJVXfu3E28NmzGk8o1sHhN3om52tRvwYeidbj7xKy2eIIse5IoKX3USlS6Tqt3BHAtflLIkCQBkzVrEEfWUyYQ==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.3.2", + "engine.io-client": "~6.6.1", + "socket.io-parser": "~4.2.4" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/socket.io-client/node_modules/debug": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/socket.io-parser": { + "version": "4.2.4", + "resolved": "https://registry.npmjs.org/socket.io-parser/-/socket.io-parser-4.2.4.tgz", + "integrity": "sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew==", + "license": "MIT", + "dependencies": { + "@socket.io/component-emitter": "~3.1.0", + "debug": "~4.3.1" + }, + "engines": { + "node": ">=10.0.0" + } + }, + "node_modules/socket.io-parser/node_modules/debug": { + "version": "4.3.7", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.7.tgz", + "integrity": "sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==", + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -2071,7 +2175,6 @@ "version": "5.37.1", "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.37.1.tgz", "integrity": "sha512-h8arWpQZ+3z8eahyBT5KkiBOUsG6xvI5Ykg0ozRr9xEdImgSMUPUlOFWRNkUsT7Ti0DSUCTEbPoped0aoxFyWA==", - "dev": true, "license": "MIT", "dependencies": { "@ampproject/remapping": "^2.3.0", @@ -2288,6 +2391,35 @@ } } }, + "node_modules/ws": { + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ws/-/ws-8.17.1.tgz", + "integrity": "sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==", + "license": "MIT", + "engines": { + "node": ">=10.0.0" + }, + "peerDependencies": { + "bufferutil": "^4.0.1", + "utf-8-validate": ">=5.0.2" + }, + "peerDependenciesMeta": { + "bufferutil": { + "optional": true + }, + "utf-8-validate": { + "optional": true + } + } + }, + "node_modules/xmlhttprequest-ssl": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/xmlhttprequest-ssl/-/xmlhttprequest-ssl-2.1.2.tgz", + "integrity": "sha512-TEU+nJVUUnA4CYJFLvK5X9AOeH4KvDvhIfm0vV1GaQRtchnG0hgK5p8hw/xjv8cunWYCsiPCSDzObPyhEwq3KQ==", + "engines": { + "node": ">=0.4.0" + } + }, "node_modules/yallist": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", @@ -2302,8 +2434,16 @@ "version": "1.1.2", "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.2.tgz", "integrity": "sha512-rAbqEGa8ovJy4pyBxZM70hg4pE6gDgaQ0Sl9M3enG3I0d6H4XSAM3GeNGLKnsBpuijUow064sf7ww1nutC5/3w==", - "dev": true, "license": "MIT" + }, + "node_modules/zod": { + "version": "4.0.14", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.0.14.tgz", + "integrity": "sha512-nGFJTnJN6cM2v9kXL+SOBq3AtjQby3Mv5ySGFof5UGRHrRioSJ5iG680cYNjE/yWk671nROcpPj4hAS8nyLhSw==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } } } } diff --git a/frontend/package.json b/frontend/package.json index cee9931..f639403 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -26,5 +26,10 @@ "tailwindcss": "^4.0.0", "typescript": "^5.0.0", "vite": "^7.0.4" + }, + "dependencies": { + "@lucide/svelte": "^0.536.0", + "socket.io-client": "^4.8.1", + "zod": "^4.0.14" } } diff --git a/frontend/src/app.css b/frontend/src/app.css index d4b5078..cb4fb29 100644 --- a/frontend/src/app.css +++ b/frontend/src/app.css @@ -1 +1,66 @@ +@import url('https://fonts.googleapis.com/css2?family=Lilita+One&display=swap'); @import 'tailwindcss'; + +@keyframes spin-slower { + from { + transform: rotate(0deg); + } + + to { + transform: rotate(360deg); + } +} + +.spin-slower { + animation: spin-slower 15s linear infinite; +} + +html, +body { + @apply bg-light-pine-base dark:bg-dark-pine-base; + /* or use your color class here */ + height: 100%; + margin: 0; +} + +.lilita-one-regular { + font-family: "Lilita One", sans-serif; + font-weight: 400; + font-style: normal; +} + +@theme { + --color-dark-pine-base: hsl(249deg, 22%, 12%); + --color-dark-pine-surface: hsl(247deg, 23%, 15%); + --color-dark-pine-overlay: hsl(248deg, 25%, 18%); + --color-dark-pine-muted: hsl(249deg, 12%, 47%); + --color-dark-pine-subtle: hsl(248deg, 15%, 61%); + --color-dark-pine-text: hsl(245deg, 50%, 91%); + --color-dark-pine-red: hsl(343deg, 76%, 68%); + --color-dark-pine-yellow: hsl(35deg, 88%, 72%); + --color-dark-pine-pink: hsl(2deg, 55%, 83%); + --color-dark-pine-blue: hsl(197deg, 49%, 38%); + --color-dark-pine-green: hsl(189deg, 43%, 73%); + --color-dark-pine-purple: hsl(267deg, 57%, 78%); + --color-dark-pine-highlight-low: hsl(244deg, 18%, 15%); + --color-dark-pine-highlight-med: hsl(249deg, 15%, 28%); + --color-dark-pine-highlight-high: hsl(248deg, 13%, 36%); +} + +@theme { + --color-light-pine-base: hsl(32deg, 57%, 95%); + --color-light-pine-surface: hsl(35deg, 100%, 98%); + --color-light-pine-overlay: hsl(33deg, 43%, 91%); + --color-light-pine-muted: hsl(257deg, 9%, 61%); + --color-light-pine-subtle: hsl(248deg, 12%, 52%); + --color-light-pine-text: hsl(248deg, 19%, 40%); + --color-light-pine-red: hsl(343deg, 35%, 55%); + --color-light-pine-yellow: hsl(35deg, 81%, 56%); + --color-light-pine-pink: hsl(3deg, 53%, 67%); + --color-light-pine-blue: hsl(197deg, 53%, 34%); + --color-light-pine-green: hsl(189deg, 30%, 48%); + --color-light-pine-purple: hsl(268deg, 21%, 57%); + --color-light-pine-highlight-low: hsl(25deg, 35%, 93%); + --color-light-pine-highlight-med: hsl(10deg, 9%, 86%); + --color-light-pine-highlight-high: hsl(315deg, 4%, 80%); +} diff --git a/frontend/src/app.html b/frontend/src/app.html index 7af2f6d..0e6ef01 100644 --- a/frontend/src/app.html +++ b/frontend/src/app.html @@ -1,15 +1,13 @@ - - - - - - %sveltekit.head% - - - -
%sveltekit.body%
- - + + + + + + %sveltekit.head% + + +
%sveltekit.body%
+ diff --git a/frontend/src/lib/components/Error.svelte b/frontend/src/lib/components/Error.svelte new file mode 100644 index 0000000..414657e --- /dev/null +++ b/frontend/src/lib/components/Error.svelte @@ -0,0 +1,10 @@ + + +
+

Error {returnError.code}

+

{returnError.message}

+
diff --git a/frontend/src/lib/components/QueueSlider.svelte b/frontend/src/lib/components/QueueSlider.svelte index 1db4c6a..5df1e8d 100644 --- a/frontend/src/lib/components/QueueSlider.svelte +++ b/frontend/src/lib/components/QueueSlider.svelte @@ -1,30 +1,44 @@
{#each displaySongs as song, i} - {#if song.name != ""} + {#if song?.title && song.title != ""}
- Song cover +
+ {#if i === 1} +
+ {/if} + Song cover +
{#if i === 1} -

{song.name}

+

{song.title} - {song.artist}

{/if}
{:else} -
+
{/if} {/each}
diff --git a/frontend/src/lib/components/RoomComponent.svelte b/frontend/src/lib/components/RoomComponent.svelte new file mode 100644 index 0000000..0192683 --- /dev/null +++ b/frontend/src/lib/components/RoomComponent.svelte @@ -0,0 +1,45 @@ + + +
+ + {#if showPinModal} + + + {/if} +
diff --git a/frontend/src/lib/components/SuggestionInput.svelte b/frontend/src/lib/components/SuggestionInput.svelte index c93c8b7..d19dd17 100644 --- a/frontend/src/lib/components/SuggestionInput.svelte +++ b/frontend/src/lib/components/SuggestionInput.svelte @@ -1,7 +1,69 @@ -
- - +
+ { + errorMsg = null + if (e.key == "Enter") { + sendSong() + } + }} + disabled={loading} + /> + {#if loading} + + + + {/if} + + +
+ +

+ {errorMsg} +

diff --git a/frontend/src/lib/components/SuggestionList.svelte b/frontend/src/lib/components/SuggestionList.svelte index ba7ed69..6c667a3 100644 --- a/frontend/src/lib/components/SuggestionList.svelte +++ b/frontend/src/lib/components/SuggestionList.svelte @@ -1,32 +1,62 @@
- {#each reactiveSugg as sug} -
-
- Song cover -

{sug.name}

+ {#if suggestions.length == 0} +

No suggestions yet! Try to add a new one using the Add button

+ {/if} + + {#each suggestions as sug} +
+
+ Song cover +
+ {sug.title} +

{sug.artist}

+
{ + await vote(1, sug.uuid) + }}> -

{sug.points}

+

{sug.upvote}

{ + await vote(-1, sug.uuid) + }}>
diff --git a/frontend/src/lib/gps.ts b/frontend/src/lib/gps.ts new file mode 100644 index 0000000..f07e4c1 --- /dev/null +++ b/frontend/src/lib/gps.ts @@ -0,0 +1,36 @@ +export type Coordinates = { + latitude: number + longitude: number +} + +function geolocation_to_simple_coords(coordinates: GeolocationCoordinates): Coordinates { + return { latitude: coordinates.latitude, longitude: coordinates.longitude } +} + +export function get_coords(): Promise<{ coords: Coordinates | null; error: string | null }> { + return new Promise((resolve) => { + if (!navigator.geolocation) { + resolve({ coords: null, error: "Geolocation is not supported by your browser" }) + return + } + + const error_callback = (gps_error: GeolocationPositionError) => { + console.log(gps_error) + resolve({ + coords: null, + error: `Unable to retrieve your location: (${gps_error.message})`, + }) + return + } + + const success_callback = (gps_position: GeolocationPosition) => { + resolve({ + coords: geolocation_to_simple_coords(gps_position.coords), + error: null, + }) + return + } + + navigator.geolocation.getCurrentPosition(success_callback, error_callback) + }) +} diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts index 29c7bd9..87182fc 100644 --- a/frontend/src/lib/types.ts +++ b/frontend/src/lib/types.ts @@ -1,9 +1,57 @@ -import * as z from "zod" +import { z } from "zod" -export const SongSchema = z.object({ - name: z.string(), - image: z.string(), - points: z.number().optional().default(0), +const SongSchema = z.object({ + uuid: z.string(), + title: z.string(), + artist: z.string(), + tags: z.array(z.string()), + image_id: z.string(), + youtube_id: z.string(), }) - export type Song = z.infer + +export const parseSong = async function (song: any): Promise { + let resp = await SongSchema.parseAsync(song) + return resp +} + +export const createEmptySong = function (): Song { + return { + uuid: "-1", + title: "", + artist: "", + tags: [""], + image_id: "", + youtube_id: "", + } +} + +const SuggestionSchema = SongSchema.extend({ + upvote: z.number(), +}) +export type Suggestion = z.infer + +export const parseSuggestion = async function (sugg: any): Promise { + let resp = await SuggestionSchema.parseAsync(sugg) + return resp +} + +const RoomSchema = z.object({ + id: z.number(), + name: z.string(), + private: z.boolean(), + coords: z.object({ latitude: z.number(), longitude: z.number() }), + range: z.number().int(), + distance: z.number() +}) +export type Room = z.infer + +export const parseRoom = async function (room: any): Promise { + let resp = await RoomSchema.parseAsync(room) + return resp +} + +export type FetchError = { + code: number + message: string +} diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts new file mode 100644 index 0000000..470da85 --- /dev/null +++ b/frontend/src/lib/utils.ts @@ -0,0 +1,76 @@ +import { type Coordinates } from "./gps" +import { parseSong, parseSuggestion, type FetchError, type Song, type Suggestion } from "./types" + +export const joinRoom = async function (roomId: string, coords: Coordinates, pin: string): Promise<[FetchError | null, string]> { + let res = await fetch(`/api/join?room=${roomId}&lat=${coords.latitude}&lon=${coords.longitude}&pin=${pin}`) + + if (res.status != 200) { + return [{ code: 400, message: "Cannot join the room" }, ""] + } + + return [null, "test"] +} + +export const getSuggestions = async function (roomId: string): Promise<[FetchError | null, Suggestion[]]> { + let resp = await fetch("/api/room/suggestions?room=" + roomId) + + if (resp.status != 200) { + return [{ code: 400, message: "Failed to retrieve suggestions" }, []] + } + + let json = await resp.json() + let suggestions: Suggestion[] = [] + + json["songs"].forEach(async (i: any) => { + suggestions.push(await parseSuggestion(i)) + }) + + suggestions = suggestions.sort((a, b) => { + return a.upvote - b.upvote + }) + + return [null, suggestions] +} + +export const getQueueSongs = async function (roomId: string): Promise<[FetchError | null, Song[], number]> { + let resp = await fetch("/api/queue?room=" + roomId) + if (resp.status != 200) { + return [{ code: 400, message: "Failed to load queue songs" }, [], 0] + } + + let json = await resp.json() + let songs: Song[] = [] + + json["queue"].forEach(async (i: any) => { + songs.push(await parseSong(i)) + }) + + let playingId = json["index"] + + return [null, songs, playingId] +} + +export const triggerPlayNext = async function (roomId: string): Promise<[FetchError | null, Song[], number]> { + let resp = await fetch("/api/queue/next?room=" + roomId, { method: "POST" }) + + if (resp.status != 200) { + return [{ code: 400, message: "Failed to trigger next song playback" }, [], 0] + } + + let json = await resp.json() + + let songs: Song[] = [] + + json["queue"].forEach(async (i: any) => { + songs.push(await parseSong(i)) + }) + return [null, songs, json["index"]] +} + +export const getStreamingUrl = async function (uuid: string) { + let resp = await fetch("/api/song/audio?song=" + uuid) + + let json = await resp.json() + + return json["url"] +} diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index 79a3f7e..0ab9b2b 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -4,4 +4,6 @@ let { children } = $props() -{@render children()} +
+ {@render children()} +
diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte index 6741d1c..b0b35bf 100644 --- a/frontend/src/routes/+page.svelte +++ b/frontend/src/routes/+page.svelte @@ -1,21 +1,44 @@ -
-
+
+
+ chillbox icon + ChillBox +
+ + radar + Scanning for rooms near you... + + + +
+ {#each rooms as room} + + {/each} +
diff --git a/frontend/src/routes/admin/[id]/+page.svelte b/frontend/src/routes/admin/[id]/+page.svelte new file mode 100644 index 0000000..02f5f08 --- /dev/null +++ b/frontend/src/routes/admin/[id]/+page.svelte @@ -0,0 +1,118 @@ + + +{#if returnError} + +{:else} +
+ + + + +
+

{formatTime(playerInfo.currentTime)} - {formatTime(playerInfo.duration)}

+ +
+ + +
+
+ + +
+{/if} diff --git a/frontend/src/routes/admin/[id]/+page.ts b/frontend/src/routes/admin/[id]/+page.ts new file mode 100644 index 0000000..216a08b --- /dev/null +++ b/frontend/src/routes/admin/[id]/+page.ts @@ -0,0 +1,11 @@ +import { error } from "@sveltejs/kit" +import type { PageLoad } from "../../room/[id]/$types" + +export const load: PageLoad = function ({ params }) { + if (params.id) { + return { + roomId: params.id, + } + } + error(400, "Please provide a room id") +} diff --git a/frontend/src/routes/room/[id]/+page.svelte b/frontend/src/routes/room/[id]/+page.svelte index eb0a4dc..84d8d67 100644 --- a/frontend/src/routes/room/[id]/+page.svelte +++ b/frontend/src/routes/room/[id]/+page.svelte @@ -1,45 +1,88 @@ -
- -
- + +{#if returnError} + +{:else} +
+ +
+ +
+
+ +
-
- -
-
+{/if} diff --git a/frontend/src/routes/room/[id]/+page.ts b/frontend/src/routes/room/[id]/+page.ts new file mode 100644 index 0000000..f3bea98 --- /dev/null +++ b/frontend/src/routes/room/[id]/+page.ts @@ -0,0 +1,8 @@ +import type { PageLoad } from "./$types" + +export const load: PageLoad = ({ params, url }) => { + return { + roomId: params.id || "", + pin: url.searchParams.get("pin") || "", + } +} diff --git a/frontend/src/routes/room/create/+page.svelte b/frontend/src/routes/room/create/+page.svelte new file mode 100644 index 0000000..d0009f3 --- /dev/null +++ b/frontend/src/routes/room/create/+page.svelte @@ -0,0 +1,83 @@ + + +
+

Create Room

+ +
+ + + + +

+ Room Coordinates: + {coord.latitude},{coord.longitude} +

+ + + + +
+
diff --git a/frontend/src/routes/zesty/gps/+page.svelte b/frontend/src/routes/zesty/gps/+page.svelte new file mode 100644 index 0000000..ab9eacd --- /dev/null +++ b/frontend/src/routes/zesty/gps/+page.svelte @@ -0,0 +1,17 @@ + diff --git a/frontend/src/routes/zesty/icons/+page.svelte b/frontend/src/routes/zesty/icons/+page.svelte new file mode 100644 index 0000000..7307ebe --- /dev/null +++ b/frontend/src/routes/zesty/icons/+page.svelte @@ -0,0 +1,7 @@ + + + + + diff --git a/frontend/src/routes/zesty/wsio/+page.svelte b/frontend/src/routes/zesty/wsio/+page.svelte new file mode 100644 index 0000000..32b44d5 --- /dev/null +++ b/frontend/src/routes/zesty/wsio/+page.svelte @@ -0,0 +1,8 @@ + diff --git a/frontend/static/CHILLBOX.svg b/frontend/static/CHILLBOX.svg new file mode 100644 index 0000000..8133a23 --- /dev/null +++ b/frontend/static/CHILLBOX.svg @@ -0,0 +1,3 @@ + + + diff --git a/frontend/static/android-chrome-192x192.png b/frontend/static/android-chrome-192x192.png new file mode 100644 index 0000000..ddc7955 Binary files /dev/null and b/frontend/static/android-chrome-192x192.png differ diff --git a/frontend/static/android-chrome-512x512.png b/frontend/static/android-chrome-512x512.png new file mode 100644 index 0000000..71ec4aa Binary files /dev/null and b/frontend/static/android-chrome-512x512.png differ diff --git a/frontend/static/apple-touch-icon.png b/frontend/static/apple-touch-icon.png new file mode 100644 index 0000000..a60445a Binary files /dev/null and b/frontend/static/apple-touch-icon.png differ diff --git a/frontend/static/favicon-16x16.png b/frontend/static/favicon-16x16.png new file mode 100644 index 0000000..6bb39f3 Binary files /dev/null and b/frontend/static/favicon-16x16.png differ diff --git a/frontend/static/favicon-32x32.png b/frontend/static/favicon-32x32.png new file mode 100644 index 0000000..7d938c2 Binary files /dev/null and b/frontend/static/favicon-32x32.png differ diff --git a/frontend/static/favicon.ico b/frontend/static/favicon.ico new file mode 100644 index 0000000..f5208bb Binary files /dev/null and b/frontend/static/favicon.ico differ diff --git a/frontend/static/favicon.svg b/frontend/static/favicon.svg deleted file mode 100644 index cc5dc66..0000000 --- a/frontend/static/favicon.svg +++ /dev/null @@ -1 +0,0 @@ -svelte-logo \ No newline at end of file diff --git a/frontend/static/icon_small.png b/frontend/static/icon_small.png new file mode 100644 index 0000000..f518ac7 Binary files /dev/null and b/frontend/static/icon_small.png differ diff --git a/frontend/static/manifest.json b/frontend/static/manifest.json new file mode 100644 index 0000000..8a414d3 --- /dev/null +++ b/frontend/static/manifest.json @@ -0,0 +1,21 @@ +{ + "name": "Chillbox Music Player", + "short_name": "Chillbox", + "start_url": "/", + "display": "standalone", + "background_color": "#334155", + "theme_color": "#334155", + "orientation": "portrait-primary", + "icons": [ + { + "src": "/android-chrome-512x512.png", + "sizes": "512x512", + "type": "image/png" + }, + { + "src": "/android-chrome-192x192.png", + "sizes": "192x192", + "type": "image/png" + } + ] +} diff --git a/frontend/static/radar.gif b/frontend/static/radar.gif new file mode 100644 index 0000000..eacb878 Binary files /dev/null and b/frontend/static/radar.gif differ diff --git a/frontend/static/radar_bonus.gif b/frontend/static/radar_bonus.gif new file mode 100644 index 0000000..972945e Binary files /dev/null and b/frontend/static/radar_bonus.gif differ diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts index eccff34..09f1019 100644 --- a/frontend/vite.config.ts +++ b/frontend/vite.config.ts @@ -1,16 +1,22 @@ -import tailwindcss from '@tailwindcss/vite'; -import { sveltekit } from '@sveltejs/kit/vite'; -import { defineConfig } from 'vite'; +import tailwindcss from "@tailwindcss/vite" +import { sveltekit } from "@sveltejs/kit/vite" +import { defineConfig } from "vite" export default defineConfig({ plugins: [tailwindcss(), sveltekit()], server: { proxy: { - '/api': { + "/api": { target: "http://backend:5000", changeOrigin: false, - secure: false - } - } - } -}); + secure: false, + }, + "/ws": { + target: "http://backend:5000", + changeOrigin: false, + secure: false, + ws: true, + }, + }, + }, +})