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 dc8f66f..3390030 100644 --- a/backend/Dockerfile +++ b/backend/Dockerfile @@ -2,7 +2,7 @@ FROM python:3.13.5-alpine WORKDIR /app -RUN apk update && apk add git +RUN apk update && apk add git ffmpeg RUN git clone --depth 1 'https://github.com/yt-dlp/yt-dlp.git' /yt-dlp @@ -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 7623d9f..cb18a70 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,4 +1,8 @@ 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 b99d40b..28c66e9 100644 --- a/backend/src/app.py +++ b/backend/src/app.py @@ -1,18 +1,43 @@ +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 .room import Room, test_algo -from .song import init_db +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 dotenv.load_dotenv() app = Flask(__name__) +app.config["SECRET_KEY"] = "your_secret_key" +socketio = SocketIO(app, cors_allowed_origins="*", path="/ws") CORS(app) -ROOMS: dict[int, Room] = {} # { room_id: room, ... } +db_conn = get_connection() +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: @@ -21,30 +46,267 @@ def error(msg: str, status: int = 400) -> Response: return res -@app.route("/api") -def index(): - return "hello from flask" +@socketio.on("connect") +def handle_connection(): + print("somebody connected to socket.io", flush=True) -@app.route("/api/join") +@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") - if (room := ROOMS.get(int(room_id))) is None: + 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}"} -init_db() -test_algo() -exit() +@app.get("/api/queue") +def queue(): + 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") + + return {"success": True, "queue": room.playing, "index": room.playing_idx} + + +@app.post("/api/queue/next") +def queue_next(): + 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") + + room.playing_idx += 1 + + if room.playing_idx >= len(room.playing): + ## queue ended + room.renew_queue() + ended = True + else: + ended = False + + 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 data + + +@app.post("/api/room/new") +def room_new(): + if (room_name := request.args.get("name")) is None: + return error("Missing room name") + + 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: + room_pin = None + + lat, lon = room_cords.split(",") + + room = Room( + 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]), + songs={}, + history=[], + playing=[], + playing_idx=-1, + ) + + state.rooms[room.id] = room + + return {"success": True, "room_id": room.id} + + +@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__": - app.run(debug=True) + socketio.run(app, host="0.0.0.0", port=5000, debug=True) diff --git a/backend/src/connect.py b/backend/src/connect.py index 962528c..c07af1b 100644 --- a/backend/src/connect.py +++ b/backend/src/connect.py @@ -3,4 +3,6 @@ import sqlite3 def get_connection(): conn = sqlite3.connect(".data/jukebox.db") + conn.row_factory = sqlite3.Row + conn.autocommit = True return conn 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 7baf617..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 @@ -9,6 +10,7 @@ TAG_WEIGHT = 0.1 RANDOM_WEIGHT = 0.1 RECENT_PENALTY = 0.5 RECENT_COUNT = 10 +QUEUE_SIZE = 3 type UserScoredSong = tuple[Song, int] @@ -29,13 +31,32 @@ 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] - history: list[Song] + songs: dict[str, UserScoredSong] # all songs + user score (the playlist) + history: list[Song] # all songs previously played + + ## playing queue info + playing: list[Song] + playing_idx: int + + def renew_queue(self): + for song in self.playing: + self.history.append(song) + self.playing.clear() + self.playing_idx = 0 + + rankings: dict[str, float] = {} + for id, (song, user_score) in self.songs.items(): + rankings[id] = self.rank_song(song, user_score).total() + + # sort dict items by their values and pick the highest 3 + top_items = sorted(rankings.items(), key=lambda item: item[1], reverse=True)[:QUEUE_SIZE] # [:3] + self.playing = list(map(lambda x: self.songs[x[0]][0], top_items)) # remove the ranking and take only the songs def rank_song_from_id(self, id: str) -> Rank: scored = self.songs[id] @@ -106,19 +127,20 @@ def test_algo(): "test", None, set(["rock", "rap"]), + 100, { - "paulham": (songs[0], 2), - "cisco": (songs[1], 1), - "vpn": (songs[2], 5), - "gang": (songs[3], 1), - "bertha1": (songs[4], 1), - "bertha2": (songs[5], 1), - "bertha3": (songs[6], 1), - "cexx": (songs[7], -123123), + "paulham": (songs[0], 7), + "cisco": (songs[1], 5), + "vpn": (songs[2], 11), + "gang": (songs[3], 10), + "bertha1": (songs[4], 4), + "bertha2": (songs[5], 5), + "bertha3": (songs[6], -4), + "cexx": (songs[7], 12), }, - [songs[4], songs[5], songs[0]], + [], + [songs[2], songs[0], songs[1]], + 1, ) - - print(room.rank_song_from_id("paulham"), room.rank_song_from_id("paulham").total()) - print(room.rank_song_from_id("vpn"), room.rank_song_from_id("vpn").total()) - print(room.rank_song_from_id("cexx"), room.rank_song_from_id("cexx").total()) + room.renew_queue() + print(room.playing) diff --git a/backend/src/song.py b/backend/src/song.py index 8570033..f95609b 100644 --- a/backend/src/song.py +++ b/backend/src/song.py @@ -1,13 +1,13 @@ from dataclasses import dataclass -from .connect import get_connection +from sqlite3 import Cursor + +from connect import get_connection -def init_db(): - conn = get_connection() - cursor = conn.cursor() - cursor.execute(""" +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, @@ -15,15 +15,57 @@ def init_db(): youtube_id TEXT NOT NULL ); """) - conn.commit() - conn.close() -@dataclass +@dataclass(frozen=True, slots=True, kw_only=True) class Song: - mbid: str + uuid: str title: str artist: str tags: list[str] image_id: str youtube_id: str + + +def get_song_by_uuid(uuid: str) -> Song | None: + conn = get_connection() + cursor = conn.cursor() + cursor.execute("SELECT * FROM songs WHERE uuid = ?", (uuid,)) + 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 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 (uuid, title, artist, tags, lastfm_image_id, youtube_id) + VALUES (?, ?, ?, ?, ?, ?) + """, + (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 96288e9..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,14 +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={ @@ -35,29 +46,82 @@ def lastfm_getinfo( 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], ) -print(yt_dlp, flush=True) +def _yt_search(query: str) -> tuple[str, str]: + ydl_opts = { + "format": "bestaudio", + "default_search": "ytsearch1", + "outtmpl": "%(title)s.%(ext)s", + "skip_download": True, + } -# # def get_yt_mp3link(name: str, artist: str) -> str: ... -# # os.popen("/yt-dlp ") + with yt_dlp.YoutubeDL(ydl_opts) as ydl: + info = ydl.extract_info(query, download=False) -# # /yt-dlp/yt-dlp.sh "ytsearch1:Never gonna give you up" --get-url -f "ba" + first = info["entries"][0] -# import json - -# print(json.dumps(lastfm_getinfo(*lastfm_search("money")), indent=2)) -# exit(1) + return first["track"], first["artists"][0] -# # def +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) -# ## query ==> lastfm ==> list of songs ==> take first ==> request song info ==> get YT link ==> save in DB ==> +def yt_search_song(name: str, artist: str) -> str | None: # video id + 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(f"{name!r} - {artist!r}", download=False) + + if len(info["entries"]) == 0: + return None + + 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 fmt["url"] + + return None diff --git a/backend/src/state.py b/backend/src/state.py new file mode 100644 index 0000000..73d05d2 --- /dev/null +++ b/backend/src/state.py @@ -0,0 +1,15 @@ +from dataclasses import dataclass, field +from sqlite3 import Cursor + +from flask import Flask +from flask_socketio import SocketIO + +from room import Room + + +@dataclass +class State: + app: Flask + socketio: SocketIO + db: Cursor + 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 new file mode 100644 index 0000000..5df1e8d --- /dev/null +++ b/frontend/src/lib/components/QueueSlider.svelte @@ -0,0 +1,45 @@ + + +
+
+ {#each displaySongs as song, i} + {#if song?.title && song.title != ""} +
+
+ {#if i === 1} +
+ {/if} + Song cover +
+ {#if i === 1} +

{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 new file mode 100644 index 0000000..d19dd17 --- /dev/null +++ b/frontend/src/lib/components/SuggestionInput.svelte @@ -0,0 +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 new file mode 100644 index 0000000..6c667a3 --- /dev/null +++ b/frontend/src/lib/components/SuggestionList.svelte @@ -0,0 +1,64 @@ + + +
+ {#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}

+
+
+
+ +

{sug.upvote}

+ +
+
+ {/each} +
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/index.ts b/frontend/src/lib/index.ts deleted file mode 100644 index 856f2b6..0000000 --- a/frontend/src/lib/index.ts +++ /dev/null @@ -1 +0,0 @@ -// place files you want to import through the `$lib` alias in this folder. diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts new file mode 100644 index 0000000..87182fc --- /dev/null +++ b/frontend/src/lib/types.ts @@ -0,0 +1,57 @@ +import { z } from "zod" + +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 ada8c4f..0ab9b2b 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -4,6 +4,6 @@ let { children } = $props() -
+
{@render children()}
diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte index 323ffa3..b0b35bf 100644 --- a/frontend/src/routes/+page.svelte +++ b/frontend/src/routes/+page.svelte @@ -1,12 +1,44 @@ -

Welcome to SvelteKit

-

{text}

+
+
+ 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 new file mode 100644 index 0000000..84d8d67 --- /dev/null +++ b/frontend/src/routes/room/[id]/+page.svelte @@ -0,0 +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, + }, + }, + }, +})