Compare commits

...
Sign in to create a new pull request.

167 commits

Author SHA1 Message Date
Simone Tesini
7443d600e7 Merge brt pushanch 'main' of https://repos.hackathon.bz.it/2025-summer/team-1 2025-08-02 13:45:32 +02:00
Simone Tesini
bf71a8103d rush 2025-08-02 13:45:27 +02:00
f7351aecf8 Add default room for testing && show error msg 2025-08-02 13:41:53 +02:00
436553809a Merge remote-tracking branch 'refs/remotes/origin/main' 2025-08-02 13:34:20 +02:00
fb49da2edd Ask for pin for private rooms 2025-08-02 13:34:10 +02:00
Simone Tesini
9a12f39f3e remove words 2025-08-02 13:22:52 +02:00
Simone Tesini
9ec601b263 Merge branch 'main' of https://repos.hackathon.bz.it/2025-summer/team-1 2025-08-02 13:20:00 +02:00
Simone Tesini
1763a6b96f add readme 2025-08-02 13:19:46 +02:00
360338ccb2 Add qrcode in admin page 2025-08-02 13:18:58 +02:00
278a2d94a8 Add custom code insertion during room creation 2025-08-02 13:10:51 +02:00
Mat12143
c3d30a1cc8 Merge branch 'main' of https://repos.hackathon.bz.it/2025-summer/team-1 2025-08-02 13:00:17 +02:00
Mat12143
1063c239b6 feat: block access for pin protected rooms 2025-08-02 13:00:11 +02:00
Simone Tesini
6a50dc4c86 Merge branch 'main' of https://repos.hackathon.bz.it/2025-summer/team-1 2025-08-02 12:52:02 +02:00
Simone Tesini
efcabd2ee4 add song cooldonw 2025-08-02 12:51:50 +02:00
746189862a Merge remote-tracking branch 'refs/remotes/origin/main' 2025-08-02 12:47:24 +02:00
01cf53c775 Try to fix thumb up / down 2025-08-02 12:47:16 +02:00
7587796934 Merge branch 'main' of https://repos.hackathon.bz.it/2025-summer/team-1 2025-08-02 12:38:29 +02:00
a244ca30d4 add pwa 2025-08-02 12:37:20 +02:00
f4a4c46fbc Fix room creation href 2025-08-02 12:34:27 +02:00
Simone Tesini
85bb34328d t pus:wqMerge branch 'main' of https://repos.hackathon.bz.it/2025-summer/team-1 2025-08-02 11:51:59 +02:00
Simone Tesini
ba2b5f04c6 anti vote spam system 2025-08-02 11:51:51 +02:00
Mat12143
45ccadb736 Merge branch 'main' of https://repos.hackathon.bz.it/2025-summer/team-1 2025-08-02 11:51:09 +02:00
Mat12143
350f44b194 feat: stop on next song + start on empty queue 2025-08-02 11:49:49 +02:00
a0360f821a Merge remote-tracking branch 'refs/remotes/origin/main' 2025-08-02 11:47:31 +02:00
2a63a527f0 Add room creation page 2025-08-02 11:47:26 +02:00
Simone Tesini
fb34d2e945 Merge branch 'main' of https://repos.hackathon.bz.it/2025-summer/team-1 2025-08-02 11:26:18 +02:00
Simone Tesini
cbf90d1d0a fix micro smarcio 2025-08-02 11:26:07 +02:00
Mat12143
7e52746b37 fix: media bar on mobile 2025-08-02 11:25:53 +02:00
Mat12143
ec36ea3feb fix: pause & unpause + timeline bar + stock image as not found 2025-08-02 11:19:09 +02:00
Simone Tesini
cd9d47ad8d fix baco 2025-08-02 11:00:02 +02:00
Mat12143
88f34c6257 Merge branch 'main' of https://repos.hackathon.bz.it/2025-summer/team-1 2025-08-02 10:50:41 +02:00
Mat12143
3ee764ad03 feat: recolor ui 2025-08-02 10:50:35 +02:00
Simone Tesini
a7374d6d94 add tag filtre 2025-08-02 10:47:49 +02:00
Mat12143
795a5a3a19 feat: fallback for gps error 2025-08-02 10:32:51 +02:00
Mat12143
5632209ace feat: fallback for gps error 2025-08-02 10:31:29 +02:00
Mat12143
e045e11023 merge 2025-08-02 10:28:38 +02:00
Mat12143
69cf462662 custom bar 2025-08-02 10:28:11 +02:00
Simone Tesini
543db8d25f Merge branch 'main' of https://repos.hackathon.bz.it/2025-summer/team-1 2025-08-02 10:07:48 +02:00
Simone Tesini
0c34586358 add room scanner logic 2025-08-02 10:06:31 +02:00
596061adfe Merge branch 'main' of https://repos.hackathon.bz.it/2025-summer/team-1 2025-08-02 09:54:06 +02:00
2a8802a0b8 Add spinner 2025-08-02 09:53:24 +02:00
396c7b0cea fix microsmarcio icon 2025-08-02 09:53:18 +02:00
656d1e40c7 Merge branch 'main' of https://repos.hackathon.bz.it/2025-summer/team-1 2025-08-02 09:50:17 +02:00
124e426b21 mod favicon 2025-08-02 09:49:52 +02:00
d50979077a Merge remote-tracking branch 'refs/remotes/origin/main' 2025-08-02 09:24:58 +02:00
fe7c740573 Fix lido scena coords 2025-08-02 09:24:38 +02:00
Mat12143
bb16817811 Merge branch 'main' of https://repos.hackathon.bz.it/2025-summer/team-1 2025-08-02 09:22:37 +02:00
244e49d311 Fix coord float 2025-08-02 09:22:30 +02:00
Mat12143
964c226bde Merge branch 'main' of https://repos.hackathon.bz.it/2025-summer/team-1 2025-08-02 09:19:52 +02:00
Mat12143
2c1928822b feat: implemented gps location check 2025-08-02 09:19:39 +02:00
cd5e52cb23 Remove dots from imports 2025-08-02 09:19:12 +02:00
6bdbae1881 Fix microsmmarcio 2025-08-02 09:11:39 +02:00
3a944767a8 Fix hard-coded coords 2025-08-02 09:07:52 +02:00
b7aebadffe Fix gps coords 2025-08-02 09:05:30 +02:00
Simone Tesini
f3101ad8ab Merge branch 'scanner_page' 2025-08-02 08:57:08 +02:00
Simone Tesini
9cb9c2cf72 proper belter of a fookin page lads 2025-08-02 08:56:00 +02:00
d337f747f3 Merge branch 'main' of https://repos.hackathon.bz.it/2025-summer/team-1 2025-08-02 08:44:50 +02:00
4bf93b565b add filter to room list 2025-08-02 08:43:41 +02:00
78f03dec5d Minimal aesthetic upgrades 2025-08-02 08:31:31 +02:00
adc362562e add deny people not in range 2025-08-02 08:10:22 +02:00
ca0b441229 Fix color 2025-08-02 08:00:06 +02:00
ebf3d29fa8 Fix nasty assert 2025-08-02 07:59:50 +02:00
Simone Tesini
a8d6279ef7 remove placeholder text 2025-08-02 06:44:59 +02:00
Simone Tesini
b8b4ae2480 add standard rosepines 2025-08-02 06:44:11 +02:00
Simone Tesini
7170c7b663 Merge branch 'main' of https://repos.hackathon.bz.it/2025-summer/team-1 into scanner_page 2025-08-02 06:24:13 +02:00
Simone Tesini
23c58b299c add icons support and remove old import 2025-08-02 06:23:32 +02:00
Simone Tesini
f36441565c refactor roomcompponent 2025-08-02 06:11:25 +02:00
db31dda495 Merge branch 'main' of https://repos.hackathon.bz.it/2025-summer/team-1 2025-08-02 06:11:10 +02:00
46b055c591 mod mooved gps functions from front end to back end 2025-08-02 06:10:41 +02:00
Mat12143
9402ff7059 Merge branch 'main' of https://repos.hackathon.bz.it/2025-summer/team-1 2025-08-02 06:07:19 +02:00
Mat12143
2b94bfddfb feat: starting autoplay 2025-08-02 06:07:14 +02:00
f70d2e707c Add song_fetch error checking 2025-08-02 06:00:11 +02:00
0066166765 add home structure 2025-08-02 05:31:53 +02:00
Mat12143
9faba7dbd3 fix: binding issue 2025-08-02 05:25:03 +02:00
Mat12143
419fde1a10 fix: websockets and vite 2025-08-02 05:16:37 +02:00
Simone Tesini
426a2706d8 force socketsio to use websockets 2025-08-02 05:08:08 +02:00
Mat12143
f05b5666db Merge branch 'main' of https://repos.hackathon.bz.it/2025-summer/team-1 2025-08-02 05:04:35 +02:00
Mat12143
0bf67061f8 feat: websockets & realtime updates 2025-08-02 05:04:30 +02:00
6c5fe4930b Merge remote-tracking branch 'refs/remotes/origin/main' 2025-08-02 04:58:50 +02:00
97b8bac747 Add manifest.json 2025-08-02 04:58:46 +02:00
Simone Tesini
e4be88db2d Merge branch 'main' of https://repos.hackathon.bz.it/2025-summer/team-1 2025-08-02 04:53:27 +02:00
Simone Tesini
bfcb29fc9f add gps functions 2025-08-02 04:53:20 +02:00
2a167ba8ad Add upvote field 2025-08-02 04:47:04 +02:00
2a4a4c3caa Add minimal styling 2025-08-02 04:34:45 +02:00
a6a7eeb690 Always send queue & event 2025-08-02 04:34:07 +02:00
72ceb0f8dc Fix testini's smarcio 2025-08-02 04:17:20 +02:00
e01d52f7f4 Remove dummy song 2025-08-02 04:11:54 +02:00
6f1e590e50 Remove dummy song 2025-08-02 04:11:11 +02:00
5caa19fd9f Merge remote-tracking branch 'refs/remotes/origin/main' 2025-08-02 04:10:25 +02:00
64941061ea Add endpoit to get audio url 2025-08-02 04:09:01 +02:00
Mat12143
7020a334a2 merge: websockets & refactoring 2025-08-02 03:56:01 +02:00
Mat12143
44e0d9f44c feat: refactoring api logic 2025-08-02 03:54:26 +02:00
Simone Tesini
a737a651ec add socket events for various backend pioints 2025-08-02 03:18:30 +02:00
Simone Tesini
a5b1943030 add socketio in forntendt 2025-08-02 03:00:28 +02:00
Simone Tesini
57e6acb1ee Merge branch 'websockets' 2025-08-02 02:16:25 +02:00
Simone Tesini
9c85ecbfaf cleanup 2025-08-02 02:15:01 +02:00
Mat12143
1383c0fbed feat: implemented api 2025-08-02 02:12:41 +02:00
Simone Tesini
59dfbbeca9 figure out how socketio works 2025-08-02 01:58:29 +02:00
aa25c6075b Call renew if queue ends 2025-08-02 01:49:35 +02:00
6a460ea38d Add room range info in api 2025-08-02 01:36:14 +02:00
6194dfbf34 Improve suggestions json 2025-08-02 01:31:02 +02:00
7a9d29a2da Filtering out empty tags 2025-08-02 01:30:36 +02:00
0d659beffb Add room range 2025-08-02 01:14:58 +02:00
4a3c75cd51 Finish add song endpoint 2025-08-02 01:02:43 +02:00
ba77a1857c Readd invisible state import 2025-08-02 01:00:32 +02:00
41b2f61f30 Merge remote-tracking branch 'refs/remotes/origin/main' 2025-08-02 00:59:44 +02:00
5a7d661629 Add some apis 2025-08-02 00:59:38 +02:00
Mat12143
ab9bfa41c9 feat: added error page + join endpoint 2025-08-02 00:52:13 +02:00
Simone Tesini
c2cf582ca4 fix state import 2025-08-02 00:51:02 +02:00
Simone Tesini
d9e7b8f0ff Merge branch 'main' of https://repos.hackathon.bz.it/2025-summer/team-1 2025-08-02 00:49:07 +02:00
Simone Tesini
85854e5673 add socketio to global state 2025-08-02 00:48:21 +02:00
3b927522e3 Add qrcode generator endpoint 2025-08-02 00:44:35 +02:00
bc9bf71824 Add dummy room 2025-08-02 00:19:07 +02:00
96fbe8058b Add index info to queue api 2025-08-01 23:32:48 +02:00
66188ba181 Merge remote-tracking branch 'refs/remotes/origin/main' 2025-08-01 23:29:44 +02:00
96e71d891c Fix after merge 2025-08-01 23:29:40 +02:00
0d1a3b8c09 Merge branch 'main' of https://repos.hackathon.bz.it/2025-summer/team-1 2025-08-01 23:28:01 +02:00
d56dbb3d9f Merge remote-tracking branch 'refs/remotes/origin/main' 2025-08-01 23:25:28 +02:00
9ce161d515 Merge branch 'main' of https://repos.hackathon.bz.it/2025-summer/team-1 2025-08-01 23:25:11 +02:00
b7fed3c719 mod mbid with uuid 2025-08-01 23:24:54 +02:00
0ebbc205a1 Add add song api endpoint 2025-08-01 23:23:00 +02:00
b86461cf2d Add song fetch final function 2025-08-01 23:21:21 +02:00
f26275766a Add env file to docker compose 2025-08-01 23:20:53 +02:00
Simone Tesini
7bd55a4932 Merge branch 'main' of https://repos.hackathon.bz.it/2025-summer/team-1 2025-08-01 23:08:17 +02:00
9ba0e04501 Merge branch 'main' of https://repos.hackathon.bz.it/2025-summer/team-1 2025-08-01 23:07:45 +02:00
e94af7a6b0 add get_song_by_title_artist 2025-08-01 23:07:04 +02:00
Simone Tesini
e37aa36a3e add global state 2025-08-01 23:07:00 +02:00
Simone Tesini
07964c469e Merge branch 'main' of https://repos.hackathon.bz.it/2025-summer/team-1 2025-08-01 22:50:12 +02:00
Simone Tesini
2ab871f7a5 add renewwww queueueue 2025-08-01 22:48:38 +02:00
0f06261c96 mod get_song_by_mbid returns None if it finds nothing 2025-08-01 22:43:15 +02:00
fbd9803965 Fix 2025-08-01 22:34:06 +02:00
6e3eae9cb3 Merge remote-tracking branch 'refs/remotes/origin/main' 2025-08-01 22:32:48 +02:00
48e3241e8e Add room list & room creation endpoints 2025-08-01 22:32:44 +02:00
Simone Tesini
c89d3af268 delete package.jess'on 2025-08-01 22:22:38 +02:00
Mat12143
ebabe54b09 Merge branch 'main' of https://repos.hackathon.bz.it/2025-summer/team-1 2025-08-01 22:17:32 +02:00
Mat12143
b265529a8d removed node_modules 2025-08-01 22:17:26 +02:00
Simone Tesini
5160846a62 Merge branch '#15-algo_test' 2025-08-01 22:16:11 +02:00
Simone Tesini
b06d02ca62 add tests 2025-08-01 22:10:59 +02:00
2df62ff714 Add queue next endpoint 2025-08-01 22:07:39 +02:00
Mat12143
51aafb9771 Merge branch 'main' of https://repos.hackathon.bz.it/2025-summer/team-1 2025-08-01 22:05:01 +02:00
Mat12143
41efae6aeb feat: added suggestions & upvoting system 2025-08-01 22:04:55 +02:00
63bacf9aae Add queue endpoint 2025-08-01 21:39:46 +02:00
e22ad91601 Merge branch 'main' of https://repos.hackathon.bz.it/2025-summer/team-1 2025-08-01 21:32:19 +02:00
386dd64a7a add add_song 2025-08-01 21:31:53 +02:00
2a10a91776 add get_song_by_mbid 2025-08-01 21:21:28 +02:00
a2ae91be13 Add youtube info download 2025-08-01 21:21:24 +02:00
3a81b332ba Installing ffmpeg in dockerfile 2025-08-01 21:20:57 +02:00
e78ecede2d mod db row_factory 2025-08-01 21:13:31 +02:00
Mat12143
25a6b4e82c Merge branch 'main' of https://repos.hackathon.bz.it/2025-summer/team-1 2025-08-01 21:10:14 +02:00
Mat12143
ac7ef89b01 feat: added queue slider & input field 2025-08-01 21:10:03 +02:00
Simone Tesini
dab8270bce Merge branch 'main' of https://repos.hackathon.bz.it/2025-summer/team-1 2025-08-01 20:59:41 +02:00
Simone Tesini
70f4b89d9e pull 2025-08-01 20:59:09 +02:00
e9b9193acf Merge branch 'main' of https://repos.hackathon.bz.it/2025-summer/team-1 2025-08-01 20:57:43 +02:00
248c9bc278 fix db creation 2025-08-01 20:56:48 +02:00
Simone Tesini
41f6f5ba1a pull 2025-08-01 20:46:42 +02:00
Simone Tesini
43b6642fab Merge branch 'main' of https://repos.hackathon.bz.it/2025-summer/team-1 2025-08-01 19:31:59 +02:00
Simone Tesini
036b64b51e theoretically finish the algorithm 2025-08-01 19:31:47 +02:00
58f585b065 Merge branch 'main' of https://repos.hackathon.bz.it/2025-summer/team-1 2025-08-01 19:18:53 +02:00
d992c037c4 Merge remote-tracking branch 'refs/remotes/origin/main' 2025-08-01 19:17:06 +02:00
8fc79437e6 Merge branch 'main' of https://repos.hackathon.bz.it/2025-summer/team-1 2025-08-01 19:16:25 +02:00
8bb582148d Add song fetch test 2025-08-01 19:16:20 +02:00
7f6a36439f add db creation 2025-08-01 19:14:10 +02:00
Simone Tesini
4bb6254396 add basic ranking algo 2025-08-01 19:14:07 +02:00
fa73c6de66 Merge remote-tracking branch 'refs/remotes/origin/main' 2025-08-01 19:13:53 +02:00
Simone Tesini
b1a59b5b62 Merge branch 'main' of https://repos.hackathon.bz.it/2025-summer/team-1 2025-08-01 19:13:28 +02:00
adce5d7c65 ops 2025-08-01 19:13:12 +02:00
Simone Tesini
a0e1662e1d pull 2025-08-01 19:13:09 +02:00
50 changed files with 1813 additions and 91 deletions

