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% - - - -{returnError.message}
++ {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 @@ + + +No suggestions yet! Try to add a new one using the Add button
+ {/if} + + {#each suggestions as sug} + + {/each} +{text}
+{formatTime(playerInfo.currentTime)} - {formatTime(playerInfo.duration)}
+ +