Compare commits

..

No commits in common. "main" and "room_refactor_ws" have entirely different histories.

45 changed files with 236 additions and 975 deletions

View file

@ -1,40 +1,3 @@
# ChillBox # team-1
> *A project by Pausetta.org, Simone Tesini, Francesco De Carlo, Leonardo Segala, Matteo Peretto* Test
**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 cant 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.

View file

@ -1,15 +0,0 @@
# 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.

View file

@ -12,6 +12,5 @@ RUN pip install --no-cache-dir -r requirements.txt
COPY . . COPY . .
EXPOSE 5000 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 # flask --app src.app run --host=0.0.0.0 --port=5001 --debug

View file

@ -5,4 +5,3 @@ dotenv
requests requests
qrcode qrcode
Pillow Pillow
eventlet>=0.33

View file

@ -2,16 +2,16 @@ import uuid
from dataclasses import asdict from dataclasses import asdict
import dotenv import dotenv
from connect import get_connection
from flask import Flask, Response, jsonify, request from flask import Flask, Response, jsonify, request
from flask_cors import CORS from flask_cors import CORS
from flask_socketio import SocketIO, join_room, leave_room from flask_socketio import SocketIO, join_room, leave_room
from gps import Coordinates, distance_between_coords
from qrcode_gen import generate_qr from .connect import get_connection
from room import Room from .qrcode_gen import generate_qr
from song import Song, add_song_in_db, get_song_by_title_artist, get_song_by_uuid, init_db from .room import Room
from song_fetch import query_search, yt_get_audio_url, yt_search_song from .song import Song, add_song_in_db, get_song_by_title_artist, init_db
from state import State from .song_fetch import download_song_mp3, lastfm_query_search
from .state import State
dotenv.load_dotenv() dotenv.load_dotenv()
@ -28,14 +28,14 @@ init_db(state.db)
state.rooms[1000] = Room( state.rooms[1000] = Room(
id=1000, id=1000,
coord=Coordinates(46.6769043, 11.1851585), coord=(1.0, 5.5),
name="Lido Scena", name="Test Room",
pin=1234, pin=None,
tags=set(["chill", "raggaetton", "spanish", "latino", "mexican", "rock"]), tags=set(),
range_size=150, range_size=100,
songs={}, songs={"b": (Song(uuid="b", title="title", artist="art", tags=["a", "B"], image_id="img", youtube_id="yt"), 1)},
history=[], history=[],
playing=[], playing=[Song(uuid="<uuid>", title="<title>", artist="<artist>", tags=[], image_id="<img>", youtube_id="<yt>")],
playing_idx=0, playing_idx=0,
) )
@ -73,7 +73,7 @@ def on_leave(data):
@app.get("/api/join") @app.get("/api/join")
def join(): def join():
room_id = request.args.get("room") room_id = request.args.get("room")
code = request.args.get("pin") code = request.args.get("code")
if room_id is None: if room_id is None:
return error("Missing room id") return error("Missing room id")
@ -81,23 +81,9 @@ def join():
if (room := state.rooms.get(int(room_id))) is None: if (room := state.rooms.get(int(room_id))) is None:
return error("Invalid room") return error("Invalid room")
if room.pin is not None: if room.pin is not None and room.pin != code:
if code is None:
return error("Missing code")
if int(room.pin) != int(code):
return error("Invalid 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}"} return {"success": True, "ws": f"/ws/{room_id}"}
@ -124,15 +110,13 @@ def queue_next():
if room.playing_idx >= len(room.playing): if room.playing_idx >= len(room.playing):
## queue ended ## 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]} # room.renew_queue()
state.socketio.emit("queue_update", data, to=str(room.id)) state.socketio.emit("update_queue", {"songs": room.playing}, to=str(room.id))
return data return {"success": True, "ended": True, "index": room.playing_idx, "queue": room.playing}
return {"success": True, "ended": False, "index": room.playing_idx}
@app.post("/api/room/new") @app.post("/api/room/new")
@ -154,8 +138,8 @@ def room_new():
lat, lon = room_cords.split(",") lat, lon = room_cords.split(",")
room = Room( room = Room(
id=max(state.rooms or [0]) + 1, id=max(state.rooms or [0]) + 1, #
coord=Coordinates(float(lat), float(lon)), coord=(float(lat), float(lon)),
range_size=int(room_range), range_size=int(room_range),
name=room_name, name=room_name,
pin=room_pin, pin=room_pin,
@ -173,14 +157,6 @@ def room_new():
@app.get("/api/room") @app.get("/api/room")
def 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 [ return [
{ {
"id": room.id, "id": room.id,
@ -188,10 +164,8 @@ def room():
"private": room.pin is not None, "private": room.pin is not None,
"coords": room.coord, "coords": room.coord,
"range": room.range_size, "range": room.range_size,
"distance": d,
} }
for room in state.rooms.values() for room in state.rooms.values()
if (d := distance_between_coords(user_coords, room.coord)) <= room.range_size
] ]
@ -206,15 +180,12 @@ def add_song():
if (query := request.args.get("query")) is None: if (query := request.args.get("query")) is None:
return error("Missing query") return error("Missing query")
if (info := query_search(query)) is None: info = lastfm_query_search(query)
return error("Search failed")
if (song := get_song_by_title_artist(info.title, info.artist)) is None: if (song := get_song_by_title_artist(info.title, info.artist)) is None:
## song not found, downolad from YT ## song not found, downolad from YT
yt_video_id = yt_search_song(info.title, info.artist) if (res := download_song_mp3(info.title, info.artist)) is None:
return error("Cannot get info from YT")
if yt_video_id is None:
return error("No video found on youtube")
## add in DB ## add in DB
song = Song( song = Song(
@ -223,24 +194,14 @@ def add_song():
artist=info.artist, artist=info.artist,
tags=info.tags, tags=info.tags,
image_id=info.img_id, image_id=info.img_id,
youtube_id=yt_video_id, youtube_id=res[0],
) )
add_song_in_db(song) 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 ## add the song in the room if does not exists
if song.uuid not in room.songs: if song.uuid not in room.songs:
room.songs[song.uuid] = (song, 1) # start with one vote 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} return {"success": True, "song": song}
@ -272,7 +233,6 @@ def post_song_vote():
## update the song ## update the song
room.songs[song_id] = (song_info[0], song_info[1] + int(request.args["increment"])) 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} return {"success": True}
@ -294,19 +254,5 @@ def room_qrcode():
return Response(stream, content_type="image/jpeg") 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__": if __name__ == "__main__":
socketio.run(app, host="0.0.0.0", port=5000, debug=True) socketio.run(app, debug=True)

View file

@ -1,29 +0,0 @@
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

View file

@ -1,8 +1,7 @@
import random import random
from dataclasses import dataclass from dataclasses import dataclass
from gps import Coordinates from .song import Song
from song import Song
USER_SCORE_WEIGHT = 0.7 USER_SCORE_WEIGHT = 0.7
ARTIST_WEIGHT = 0.1 ARTIST_WEIGHT = 0.1
@ -31,7 +30,7 @@ class Rank:
@dataclass @dataclass
class Room: class Room:
id: int id: int
coord: Coordinates coord: tuple[float, float]
name: str name: str
pin: int | None pin: int | None
tags: set[str] tags: set[str]

View file

@ -1,7 +1,7 @@
from dataclasses import dataclass from dataclasses import dataclass
from sqlite3 import Cursor from sqlite3 import Cursor
from connect import get_connection from .connect import get_connection
def init_db(db: Cursor): def init_db(db: Cursor):

View file

@ -1,10 +1,9 @@
import os
import os.path
import sys
import urllib.parse
from dataclasses import dataclass
import requests import requests
import urllib.parse
import os.path
import os
import sys
from dataclasses import dataclass
sys.path.append("/yt-dlp") sys.path.append("/yt-dlp")
import yt_dlp import yt_dlp
@ -18,7 +17,7 @@ class SongInfo:
tags: list[str] tags: list[str]
def _lastfm_search(query: str) -> tuple[str, str] | None: def _lastfm_search(query: str) -> tuple[str, str]:
response = requests.get( response = requests.get(
url="https://ws.audioscrobbler.com/2.0/?method=track.search&format=json", url="https://ws.audioscrobbler.com/2.0/?method=track.search&format=json",
params={"limit": 5, "track": query, "api_key": os.environ["LASTFM_API_KEY"]}, params={"limit": 5, "track": query, "api_key": os.environ["LASTFM_API_KEY"]},
@ -26,10 +25,7 @@ def _lastfm_search(query: str) -> tuple[str, str] | None:
assert response.status_code == 200 assert response.status_code == 200
tracks = response.json()["results"]["trackmatches"]["track"] track_info = response.json()["results"]["trackmatches"]["track"][0]
if len(tracks) == 0:
return None
track_info = tracks[0]
return track_info["name"], track_info["artist"] return track_info["name"], track_info["artist"]
@ -46,48 +42,24 @@ def _lastfm_getinfo(name: str, artist: str) -> tuple[str, list[str]]: # ( image
track_info = response.json()["track"] track_info = response.json()["track"]
image_id = ""
if "album" in track_info:
image_url = urllib.parse.urlparse(track_info["album"]["image"][0]["#text"]) 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 ( return (
# track_info["mbid"], # track_info["mbid"],
image_id, os.path.splitext(os.path.basename(image_url.path))[0],
[t["name"] for t in track_info["toptags"]["tag"]], [t["name"] for t in track_info["toptags"]["tag"]],
) )
def _yt_search(query: str) -> tuple[str, str]: def lastfm_query_search(query: str) -> SongInfo:
ydl_opts = { name, artist = _lastfm_search(query)
"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) img_id, tags = _lastfm_getinfo(name, artist)
return SongInfo(artist=artist, title=name, img_id=img_id, tags=tags) return SongInfo(artist=artist, title=name, img_id=img_id, tags=tags)
def yt_search_song(name: str, artist: str) -> str | None: # video id def download_song_mp3(name: str, artist: str) -> tuple[str, str] | None: # ( id, audio )
ydl_opts = { ydl_opts = {
"format": "bestaudio", "format": "bestaudio",
"default_search": "ytsearch1", "default_search": "ytsearch1",
@ -98,30 +70,12 @@ def yt_search_song(name: str, artist: str) -> str | None: # video id
with yt_dlp.YoutubeDL(ydl_opts) as ydl: with yt_dlp.YoutubeDL(ydl_opts) as ydl:
info = ydl.extract_info(f"{name!r} - {artist!r}", download=False) 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] first_entry = info["entries"][0]
video_id = first_entry["id"]
for fmt in first_entry["formats"]: for fmt in first_entry["formats"]:
if "acodec" in fmt and fmt["acodec"] != "none": if "acodec" in fmt and fmt["acodec"] != "none":
return fmt["url"] return video_id, fmt["url"]
return None return None

View file

@ -4,7 +4,7 @@ from sqlite3 import Cursor
from flask import Flask from flask import Flask
from flask_socketio import SocketIO from flask_socketio import SocketIO
from room import Room from .room import Room
@dataclass @dataclass

View file

@ -8,7 +8,6 @@
"name": "frontend", "name": "frontend",
"version": "0.0.1", "version": "0.0.1",
"dependencies": { "dependencies": {
"@lucide/svelte": "^0.536.0",
"socket.io-client": "^4.8.1", "socket.io-client": "^4.8.1",
"zod": "^4.0.14" "zod": "^4.0.14"
}, },
@ -31,6 +30,7 @@
"version": "2.3.0", "version": "2.3.0",
"resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz", "resolved": "https://registry.npmjs.org/@ampproject/remapping/-/remapping-2.3.0.tgz",
"integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==", "integrity": "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==",
"dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/gen-mapping": "^0.3.5",
@ -499,6 +499,7 @@
"version": "0.3.12", "version": "0.3.12",
"resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.12.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.12.tgz",
"integrity": "sha512-OuLGC46TjB5BbN1dH8JULVVZY4WTdkF7tV9Ys6wLL1rubZnCMstOhNHueU5bLCrnRuDhKPDM4g6sw4Bel5Gzqg==", "integrity": "sha512-OuLGC46TjB5BbN1dH8JULVVZY4WTdkF7tV9Ys6wLL1rubZnCMstOhNHueU5bLCrnRuDhKPDM4g6sw4Bel5Gzqg==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/sourcemap-codec": "^1.5.0",
@ -509,6 +510,7 @@
"version": "3.1.2", "version": "3.1.2",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=6.0.0" "node": ">=6.0.0"
@ -518,27 +520,20 @@
"version": "1.5.4", "version": "1.5.4",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.4.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.4.tgz",
"integrity": "sha512-VT2+G1VQs/9oz078bLrYbecdZKs912zQlkelYpuf+SXF+QvZDYJlbx/LSx+meSAwdDFnF8FVXW92AVjjkVmgFw==", "integrity": "sha512-VT2+G1VQs/9oz078bLrYbecdZKs912zQlkelYpuf+SXF+QvZDYJlbx/LSx+meSAwdDFnF8FVXW92AVjjkVmgFw==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@jridgewell/trace-mapping": { "node_modules/@jridgewell/trace-mapping": {
"version": "0.3.29", "version": "0.3.29",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.29.tgz", "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.29.tgz",
"integrity": "sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ==", "integrity": "sha512-uw6guiW/gcAGPDhLmd77/6lW8QLeiV5RUTsAX46Db6oLhGaVj4lhnPwb184s1bkc8kdVg/+h988dro8GRDpmYQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/resolve-uri": "^3.1.0",
"@jridgewell/sourcemap-codec": "^1.4.14" "@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": { "node_modules/@polka/url": {
"version": "1.0.0-next.29", "version": "1.0.0-next.29",
"resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz", "resolved": "https://registry.npmjs.org/@polka/url/-/url-1.0.0-next.29.tgz",
@ -836,6 +831,7 @@
"version": "1.0.5", "version": "1.0.5",
"resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.5.tgz", "resolved": "https://registry.npmjs.org/@sveltejs/acorn-typescript/-/acorn-typescript-1.0.5.tgz",
"integrity": "sha512-IwQk4yfwLdibDlrXVE04jTZYlLnwsTT2PIOQQGNLWfjavGifnk1JD1LcZjZaBTRcxZu2FfPfNLOE04DSu9lqtQ==", "integrity": "sha512-IwQk4yfwLdibDlrXVE04jTZYlLnwsTT2PIOQQGNLWfjavGifnk1JD1LcZjZaBTRcxZu2FfPfNLOE04DSu9lqtQ==",
"dev": true,
"license": "MIT", "license": "MIT",
"peerDependencies": { "peerDependencies": {
"acorn": "^8.9.0" "acorn": "^8.9.0"
@ -1211,12 +1207,26 @@
"version": "1.0.8", "version": "1.0.8",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz",
"integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/@types/node": {
"version": "24.1.0",
"resolved": "https://registry.npmjs.org/@types/node/-/node-24.1.0.tgz",
"integrity": "sha512-ut5FthK5moxFKH2T1CUOC6ctR67rQRvvHdFLCD2Ql6KXmMuCrjsSsRI9UsLCm9M18BMwClv4pn327UvB7eeO1w==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"undici-types": "~7.8.0"
}
},
"node_modules/acorn": { "node_modules/acorn": {
"version": "8.15.0", "version": "8.15.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz",
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT", "license": "MIT",
"bin": { "bin": {
"acorn": "bin/acorn" "acorn": "bin/acorn"
@ -1229,6 +1239,7 @@
"version": "5.3.2", "version": "5.3.2",
"resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz", "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.2.tgz",
"integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==", "integrity": "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==",
"dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"engines": { "engines": {
"node": ">= 0.4" "node": ">= 0.4"
@ -1238,6 +1249,7 @@
"version": "4.1.0", "version": "4.1.0",
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz", "resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
"integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==", "integrity": "sha512-qIj0G9wZbMGNLjLmg1PT6v2mE9AH2zlnADJD/2tC6E00hgmhUOfEB6greHPAfLRSufHqROIUTkw6E+M3lH0PTQ==",
"dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"engines": { "engines": {
"node": ">= 0.4" "node": ">= 0.4"
@ -1273,6 +1285,7 @@
"version": "2.1.1", "version": "2.1.1",
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
"dev": true,
"license": "MIT", "license": "MIT",
"engines": { "engines": {
"node": ">=6" "node": ">=6"
@ -1432,12 +1445,14 @@
"version": "1.2.2", "version": "1.2.2",
"resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz", "resolved": "https://registry.npmjs.org/esm-env/-/esm-env-1.2.2.tgz",
"integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==", "integrity": "sha512-Epxrv+Nr/CaL4ZcFGPJIYLWFom+YeV1DqMLHJoEd9SYRxNbaFruBwfEX/kkHUJf55j2+TUbmDcmuilbP1TmXHA==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/esrap": { "node_modules/esrap": {
"version": "2.1.0", "version": "2.1.0",
"resolved": "https://registry.npmjs.org/esrap/-/esrap-2.1.0.tgz", "resolved": "https://registry.npmjs.org/esrap/-/esrap-2.1.0.tgz",
"integrity": "sha512-yzmPNpl7TBbMRC5Lj2JlJZNPml0tzqoqP5B1JXycNUwtqma9AKCO0M2wHrdgsHcy1WRW7S9rJknAMtByg3usgA==", "integrity": "sha512-yzmPNpl7TBbMRC5Lj2JlJZNPml0tzqoqP5B1JXycNUwtqma9AKCO0M2wHrdgsHcy1WRW7S9rJknAMtByg3usgA==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@jridgewell/sourcemap-codec": "^1.4.15" "@jridgewell/sourcemap-codec": "^1.4.15"
@ -1484,6 +1499,7 @@
"version": "3.0.3", "version": "3.0.3",
"resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz", "resolved": "https://registry.npmjs.org/is-reference/-/is-reference-3.0.3.tgz",
"integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==", "integrity": "sha512-ixkJoqQvAP88E6wLydLGGqCJsrFUnqoH6HnaczB8XmDH1oaWU+xxdptvikTgaEhtZ53Ky6YXiBuUI2WXLMCwjw==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@types/estree": "^1.0.6" "@types/estree": "^1.0.6"
@ -1752,12 +1768,14 @@
"version": "3.0.0", "version": "3.0.0",
"resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz", "resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz",
"integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==", "integrity": "sha512-SW13ws7BjaeJ6p7Q6CO2nchbYEc3X3J6WrmTTDto7yMPqVSZTUyY5Tjbid+Ab8gLnATtygYtiDIJGQRRn2ZOiA==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/magic-string": { "node_modules/magic-string": {
"version": "0.30.17", "version": "0.30.17",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.17.tgz",
"integrity": "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==", "integrity": "sha512-sNPKHvyjVf7gyjwS4xGTaW/mCnF8wnjtifKBEhxfZ7E/S8tQ0rssrwGNn6q8JH/ohItJfSQp9mBtQYuTlH5QnA==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@jridgewell/sourcemap-codec": "^1.5.0" "@jridgewell/sourcemap-codec": "^1.5.0"
@ -2175,6 +2193,7 @@
"version": "5.37.1", "version": "5.37.1",
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.37.1.tgz", "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.37.1.tgz",
"integrity": "sha512-h8arWpQZ+3z8eahyBT5KkiBOUsG6xvI5Ykg0ozRr9xEdImgSMUPUlOFWRNkUsT7Ti0DSUCTEbPoped0aoxFyWA==", "integrity": "sha512-h8arWpQZ+3z8eahyBT5KkiBOUsG6xvI5Ykg0ozRr9xEdImgSMUPUlOFWRNkUsT7Ti0DSUCTEbPoped0aoxFyWA==",
"dev": true,
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@ampproject/remapping": "^2.3.0", "@ampproject/remapping": "^2.3.0",
@ -2296,6 +2315,15 @@
"node": ">=14.17" "node": ">=14.17"
} }
}, },
"node_modules/undici-types": {
"version": "7.8.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.8.0.tgz",
"integrity": "sha512-9UJ2xGDvQ43tYyVMpuHlsgApydB8ZKfVYTsLDhXkFL/6gfkp+U8xTGdh8pMJv1SpZna0zxG1DwsKZsreLbXBxw==",
"dev": true,
"license": "MIT",
"optional": true,
"peer": true
},
"node_modules/vite": { "node_modules/vite": {
"version": "7.0.6", "version": "7.0.6",
"resolved": "https://registry.npmjs.org/vite/-/vite-7.0.6.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-7.0.6.tgz",
@ -2434,6 +2462,7 @@
"version": "1.1.2", "version": "1.1.2",
"resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.2.tgz", "resolved": "https://registry.npmjs.org/zimmerframe/-/zimmerframe-1.1.2.tgz",
"integrity": "sha512-rAbqEGa8ovJy4pyBxZM70hg4pE6gDgaQ0Sl9M3enG3I0d6H4XSAM3GeNGLKnsBpuijUow064sf7ww1nutC5/3w==", "integrity": "sha512-rAbqEGa8ovJy4pyBxZM70hg4pE6gDgaQ0Sl9M3enG3I0d6H4XSAM3GeNGLKnsBpuijUow064sf7ww1nutC5/3w==",
"dev": true,
"license": "MIT" "license": "MIT"
}, },
"node_modules/zod": { "node_modules/zod": {

View file

@ -28,8 +28,7 @@
"vite": "^7.0.4" "vite": "^7.0.4"
}, },
"dependencies": { "dependencies": {
"@lucide/svelte": "^0.536.0", "zod": "^4.0.14",
"socket.io-client": "^4.8.1", "socket.io-client": "^4.8.1"
"zod": "^4.0.14"
} }
} }

View file

@ -1,4 +1,3 @@
@import url('https://fonts.googleapis.com/css2?family=Lilita+One&display=swap');
@import 'tailwindcss'; @import 'tailwindcss';
@keyframes spin-slower { @keyframes spin-slower {
@ -14,53 +13,3 @@
.spin-slower { .spin-slower {
animation: spin-slower 15s linear infinite; 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%);
}

View file

@ -1,13 +1,15 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<link rel="icon" href="%sveltekit.assets%/favicon.ico" /> <link rel="icon" href="%sveltekit.assets%/favicon.svg" />
<link rel="manifest" href="manifest.json" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
%sveltekit.head% %sveltekit.head%
</head> </head>
<body data-sveltekit-preload-data="hover" class="h-max"> <body data-sveltekit-preload-data="hover" class="h-max">
<div style="display: contents">%sveltekit.body%</div> <div style="display: contents">%sveltekit.body%</div>
</body> </body>
</html> </html>

View file

@ -1,10 +1,8 @@
<script lang="ts"> <script lang="ts">
import type { FetchError } from "$lib/types" let { code, message } = $props()
let { returnError }: { returnError: FetchError } = $props()
</script> </script>
<div class="flex h-screen w-full flex-col items-center justify-center"> <div class="flex h-screen w-full flex-col items-center justify-center">
<h1 class="p-2 text-xl">Error {returnError.code}</h1> <h1 class="p-2 text-xl">Error {code}</h1>
<p>{returnError.message}</p> <p>{message}</p>
</div> </div>

View file

@ -1,17 +1,13 @@
<script lang="ts"> <script lang="ts">
import { type Song, createEmptySong } from "$lib/types" import { type Song, createEmptySong } from "$lib/types"
let { queueSongs, playingIndex } = $props() let { songs, playing } = $props()
let displaySongs = $derived<Song[]>([ let displaySongs = $derived<Song[]>([
playingIndex > 0 ? queueSongs[playingIndex - 1] : createEmptySong(), playing > 0 && playing < songs.length ? songs[playing - 1] : createEmptySong(),
queueSongs[playingIndex], songs[playing],
playingIndex == queueSongs.length - 1 ? createEmptySong() : queueSongs[playingIndex + 1], playing == songs.length - 1 ? createEmptySong() : songs[playing + 1],
]) ])
$effect(() => {
console.log(displaySongs)
})
</script> </script>
<div class="relative flex w-full justify-center overflow-hidden"> <div class="relative flex w-full justify-center overflow-hidden">
@ -23,22 +19,20 @@
class={`flex h-[60vw] max-h-[250px] w-[60vw] max-w-[250px] items-center justify-center ${i === 1 ? "spin-slower rounded-full border-2 border-black" : "rounded"} object-cover`} class={`flex h-[60vw] max-h-[250px] w-[60vw] max-w-[250px] items-center justify-center ${i === 1 ? "spin-slower rounded-full border-2 border-black" : "rounded"} object-cover`}
> >
{#if i === 1} {#if i === 1}
<div class="absolute z-20 h-16 w-16 rounded-full border-2 border-black bg-light-pine-base dark:bg-dark-pine-base"></div> <div class="absolute z-20 h-16 w-16 rounded-full border-2 border-black bg-white"></div>
{/if} {/if}
<img <img class={`h-full overflow-hidden ${i === 1 ? "rounded-full" : "rounded"}`} src={`https://lastfm.freetls.fastly.net/i/u/174s/${song.image_id}.png`} alt="Song cover" />
class={`h-full overflow-hidden ${i === 1 ? "rounded-full" : "rounded"}`}
src={song.image_id != ""
? `https://lastfm.freetls.fastly.net/i/u/174s/${song.image_id}.png`
: "https://s2.qwant.com/thumbr/474x474/f/6/b50687db1ebb262ac78b98a8f3c56a1e62235aaeebe0346dd27d4fbf1edec8/OIP.kXN41HyriW5dLTkjm0QQoAHaHa.jpg?u=https%3A%2F%2Ftse.mm.bing.net%2Fth%2Fid%2FOIP.kXN41HyriW5dLTkjm0QQoAHaHa%3Fpid%3DApi&q=0&b=1&p=0&a=0"}
alt="Song cover"
/>
</div> </div>
{#if i === 1} {#if i === 1}
<h1 class="mt-5">{song.title} - {song.artist}</h1> <h1 class="mt-2">{song.title} - {song.artist}</h1>
{/if} {/if}
</div> </div>
{:else} {:else}
<div class="flex h-[60vw] max-h-[250px] w-[60vw] max-w-[250px] items-center justify-center"></div> <div class="flex h-[60vw] max-h-[250px] w-[60vw] max-w-[250px] items-center justify-center">
{#if i === 1}
<p>No song in queue</p>
{/if}
</div>
{/if} {/if}
{/each} {/each}
</div> </div>

View file

@ -1,45 +0,0 @@
<script lang="ts">
import { type Room } from "$lib/types"
let { room }: { room: Room } = $props()
let showPinModal: boolean = $state(false)
let pin: number = $state()
</script>
<div
class="flex gap-2 w-82 cursor-pointer flex-col rounded-md border border-dark-pine-muted bg-light-pine-overlay p-3 hover:bg-dark-pine-base/20 dark:bg-dark-pine-overlay hover:dark:bg-light-pine-base/20"
>
<button
class="flex flex-row items-center"
onclick={() => {
if (!room.private) {
window.location.href = "/room/" + room.id
return
}
showPinModal = !showPinModal
}}
>
<div class="flex flex-row">
{room.name}
{room.private ? "🔒" : ""}
</div>
<div class="grow"></div>
<div class="flex flex-row items-center gap-2">
<div class="font-mono">{Math.round(room.distance)}m</div>
<div class="rounded bg-light-pine-blue px-2 py-0.5 text-dark-pine-text dark:bg-dark-pine-blue">Join</div>
</div>
</button>
{#if showPinModal}
<input
placeholder="PIN (requied)"
class="p-2 text-xl rounded-md border-dark-pine-muted bg-light-pine-overlay dark:bg-dark-pine-base hover:dark:bg-light-pine-base/20 duration-100 outline-none focus:ring-2"
type="number"
bind:value={pin}
/>
<button
onclick={() => {
window.location.href = `/room/${room.id}?pin=${pin}`
}}
class="p-2 text-xl rounded-md border-dark-pine-muted bg-light-pine-overlay dark:bg-dark-pine-base hover:dark:bg-light-pine-base/20 duration-100 outline-none focus:ring-2">JOIN</button
>
{/if}
</div>

View file

@ -1,69 +1,15 @@
<script lang="ts"> <script lang="ts">
import { LoaderCircle } from "@lucide/svelte"
const COOLDOWN_SECS = 10
let { roomId } = $props() let { roomId } = $props()
let input = $state("")
let loading: boolean = $state(false)
let cooldowned: boolean = $state(false)
let errorMsg: string = $state()
$effect(() => { let input = $state("")
console.log("cooldowned is now", cooldowned)
})
async function sendSong() { async function sendSong() {
loading = true let resp = await fetch(`/api/addsong?room=${roomId}&query=${input}`, { method: "POST" })
const res = await fetch(`/api/addsong?room=${roomId}&query=${input}`, { method: "POST" })
const json = await res.json()
input = "" input = ""
loading = false
if (!json.success) {
errorMsg = json.error
}
cooldowned = true
setTimeout(() => {
cooldowned = false
console.log("unset cooldown")
}, COOLDOWN_SECS * 1000)
} }
</script> </script>
<div <div class="flex h-full w-full flex-row items-center gap-2 rounded border-2 border-black p-4">
class={`flex h-full w-full flex-row items-center gap-2 rounded-md border-dark-pine-muted bg-light-pine-overlay hover:bg-dark-pine-base/20 dark:bg-dark-pine-overlay hover:dark:bg-light-pine-base/20 ${loading ? "disabled" : ""}`} <input type="text" placeholder="Song & Artist" class="h-full w-3/4 rounded" bind:value={input} />
> <button class="w-1/4 rounded" onclick={sendSong}>Send</button>
<input
type="text"
placeholder="Song & Artist"
class="h-[50px] w-3/4 rounded px-4 font-bold text-white outline-none"
bind:value={input}
onkeydown={(e) => {
errorMsg = null
if (e.key == "Enter") {
sendSong()
}
}}
disabled={loading}
/>
{#if loading}
<span class="animate-spin">
<LoaderCircle />
</span>
{/if}
<button
disabled={cooldowned}
class="i-lucide-check h-[40px] w-1/4 rounded font-semibold shadow-xl duration-100 active:scale-90 {!cooldowned
? 'cursor-pointer bg-light-pine-blue hover:scale-105 dark:bg-dark-pine-blue '
: 'bg-light-pine-muted dark:bg-light-pine-muted'}"
onclick={sendSong}>Add</button
>
<span class="i-lucide-chevrons-left"></span>
</div> </div>
<p class="text-red-500 font-semibold">
{errorMsg}
</p>

View file

@ -1,62 +1,31 @@
<script lang="ts"> <script lang="ts">
import type { Suggestion } from "$lib/types" import type { Suggestion } from "$lib/types"
import { ThumbsUp, ThumbsDown } from "@lucide/svelte"
import { onMount } from "svelte"
let { suggestions = $bindable(), roomId }: { suggestions: Suggestion[]; roomId: string } = $props() let { suggestions, roomId }: { suggestions: Suggestion[]; roomId: string } = $props()
let picked_suggestions: string[] = $state([])
async function vote(amount: number, songId: string) { async function vote(amount: number, songId: string) {
if (picked_suggestions.includes(songId)) return console.log("rejecting vote")
await fetch(`/api/song/voting?room=${roomId}&song=${songId}&increment=${amount}`, { method: "POST" }) await fetch(`/api/song/voting?room=${roomId}&song=${songId}&increment=${amount}`, { method: "POST" })
picked_suggestions.push(songId)
console.log("accepted vote")
sessionStorage.setItem("picked_suggestions", JSON.stringify(picked_suggestions))
} }
onMount(() => {
picked_suggestions = JSON.parse(sessionStorage.getItem("picked_suggestions") ?? "[]")
})
</script> </script>
<div class="flex h-full w-full flex-col items-center gap-2 overflow-y-auto"> <div class="flex h-full w-full flex-col items-center gap-2 overflow-y-auto">
{#if suggestions.length == 0}
<p>No suggestions yet! Try to add a new one using the Add button</p>
{/if}
{#each suggestions as sug} {#each suggestions as sug}
<div <div class="flex h-[80px] w-full flex-row gap-2 rounded border-2 border-black p-2">
class="flex h-[80px] w-full flex-row gap-2 rounded-md border-dark-pine-muted bg-light-pine-overlay p-2 shadow-md duration-100 hover:bg-dark-pine-base/20 dark:bg-dark-pine-overlay hover:dark:bg-light-pine-base/20" <div class="flex w-3/4 flex-row gap-2">
> <img class="w-[60px] min-w-[60px] rounded" src={`https://lastfm.freetls.fastly.net/i/u/174s/${sug.image_id}.png`} alt="Song cover" />
<div class="flex w-3/4 flex-row items-center gap-2"> <h1>{sug.title} - {sug.artist}</h1>
<img
class="w-[60px] min-w-[60px] rounded"
alt="Song cover"
src={sug.image_id != ""
? `https://lastfm.freetls.fastly.net/i/u/174s/${sug.image_id}.png`
: "https://s2.qwant.com/thumbr/474x474/f/6/b50687db1ebb262ac78b98a8f3c56a1e62235aaeebe0346dd27d4fbf1edec8/OIP.kXN41HyriW5dLTkjm0QQoAHaHa.jpg?u=https%3A%2F%2Ftse.mm.bing.net%2Fth%2Fid%2FOIP.kXN41HyriW5dLTkjm0QQoAHaHa%3Fpid%3DApi&q=0&b=1&p=0&a=0"}
/>
<div class="h-fit w-fit flex-col text-white">
<b>{sug.title}</b>
<p>{sug.artist}</p>
</div>
</div> </div>
<div class="flex w-1/4 flex-row items-center justify-center gap-2"> <div class="flex w-1/4 flex-row items-center justify-center gap-2">
<button <button
class={!picked_suggestions.includes(sug.uuid) ? "text-light-pine-green duration-100 hover:scale-150 dark:text-dark-pine-green" : "text-light-pine-muted dark:text-dark-pine-muted"} onclick={() => {
disabled={!!picked_suggestions.includes(sug.uuid)} vote(1, sug.uuid)
onclick={async () => { }}>+1</button
await vote(1, sug.uuid)
}}><ThumbsUp /></button
> >
<p class="font-semibold text-light-pine-text dark:text-dark-pine-text">{sug.upvote}</p> <p>{sug.upvote}</p>
<button <button
class={!picked_suggestions.includes(sug.uuid) ? "text-light-pine-red duration-100 hover:scale-150 dark:text-dark-pine-red" : "text-light-pine-muted dark:text-dark-pine-muted"} onclick={() => {
disabled={!!picked_suggestions.includes(sug.uuid)} vote(-1, sug.uuid)
onclick={async () => { }}>-1</button
await vote(-1, sug.uuid)
}}><ThumbsDown /></button
> >
</div> </div>
</div> </div>

View file

@ -1,36 +0,0 @@
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)
})
}

View file

@ -22,7 +22,7 @@ export const createEmptySong = function (): Song {
artist: "", artist: "",
tags: [""], tags: [""],
image_id: "", image_id: "",
youtube_id: "", youtube_id: 0,
} }
} }
@ -35,23 +35,3 @@ export const parseSuggestion = async function (sugg: any): Promise<Suggestion> {
let resp = await SuggestionSchema.parseAsync(sugg) let resp = await SuggestionSchema.parseAsync(sugg)
return resp 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<typeof RoomSchema>
export const parseRoom = async function (room: any): Promise<Room> {
let resp = await RoomSchema.parseAsync(room)
return resp
}
export type FetchError = {
code: number
message: string
}

1
frontend/src/lib/util.ts Normal file
View file

@ -0,0 +1 @@
export type FetchError = { code: number, message: string }

View file

@ -1,76 +0,0 @@
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"]
}

View file

@ -4,6 +4,4 @@
let { children } = $props() let { children } = $props()
</script> </script>
<div class="min-h-screen w-full bg-light-pine-base px-1 text-light-pine-text sm:px-20 md:px-40 lg:px-80 dark:bg-dark-pine-base dark:text-dark-pine-text">
{@render children()} {@render children()}
</div>

View file

@ -1,44 +1,8 @@
<script lang="ts"> <script lang="ts">
import RoomComponent from "$lib/components/RoomComponent.svelte"
import { get_coords } from "$lib/gps"
import { parseRoom, type Room } from "$lib/types"
import { Plus } from "@lucide/svelte"
import { onMount } from "svelte"
let rooms: Room[] = $state([])
onMount(async () => {
let { coords, error } = await get_coords()
if (error != null || coords == null) coords = { latitude: 46.6769043, longitude: 11.1851585 }
let res = await fetch(`/api/room?lat=${coords.latitude}&lon=${coords.longitude}`)
let json = await res.json()
for (let room of json) rooms.push(await parseRoom(room))
})
</script> </script>
<div class="flex flex-col items-center gap-2"> <div class="flex h-full w-full items-center justify-center p-4">
<div class="flex flex-row items-center gap-4 py-4"> <div class="">
<img src="icon_small.png" class="h-12 w-12" alt="chillbox icon" /> <h1>Scan your nearby rooms</h1>
<span class="lilita-one-regular text-6xl font-bold">ChillBox</span>
</div>
<img src="/radar_bonus.gif" alt="radar" class="h-64 w-64" />
<span class="animate-pulse text-sm italic">Scanning for rooms near you...</span>
<button
onclick={() => {
window.location.href = "/room/create"
}}
class="fixed right-4 bottom-4 flex flex-row gap-1 rounded-xl bg-light-pine-blue p-2 text-dark-pine-text sm:right-20 md:right-40 lg:right-80 dark:bg-dark-pine-blue"
>
<span> New Room </span>
<Plus />
</button>
<div class="flex flex-col gap-2">
{#each rooms as room}
<RoomComponent {room} />
{/each}
</div> </div>
</div> </div>

View file

@ -1,118 +1,37 @@
<script lang="ts"> <script lang="ts">
import QueueSlider from "$lib/components/QueueSlider.svelte" import QueueSlider from "$lib/components/QueueSlider.svelte"
import { type Song } from "$lib/types"
import { onMount } from "svelte"
import type { FetchError } from "$lib/types"
import { getQueueSongs, getStreamingUrl, triggerPlayNext } from "$lib/utils.js"
import Error from "$lib/components/Error.svelte"
let { data } = $props() let songs = $state([
{
name: "Cisco PT - Cantarex",
image: "https://s2.qwant.com/thumbr/474x474/5/9/bcbd0c0aeb1838f6916bf452c557251d7be985a13449e49fccb567a3374d4e/OIP.pmqEiKWv47zViDGgPgbbQAHaHa.jpg?u=https%3A%2F%2Ftse.mm.bing.net%2Fth%2Fid%2FOIP.pmqEiKWv47zViDGgPgbbQAHaHa%3Fr%3D0%26pid%3DApi&q=0&b=1&p=0&a=0",
points: 0,
},
{
name: "Io e i miei banchi - Paul Ham",
image: "https://i.prcdn.co/img?regionKey=RbtvKb5E1Cv4j1VWm2uGrw%3D%3D",
points: 0,
},
{
name: "Ragatthi - Bersatetthi",
image: "https://s2.qwant.com/thumbr/474x474/5/9/bcbd0c0aeb1838f6916bf452c557251d7be985a13449e49fccb567a3374d4e/OIP.pmqEiKWv47zViDGgPgbbQAHaHa.jpg?u=https%3A%2F%2Ftse.mm.bing.net%2Fth%2Fid%2FOIP.pmqEiKWv47zViDGgPgbbQAHaHa%3Fr%3D0%26pid%3DApi&q=0&b=1&p=0&a=0",
points: 0,
},
{
name: "Quarta",
image: "https://s2.qwant.com/thumbr/474x474/5/9/bcbd0c0aeb1838f6916bf452c557251d7be985a13449e49fccb567a3374d4e/OIP.pmqEiKWv47zViDGgPgbbQAHaHa.jpg?u=https%3A%2F%2Ftse.mm.bing.net%2Fth%2Fid%2FOIP.pmqEiKWv47zViDGgPgbbQAHaHa%3Fr%3D0%26pid%3DApi&q=0&b=1&p=0&a=0",
points: 0,
},
{
name: "Quinta",
image: "https://s2.qwant.com/thumbr/474x474/5/9/bcbd0c0aeb1838f6916bf452c557251d7be985a13449e49fccb567a3374d4e/OIP.pmqEiKWv47zViDGgPgbbQAHaHa.jpg?u=https%3A%2F%2Ftse.mm.bing.net%2Fth%2Fid%2FOIP.pmqEiKWv47zViDGgPgbbQAHaHa%3Fr%3D0%26pid%3DApi&q=0&b=1&p=0&a=0",
points: 0,
},
])
let queueSongs = $state<Song[]>([]) let playing = $state(1)
let playingIndex = $state<number>(0)
let returnError = $state<FetchError | null>()
let currentPlaying = $derived<Song>(queueSongs[playingIndex])
let audioController = $state<HTMLAudioElement>()
let playerInfo = $state({
playing: false,
currentTime: 0,
duration: 0,
})
async function playOnEnd() {
let url = await getStreamingUrl(currentPlaying.uuid)
if (audioController) {
audioController.src = url
await audioController.play()
}
}
$effect(() => {
if (audioController) {
audioController.ontimeupdate = () => {
playerInfo.currentTime = audioController?.currentTime || 0
}
audioController.onloadedmetadata = () => {
playerInfo.duration = audioController?.duration || 0
}
audioController.onplay = () => {
playerInfo.playing = true
}
audioController.onpause = () => {
playerInfo.playing = false
}
}
})
onMount(async () => {
let songs, index
;[returnError, songs, index] = await getQueueSongs(data.roomId)
queueSongs = songs
playingIndex = index
if (queueSongs.length == 0) {
playNext()
}
})
$effect(() => {
playOnEnd()
})
const formatTime = (t: number) => {
const min = Math.floor(t / 60)
const sec = Math.floor(t % 60)
return `${min}:${sec.toString().padStart(2, "0")}`
}
async function playNext() {
let songs, index
;[returnError, songs, index] = await triggerPlayNext(data.roomId)
if (returnError) return
queueSongs = songs
playingIndex = index
if (audioController) {
audioController.pause()
audioController.currentTime = 0
}
}
function seek(e: Event) {
const target = e.target as HTMLInputElement
const seekTime = parseFloat(target.value)
playerInfo.currentTime = seekTime
if (audioController) {
audioController.currentTime = seekTime
}
}
</script> </script>
{#if returnError}
<Error {returnError} />
{:else}
<div class="flex w-full flex-col items-center justify-center p-4 lg:p-10"> <div class="flex w-full flex-col items-center justify-center p-4 lg:p-10">
<QueueSlider {queueSongs} {playingIndex} /> <QueueSlider {songs} {playing} />
<audio autoplay bind:this={audioController} hidden onended={playNext}></audio>
<div class="flex w-full flex-col items-start justify-start gap-4 p-2 lg:w-[30vw]">
<p>{formatTime(playerInfo.currentTime)} - {formatTime(playerInfo.duration)}</p>
<input type="range" min="0" max={playerInfo.duration} step="0.1" value={playerInfo.currentTime} class="w-full accent-blue-500" oninput={seek} />
<div class="flex w-full flex-row items-center justify-center gap-6">
<button
class="rounded-md border border-dark-pine-muted p-2 hover:scale-105 active:scale-90 dark:bg-dark-pine-blue"
onclick={() => (playerInfo.playing ? audioController?.pause() : audioController?.play())}>{playerInfo.playing ? "Pause" : "Unpause"}</button
>
<button class="rounded-md border border-dark-pine-muted p-2 hover:scale-105 active:scale-90 dark:bg-dark-pine-blue" onclick={playNext}>Next</button>
</div> </div>
</div>
<img class="absolute right-1 bottom-1" src="/api/room/qrcode?room=1000&pin=1234" />
<!-- @PERETTO fix here pls -->
</div>
{/if}

View file

@ -1,11 +0,0 @@
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")
}

View file

@ -2,87 +2,78 @@
import QueueSlider from "$lib/components/QueueSlider.svelte" import QueueSlider from "$lib/components/QueueSlider.svelte"
import SuggestionInput from "$lib/components/SuggestionInput.svelte" import SuggestionInput from "$lib/components/SuggestionInput.svelte"
import Error from "$lib/components/Error.svelte" import Error from "$lib/components/Error.svelte"
import { type Suggestion, type Song, parseSong, parseSuggestion } from "$lib/types.js" import { parseSong, parseSuggestion, type Suggestion, type Song } from "$lib/types.js"
import { onDestroy, onMount } from "svelte" import { onMount } from "svelte"
import SuggestionList from "$lib/components/SuggestionList.svelte" import SuggestionList from "$lib/components/SuggestionList.svelte"
import { getQueueSongs, getSuggestions, joinRoom } from "$lib/utils.js" import { io } from "socket.io-client"
import type { FetchError } from "$lib/types.js"
import { io, Socket } from "socket.io-client"
import { get_coords } from "$lib/gps.js"
let { data } = $props() let { data } = $props()
let queueSongs = $state<Song[]>([]) let songs = $state<Song[]>([])
let playingIndex = $state(0) let playing = $state(0)
let suggestions = $state<Suggestion[]>([]) let suggestions = $state<Suggestion[]>([])
let returnError = $state<FetchError | null>() let error = $state({ code: 0, message: "" })
let socket: Socket
onMount(async () => { onMount(async () => {
// Join the room // Join the room
let resp = await fetch("/api/join?room=" + data.roomId)
let { coords, error } = await get_coords() if (resp.status != 200) {
if (error || coords == null) { error = { code: 400, message: "Failed to join the room. Maybe wrong code or location?" }
// Default to Lido
coords = { latitude: 46.6769043, longitude: 11.1851585 }
}
let sugg, queue, index
;[returnError] = await joinRoom(data.roomId, coords, data.pin)
if (returnError) {
return return
} }
;[returnError, sugg] = await getSuggestions(data.roomId)
if (returnError) return
;[returnError, queue, index] = await getQueueSongs(data.roomId)
if (returnError) {
return
}
queueSongs = queue
suggestions = sugg
playingIndex = index
// Setup websocket connection // Setup websocket connection
socket = io("/", { path: "/ws", transports: ["websocket"] }) let socket = io("/", { path: "/ws" })
await socket.emitWithAck("join_room", { id: data.roomId }) await socket.emitWithAck("join_room", { id: data.roomId })
socket.on("queue_update", async (d) => { // Get room suggestions
const songs = await Promise.all(d.queue.map(parseSong)) resp = await fetch("/api/room/suggestions?room=" + data.roomId)
queueSongs = songs
playingIndex = d.index if (resp.status != 200) {
error = { code: 500, message: "Failed to retrive suggestions" }
return
}
let json = await resp.json()
json["songs"].forEach(async (i: any) => {
suggestions.push(await parseSuggestion(i))
}) })
socket.on("new_vote", async (d) => { suggestions = suggestions.sort((a, b) => {
const updated = await parseSuggestion(d.song) return a.upvote - b.upvote
suggestions = suggestions.map((s) => (s.uuid === updated.uuid ? updated : s))
}) })
socket.on("new_song", async (d) => { // Get the room queue
const song = await parseSuggestion(d.song)
suggestions = [...suggestions, song] resp = await fetch("/api/queue?room=" + data.roomId)
if (resp.status != 200) {
error = { code: 404, message: "Room not found" }
return
}
json = await resp.json()
json["queue"].forEach(async (i: any) => {
songs.push(await parseSong(i))
}) })
}) console.log(songs)
onDestroy(() => {
if (socket) socket.disconnect()
}) })
</script> </script>
<!-- Check if the room exists --> <!-- Check if the room exists -->
{#if returnError} {#if error.code != 0}
<Error {returnError} /> <Error code={error.code} message={error.message} />
{:else} {:else}
<div class="flex w-full flex-col items-center justify-center px-2 py-4 lg:p-10"> <div class="flex w-full flex-col items-center justify-center p-4 lg:p-10">
<QueueSlider {queueSongs} {playingIndex} /> <QueueSlider {songs} {playing} />
<div class="w-full py-6 lg:w-[30vw]"> <div class="w-full py-6 lg:w-[30vw]">
<SuggestionInput roomId={data.roomId} /> <SuggestionInput roomId={data.roomId} />
</div> </div>
<div class="w-full py-6 lg:w-[30vw]"> <div class="w-full py-6 lg:w-[30vw]">
<SuggestionList bind:suggestions roomId={data.roomId} /> <SuggestionList {suggestions} roomId={data.roomId} />
</div> </div>
</div> </div>
{/if} {/if}

View file

@ -1,8 +1,13 @@
import { error, type Load, type ServerLoad } from "@sveltejs/kit"
import type { PageLoad } from "./$types" import type { PageLoad } from "./$types"
import type { FetchError } from "$lib/util"
import { parseSuggestion, type Suggestion } from "$lib/types"
export const load: PageLoad = ({ params, url }) => { export const load: PageLoad = async ({ params }) => {
if (params.id) {
return { return {
roomId: params.id || "", roomId: params.id,
pin: url.searchParams.get("pin") || "",
} }
} }
error(400, "Please provide a room id")
}

View file

@ -1,83 +0,0 @@
<script lang="ts">
import { get_coords } from "$lib/gps"
import { X, Check, Plus } from "@lucide/svelte"
import { onMount } from "svelte"
let privateRoom: boolean = $state(true)
let coord = $state({ latitude: 0, longitude: 0 })
let creating: boolean = $state(false)
let name: string = $state()
let range: number = $state()
let pin: number = $state()
async function createRoom() {
if (creating) {
return
}
if (!name) {
return
}
creating = true
const res = await fetch(
`/api/room/new?name=${encodeURIComponent(name)}&coords=${coord.latitude},${coord.longitude}&range=${encodeURIComponent(range ?? "100")}&pin=${encodeURIComponent(pin ?? "")}`,
{ method: "POST" }
)
const json = await res.json()
window.location.href = `/admin/${json.room_id}`
}
onMount(async () => {
const res = await get_coords()
coord = res.coords ?? { latitude: 46.6769043, longitude: 11.1851585 }
})
</script>
<form class="flex flex-col items-center">
<h1 class="text-4xl my-10">Create Room</h1>
<div class="flex flex-col gap-3 w-1/2">
<input
bind:value={name}
placeholder="Room name (Required)"
class="{name
? ''
: 'border-2 border-red-500'} p-2 text-xl rounded-md bg-light-pine-overlay hover:bg-dark-pine-base/20 dark:bg-dark-pine-overlay hover:dark:bg-light-pine-base/20 duration-100 outline-none focus:ring-2"
/>
<input
bind:value={range}
type="number"
min="10"
placeholder="Range (in meters)"
class="p-2 text-xl rounded-md border-dark-pine-muted bg-light-pine-overlay hover:bg-dark-pine-base/20 dark:bg-dark-pine-overlay hover:dark:bg-light-pine-base/20 duration-100 outline-none focus:ring-2"
/>
<p>
Room Coordinates:
<span>{coord.latitude},{coord.longitude}</span>
</p>
<input
bind:value={pin}
type="number"
max="9999"
placeholder="PIN (none if public)"
class="p-2 text-xl rounded-md border-dark-pine-muted bg-light-pine-overlay hover:bg-dark-pine-base/20 dark:bg-dark-pine-overlay hover:dark:bg-light-pine-base/20 duration-100 outline-none focus:ring-2"
/>
<button
type="button"
class="cursor-pointer flex flex-row items-center justify-center gap-3 border border-dark-pine-muted bg-light-pine-overlay hover:bg-dark-pine-base/20 dark:bg-dark-pine-overlay hover:dark:bg-light-pine-base/20 duration-100 rounded-lg h-10 font-bold"
onclick={createRoom}
>
CREA
<Plus />
</button>
</div>
</form>

View file

@ -1,17 +0,0 @@
<script lang="ts">
import { onMount } from "svelte"
import { get_coords, type Coordinates } from "$lib/gps"
let lido_schenna_coords: Coordinates = { latitude: 46.6769043, longitude: 11.1851585 }
onMount(async () => {
let { coords, error } = await get_coords()
console.log(coords)
if (error != null) {
return console.log(error)
}
if (coords == null) return
// console.log(is_within_range(coords, lido_schenna_coords, 103))
})
</script>

View file

@ -1,7 +0,0 @@
<script lang="ts">
import { Skull, Accessibility, Anvil } from "@lucide/svelte"
</script>
<Skull />
<Accessibility />
<Anvil />

File diff suppressed because one or more lines are too long

Before

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 768 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 15 KiB

View file

@ -0,0 +1 @@
<svg xmlns="http://www.w3.org/2000/svg" width="107" height="128" viewBox="0 0 107 128"><title>svelte-logo</title><path d="M94.157 22.819c-10.4-14.885-30.94-19.297-45.792-9.835L22.282 29.608A29.92 29.92 0 0 0 8.764 49.65a31.5 31.5 0 0 0 3.108 20.231 30 30 0 0 0-4.477 11.183 31.9 31.9 0 0 0 5.448 24.116c10.402 14.887 30.942 19.297 45.791 9.835l26.083-16.624A29.92 29.92 0 0 0 98.235 78.35a31.53 31.53 0 0 0-3.105-20.232 30 30 0 0 0 4.474-11.182 31.88 31.88 0 0 0-5.447-24.116" style="fill:#ff3e00"/><path d="M45.817 106.582a20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.503 18 18 0 0 1 .624-2.435l.49-1.498 1.337.981a33.6 33.6 0 0 0 10.203 5.098l.97.294-.09.968a5.85 5.85 0 0 0 1.052 3.878 6.24 6.24 0 0 0 6.695 2.485 5.8 5.8 0 0 0 1.603-.704L69.27 76.28a5.43 5.43 0 0 0 2.45-3.631 5.8 5.8 0 0 0-.987-4.371 6.24 6.24 0 0 0-6.698-2.487 5.7 5.7 0 0 0-1.6.704l-9.953 6.345a19 19 0 0 1-5.296 2.326 20.72 20.72 0 0 1-22.237-8.243 19.17 19.17 0 0 1-3.277-14.502 17.99 17.99 0 0 1 8.13-12.052l26.081-16.623a19 19 0 0 1 5.3-2.329 20.72 20.72 0 0 1 22.237 8.243 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-.624 2.435l-.49 1.498-1.337-.98a33.6 33.6 0 0 0-10.203-5.1l-.97-.294.09-.968a5.86 5.86 0 0 0-1.052-3.878 6.24 6.24 0 0 0-6.696-2.485 5.8 5.8 0 0 0-1.602.704L37.73 51.72a5.42 5.42 0 0 0-2.449 3.63 5.79 5.79 0 0 0 .986 4.372 6.24 6.24 0 0 0 6.698 2.486 5.8 5.8 0 0 0 1.602-.704l9.952-6.342a19 19 0 0 1 5.295-2.328 20.72 20.72 0 0 1 22.237 8.242 19.17 19.17 0 0 1 3.277 14.503 18 18 0 0 1-8.13 12.053l-26.081 16.622a19 19 0 0 1-5.3 2.328" style="fill:#fff"/></svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 17 KiB

View file

@ -1,21 +0,0 @@
{
"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"
}
]
}

Binary file not shown.

Before

Width:  |  Height:  |  Size: 420 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 446 KiB

View file

@ -1,22 +1,21 @@
import tailwindcss from "@tailwindcss/vite" import tailwindcss from '@tailwindcss/vite';
import { sveltekit } from "@sveltejs/kit/vite" import { sveltekit } from '@sveltejs/kit/vite';
import { defineConfig } from "vite" import { defineConfig } from 'vite';
export default defineConfig({ export default defineConfig({
plugins: [tailwindcss(), sveltekit()], plugins: [tailwindcss(), sveltekit()],
server: { server: {
proxy: { proxy: {
"/api": { '/api': {
target: "http://backend:5000", target: "http://backend:5000",
changeOrigin: false, changeOrigin: false,
secure: false, secure: false
}, },
"/ws": { '/ws': {
target: "http://backend:5000", target: "http://backend:5000",
changeOrigin: false, changeOrigin: false,
secure: false, secure: false
ws: true, }
}, }
}, }
}, });
})