View file

@ -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 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.

15
SPEECH.md Normal file
View file

@ -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.

View file

@ -2,11 +2,16 @@ FROM python:3.13.5-alpine
WORKDIR /app
RUN apk update && apk add git ffmpeg
RUN git clone --depth 1 'https://github.com/yt-dlp/yt-dlp.git' /yt-dlp
COPY requirements.txt ./
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

View file

@ -1,2 +1,8 @@
Flask==3.1.0
flask-cors
flask-socketio
dotenv
requests
qrcode
Pillow
eventlet>=0.33

View file

@ -1,12 +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 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:
@ -15,27 +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}"}
@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)

View file

@ -2,5 +2,7 @@ import sqlite3
def get_connection():
conn = sqlite3.connect("jukebox.db")
conn = sqlite3.connect(".data/jukebox.db")
conn.row_factory = sqlite3.Row
conn.autocommit = True
return conn

29
backend/src/gps.py Normal file
View file

@ -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

32
backend/src/qrcode_gen.py Normal file
View file

@ -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

View file

@ -1,18 +1,146 @@
import random
from dataclasses import dataclass
from gps import Coordinates
from song import Song
USER_SCORE_WEIGHT = 0.7
ARTIST_WEIGHT = 0.1
TAG_WEIGHT = 0.1
RANDOM_WEIGHT = 0.1
RECENT_PENALTY = 0.5
RECENT_COUNT = 10
QUEUE_SIZE = 3
type UserScoredSong = tuple[Song, int]
@dataclass
class Rank:
user_score: float
tag: float
artist: float
random: float
recent_penalty: float
def total(self) -> float:
return self.user_score + self.tag + self.artist + self.random - self.recent_penalty
@dataclass
class Room:
id: int
coord: tuple[float, float]
coord: Coordinates
name: str
pin: int | None
tags: set[str]
creative: bool
range_size: int # in meters ??
playlist: set[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]
return self.rank_song(scored[0], scored[1])
def rank_song(self, song: Song, user_score: int) -> Rank:
song_items = self.songs.items()
lowest_score = min(song_items, key=lambda item: item[1][1])[1][1]
highest_score = max(song_items, key=lambda item: item[1][1])[1][1]
score_rank = translate(user_score, lowest_score, highest_score, 0.0, USER_SCORE_WEIGHT)
recent_songs = self.history[-RECENT_COUNT:]
tag_counts = {}
artist_counts = {}
for recent_song in recent_songs:
for tag in recent_song.tags:
if tag not in tag_counts:
tag_counts[tag] = 0
tag_counts[tag] += 1
if recent_song.artist not in artist_counts:
artist_counts[recent_song.artist] = 0
artist_counts[recent_song.artist] += 1
tag_total = 0
for tag in song.tags:
if tag in tag_counts:
tag_total += tag_counts[tag]
artist_total = artist_counts[song.artist] if song.artist in artist_counts else 0
tag_value = min(RECENT_COUNT, len(self.history)) - tag_total
artist_value = min(RECENT_COUNT, len(self.history)) - artist_total
tag_rank = translate(tag_value, 0, min(RECENT_COUNT, len(self.history)), 0, TAG_WEIGHT)
artist_rank = translate(artist_value, 0, min(RECENT_COUNT, len(self.history)), 0, ARTIST_WEIGHT)
random_rank = translate(random.random(), 0, 1, 0.0, RANDOM_WEIGHT)
recent_penalty = RECENT_PENALTY if song in recent_songs else 0
return Rank(score_rank, tag_rank, artist_rank, random_rank, recent_penalty)
def translate(value: float, in_min: float, in_max: float, out_min: float, out_max: float):
if in_max == in_min:
return out_min
return (value - in_min) / (in_max - in_min) * (out_max - out_min) + out_min
def test_algo():
songs = [
Song("paulham", "Io e i miei banchi", "Paul Ham", ["pop"], "", ""),
Song("cisco", "CiscoPT", "Cantarex", ["rap"], "", ""),
Song("vpn", "VPN", "Cantarex", ["rap"], "", ""),
Song("gang", "Gang Gang Gang", "Cantarex", ["rap"], "", ""),
Song("bertha1", "Rindondantha", "Berthanetti", ["rock"], "", ""),
Song("bertha2", "Ragatthi", "Berthanetti", ["rock"], "", ""),
Song("bertha3", "Tranthathione", "Berthanetti", ["rock"], "", ""),
Song("cexx", "Spritz", "Cex", ["kpop"], "", ""),
]
room = Room(
123,
(0.0, 0.0),
"test",
None,
set(["rock", "rap"]),
100,
{
"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[2], songs[0], songs[1]],
1,
)
room.renew_queue()
print(room.playing)

View file

@ -1,13 +1,13 @@
from dataclasses import dataclass
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()

127
backend/src/song_fetch.py Normal file
View file

@ -0,0 +1,127 @@
import os
import os.path
import sys
import urllib.parse
from dataclasses import dataclass
import requests
sys.path.append("/yt-dlp")
import yt_dlp
@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"]},
)
assert response.status_code == 200
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, list[str]]: # ( image_id, tags )
response = requests.get(
url="https://ws.audioscrobbler.com/2.0/?method=track.getInfo&format=json",
params={
"track": name,
"artist": artist,
"api_key": os.environ["LASTFM_API_KEY"],
},
)
track_info = response.json()["track"]
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"],
image_id,
[t["name"] for t in track_info["toptags"]["tag"]],
)
def _yt_search(query: str) -> tuple[str, str]:
ydl_opts = {
"format": "bestaudio",
"default_search": "ytsearch1",
"outtmpl": "%(title)s.%(ext)s",
"skip_download": True,
}
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
info = ydl.extract_info(query, download=False)
first = info["entries"][0]
return first["track"], first["artists"][0]
def query_search(query: str) -> SongInfo | None:
res = _lastfm_search(query)
if res is None:
return None
name, artist = res
img_id, tags = _lastfm_getinfo(name, artist)
return SongInfo(artist=artist, title=name, img_id=img_id, tags=tags)
def yt_search_song(name: str, artist: str) -> str | None: # video id
ydl_opts = {
"format": "bestaudio",
"default_search": "ytsearch1",
"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

15
backend/src/state.py Normal file
View file

@ -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, ... }

View file

@ -1,18 +1,20 @@
services:
backend:
build: ./backend
ports:
- 5001:5000
environment:
NODE_ENV: development
volumes:
- ./backend:/app
- ./.data/jukebox.sqlite3:/app/.data/jukebox.sqlite3
backend:
build: ./backend
ports:
- 5001:5000
environment:
NODE_ENV: development
volumes:
- ./backend:/app
- ./.data:/app/.data
env_file:
- .env
frontend:
build: ./frontend
ports:
- 5173:5173
volumes:
- ./frontend:/app
- /app/node_modules
frontend:
build: ./frontend
ports:
- 5173:5173
volumes:
- ./frontend:/app
- /app/node_modules

View file

@ -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"
}
}
}
}

View file

@ -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"
}
}

View file

@ -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%);
}

View file

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

View file

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

View file

@ -0,0 +1,45 @@
<script lang="ts">
import { type Song, createEmptySong } from "$lib/types"
let { queueSongs, playingIndex } = $props()
let displaySongs = $derived<Song[]>([
playingIndex > 0 ? queueSongs[playingIndex - 1] : createEmptySong(),
queueSongs[playingIndex],
playingIndex == queueSongs.length - 1 ? createEmptySong() : queueSongs[playingIndex + 1],
])
$effect(() => {
console.log(displaySongs)
})
</script>
<div class="relative flex w-full justify-center overflow-hidden">
<div class="flex w-fit flex-row gap-4">
{#each displaySongs as song, i}
{#if song?.title && song.title != ""}
<div class={`relative flex flex-col items-center transition-all duration-300 ${i === 1 ? "z-10" : "z-0 scale-85 opacity-50"}`}>
<div
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}
<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>
{/if}
<img
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>
{#if i === 1}
<h1 class="mt-5">{song.title} - {song.artist}</h1>
{/if}
</div>
{:else}
<div class="flex h-[60vw] max-h-[250px] w-[60vw] max-w-[250px] items-center justify-center"></div>
{/if}
{/each}
</div>
</div>

View file

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

@ -0,0 +1,69 @@
<script lang="ts">
import { LoaderCircle } from "@lucide/svelte"
const COOLDOWN_SECS = 10
let { roomId } = $props()
let input = $state("")
let loading: boolean = $state(false)
let cooldowned: boolean = $state(false)
let errorMsg: string = $state()
$effect(() => {
console.log("cooldowned is now", cooldowned)
})
async function sendSong() {
loading = true
const res = await fetch(`/api/addsong?room=${roomId}&query=${input}`, { method: "POST" })
const json = await res.json()
input = ""
loading = false
if (!json.success) {
errorMsg = json.error
}
cooldowned = true
setTimeout(() => {
cooldowned = false
console.log("unset cooldown")
}, COOLDOWN_SECS * 1000)
}
</script>
<div
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-[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>
<p class="text-red-500 font-semibold">
{errorMsg}
</p>

View file

@ -0,0 +1,64 @@
<script lang="ts">
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 picked_suggestions: string[] = $state([])
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" })
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>
<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}
<div
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 items-center gap-2">
<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 class="flex w-1/4 flex-row items-center justify-center gap-2">
<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"}
disabled={!!picked_suggestions.includes(sug.uuid)}
onclick={async () => {
await vote(1, sug.uuid)
}}><ThumbsUp /></button
>
<p class="font-semibold text-light-pine-text dark:text-dark-pine-text">{sug.upvote}</p>
<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"}
disabled={!!picked_suggestions.includes(sug.uuid)}
onclick={async () => {
await vote(-1, sug.uuid)
}}><ThumbsDown /></button
>
</div>
</div>
{/each}
</div>

36
frontend/src/lib/gps.ts Normal file
View file

@ -0,0 +1,36 @@
export type Coordinates = {
latitude: number
longitude: number
}
function geolocation_to_simple_coords(coordinates: GeolocationCoordinates): Coordinates {
return { latitude: coordinates.latitude, longitude: coordinates.longitude }
}
export function get_coords(): Promise<{ coords: Coordinates | null; error: string | null }> {
return new Promise((resolve) => {
if (!navigator.geolocation) {
resolve({ coords: null, error: "Geolocation is not supported by your browser" })
return
}
const error_callback = (gps_error: GeolocationPositionError) => {
console.log(gps_error)
resolve({
coords: null,
error: `Unable to retrieve your location: (${gps_error.message})`,
})
return
}
const success_callback = (gps_position: GeolocationPosition) => {
resolve({
coords: geolocation_to_simple_coords(gps_position.coords),
error: null,
})
return
}
navigator.geolocation.getCurrentPosition(success_callback, error_callback)
})
}

View file

@ -1 +0,0 @@
// place files you want to import through the `$lib` alias in this folder.

57
frontend/src/lib/types.ts Normal file
View file

@ -0,0 +1,57 @@
import { z } from "zod"
const SongSchema = z.object({
uuid: z.string(),
title: z.string(),
artist: z.string(),
tags: z.array(z.string()),
image_id: z.string(),
youtube_id: z.string(),
})
export type Song = z.infer<typeof SongSchema>
export const parseSong = async function (song: any): Promise<Song> {
let resp = await SongSchema.parseAsync(song)
return resp
}
export const createEmptySong = function (): Song {
return {
uuid: "-1",
title: "",
artist: "",
tags: [""],
image_id: "",
youtube_id: "",
}
}
const SuggestionSchema = SongSchema.extend({
upvote: z.number(),
})
export type Suggestion = z.infer<typeof SuggestionSchema>
export const parseSuggestion = async function (sugg: any): Promise<Suggestion> {
let resp = await SuggestionSchema.parseAsync(sugg)
return resp
}
const RoomSchema = z.object({
id: z.number(),
name: z.string(),
private: z.boolean(),
coords: z.object({ latitude: z.number(), longitude: z.number() }),
range: z.number().int(),
distance: z.number()
})
export type Room = z.infer<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
}

76
frontend/src/lib/utils.ts Normal file
View file

@ -0,0 +1,76 @@
import { type Coordinates } from "./gps"
import { parseSong, parseSuggestion, type FetchError, type Song, type Suggestion } from "./types"
export const joinRoom = async function (roomId: string, coords: Coordinates, pin: string): Promise<[FetchError | null, string]> {
let res = await fetch(`/api/join?room=${roomId}&lat=${coords.latitude}&lon=${coords.longitude}&pin=${pin}`)
if (res.status != 200) {
return [{ code: 400, message: "Cannot join the room" }, ""]
}
return [null, "test"]
}
export const getSuggestions = async function (roomId: string): Promise<[FetchError | null, Suggestion[]]> {
let resp = await fetch("/api/room/suggestions?room=" + roomId)
if (resp.status != 200) {
return [{ code: 400, message: "Failed to retrieve suggestions" }, []]
}
let json = await resp.json()
let suggestions: Suggestion[] = []
json["songs"].forEach(async (i: any) => {
suggestions.push(await parseSuggestion(i))
})
suggestions = suggestions.sort((a, b) => {
return a.upvote - b.upvote
})
return [null, suggestions]
}
export const getQueueSongs = async function (roomId: string): Promise<[FetchError | null, Song[], number]> {
let resp = await fetch("/api/queue?room=" + roomId)
if (resp.status != 200) {
return [{ code: 400, message: "Failed to load queue songs" }, [], 0]
}
let json = await resp.json()
let songs: Song[] = []
json["queue"].forEach(async (i: any) => {
songs.push(await parseSong(i))
})
let playingId = json["index"]
return [null, songs, playingId]
}
export const triggerPlayNext = async function (roomId: string): Promise<[FetchError | null, Song[], number]> {
let resp = await fetch("/api/queue/next?room=" + roomId, { method: "POST" })
if (resp.status != 200) {
return [{ code: 400, message: "Failed to trigger next song playback" }, [], 0]
}
let json = await resp.json()
let songs: Song[] = []
json["queue"].forEach(async (i: any) => {
songs.push(await parseSong(i))
})
return [null, songs, json["index"]]
}
export const getStreamingUrl = async function (uuid: string) {
let resp = await fetch("/api/song/audio?song=" + uuid)
let json = await resp.json()
return json["url"]
}

View file

@ -4,6 +4,6 @@
let { children } = $props()
</script>
<div class="bg-red-500 w-full h-max">
<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()}
</div>

View file

@ -1,12 +1,44 @@
<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 text = $state("...")
let rooms: Room[] = $state([])
onMount(async () => {
let res = await fetch("/api")
text = await res.text()
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>
<h1>Welcome to SvelteKit</h1>
<p class="text-2xl text-red-500">{text}</p>
<div class="flex flex-col items-center gap-2">
<div class="flex flex-row items-center gap-4 py-4">
<img src="icon_small.png" class="h-12 w-12" alt="chillbox icon" />
<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>

View file

@ -0,0 +1,118 @@
<script lang="ts">
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 queueSongs = $state<Song[]>([])
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>
{#if returnError}
<Error {returnError} />
{:else}
<div class="flex w-full flex-col items-center justify-center p-4 lg:p-10">
<QueueSlider {queueSongs} {playingIndex} />
<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>
<img class="absolute right-1 bottom-1" src="/api/room/qrcode?room=1000&pin=1234" />
<!-- @PERETTO fix here pls -->
</div>
{/if}

View file

@ -0,0 +1,11 @@
import { error } from "@sveltejs/kit"
import type { PageLoad } from "../../room/[id]/$types"
export const load: PageLoad = function ({ params }) {
if (params.id) {
return {
roomId: params.id,
}
}
error(400, "Please provide a room id")
}

View file

@ -0,0 +1,88 @@
<script lang="ts">
import QueueSlider from "$lib/components/QueueSlider.svelte"
import SuggestionInput from "$lib/components/SuggestionInput.svelte"
import Error from "$lib/components/Error.svelte"
import { type Suggestion, type Song, parseSong, parseSuggestion } from "$lib/types.js"
import { onDestroy, onMount } from "svelte"
import SuggestionList from "$lib/components/SuggestionList.svelte"
import { getQueueSongs, getSuggestions, joinRoom } from "$lib/utils.js"
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 queueSongs = $state<Song[]>([])
let playingIndex = $state(0)
let suggestions = $state<Suggestion[]>([])
let returnError = $state<FetchError | null>()
let socket: Socket
onMount(async () => {
// Join the room
let { coords, error } = await get_coords()
if (error || coords == null) {
// 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
}
;[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
socket = io("/", { path: "/ws", transports: ["websocket"] })
await socket.emitWithAck("join_room", { id: data.roomId })
socket.on("queue_update", async (d) => {
const songs = await Promise.all(d.queue.map(parseSong))
queueSongs = songs
playingIndex = d.index
})
socket.on("new_vote", async (d) => {
const updated = await parseSuggestion(d.song)
suggestions = suggestions.map((s) => (s.uuid === updated.uuid ? updated : s))
})
socket.on("new_song", async (d) => {
const song = await parseSuggestion(d.song)
suggestions = [...suggestions, song]
})
})
onDestroy(() => {
if (socket) socket.disconnect()
})
</script>
<!-- Check if the room exists -->
{#if returnError}
<Error {returnError} />
{:else}
<div class="flex w-full flex-col items-center justify-center px-2 py-4 lg:p-10">
<QueueSlider {queueSongs} {playingIndex} />
<div class="w-full py-6 lg:w-[30vw]">
<SuggestionInput roomId={data.roomId} />
</div>
<div class="w-full py-6 lg:w-[30vw]">
<SuggestionList bind:suggestions roomId={data.roomId} />
</div>
</div>
{/if}

View file

@ -0,0 +1,8 @@
import type { PageLoad } from "./$types"
export const load: PageLoad = ({ params, url }) => {
return {
roomId: params.id || "",
pin: url.searchParams.get("pin") || "",
}
}

View file

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

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

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

View file

@ -0,0 +1,8 @@
<script lang="ts">
import { io } from "socket.io-client"
const socket = io("/", { path: "/ws" })
socket.on("connect", () => {
console.log("successfully connected to socket.io server")
socket.emit("join_room", { id: "gang" })
})
</script>

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 142 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 768 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

BIN
frontend/static/favicon.ico Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View file

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

Before

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 17 KiB

View file

@ -0,0 +1,21 @@
{
"name": "Chillbox Music Player",
"short_name": "Chillbox",
"start_url": "/",
"display": "standalone",
"background_color": "#334155",
"theme_color": "#334155",
"orientation": "portrait-primary",
"icons": [
{
"src": "/android-chrome-512x512.png",
"sizes": "512x512",
"type": "image/png"
},
{
"src": "/android-chrome-192x192.png",
"sizes": "192x192",
"type": "image/png"
}
]
}

BIN
frontend/static/radar.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 420 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 446 KiB

View file

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

1
ruff.toml Normal file
View file

@ -0,0 +1 @@
line-length = 200