Compare commits

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

18 commits
pwa ... main

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
f4a4c46fbc Fix room creation href 2025-08-02 12:34:27 +02:00
14 changed files with 169 additions and 67 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

@ -29,9 +29,9 @@ init_db(state.db)
state.rooms[1000] = Room( state.rooms[1000] = Room(
id=1000, id=1000,
coord=Coordinates(46.6769043, 11.1851585), coord=Coordinates(46.6769043, 11.1851585),
name="Test Room", name="Lido Scena",
pin=None, pin=1234,
tags=set(), tags=set(["chill", "raggaetton", "spanish", "latino", "mexican", "rock"]),
range_size=150, range_size=150,
songs={}, songs={},
history=[], history=[],
@ -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("code") code = request.args.get("pin")
if room_id is None: if room_id is None:
return error("Missing room id") return error("Missing room id")
@ -81,8 +81,11 @@ 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 and room.pin != code: if room.pin is not None:
return error("Invalid code") if code is None:
return error("Missing code")
if int(room.pin) != int(code):
return error("Invalid code")
distance = distance_between_coords( distance = distance_between_coords(
lhs=room.coord, lhs=room.coord,

View file

@ -1,19 +1,45 @@
<script lang="ts"> <script lang="ts">
import { type Room } from "$lib/types" import { type Room } from "$lib/types"
let { room }: { room: Room } = $props() let { room }: { room: Room } = $props()
let showPinModal: boolean = $state(false)
let pin: number = $state()
</script> </script>
<a <div
class="flex w-82 cursor-pointer flex-row items-center 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" 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"
href="/room/{room.id}"
> >
<div class="flex flex-row"> <button
{room.name} class="flex flex-row items-center"
{room.private ? "🔒" : ""} onclick={() => {
</div> if (!room.private) {
<div class="grow"></div> window.location.href = "/room/" + room.id
<div class="flex flex-row items-center gap-2"> return
<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> showPinModal = !showPinModal
</div> }}
</a> >
<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,20 +1,39 @@
<script lang="ts"> <script lang="ts">
import { LoaderCircle } from "@lucide/svelte" import { LoaderCircle } from "@lucide/svelte"
const COOLDOWN_SECS = 10
let { roomId } = $props() let { roomId } = $props()
let input = $state("") let input = $state("")
let disabled: boolean = $state(false) let loading: boolean = $state(false)
let cooldowned: boolean = $state(false)
let errorMsg: string = $state()
$effect(() => {
console.log("cooldowned is now", cooldowned)
})
async function sendSong() { async function sendSong() {
disabled = true loading = true
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 = ""
disabled = false 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-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 ${disabled ? "disabled" : ""}`} 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 <input
type="text" type="text"
@ -22,20 +41,29 @@
class="h-[50px] w-3/4 rounded px-4 font-bold text-white outline-none" class="h-[50px] w-3/4 rounded px-4 font-bold text-white outline-none"
bind:value={input} bind:value={input}
onkeydown={(e) => { onkeydown={(e) => {
errorMsg = null
if (e.key == "Enter") { if (e.key == "Enter") {
sendSong() sendSong()
} }
}} }}
{disabled} disabled={loading}
/> />
{#if disabled} {#if loading}
<span class="animate-spin"> <span class="animate-spin">
<LoaderCircle /> <LoaderCircle />
</span> </span>
{/if} {/if}
<button class="i-lucide-check h-[40px] w-1/4 cursor-pointer rounded border border-0 font-semibold shadow-xl duration-100 hover:scale-105 active:scale-90 dark:bg-dark-pine-blue" onclick={sendSong} <button
>Add</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> <span class="i-lucide-chevrons-left"></span>
</div> </div>
<p class="text-red-500 font-semibold">
{errorMsg}
</p>

View file

@ -7,9 +7,8 @@
let picked_suggestions: string[] = $state([]) let picked_suggestions: string[] = $state([])
async function vote(idx: number, amount: number, songId: string) { async function vote(amount: number, songId: string) {
if (picked_suggestions.includes(songId)) return console.log("rejecting vote") if (picked_suggestions.includes(songId)) return console.log("rejecting vote")
suggestions[idx].upvote += amount
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) picked_suggestions.push(songId)
console.log("accepted vote") console.log("accepted vote")
@ -26,7 +25,7 @@
<p>No suggestions yet! Try to add a new one using the Add button</p> <p>No suggestions yet! Try to add a new one using the Add button</p>
{/if} {/if}
{#each suggestions as sug, idx} {#each suggestions as sug}
<div <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" 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"
> >
@ -48,7 +47,7 @@
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"} 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)} disabled={!!picked_suggestions.includes(sug.uuid)}
onclick={async () => { onclick={async () => {
await vote(idx, 1, sug.uuid) await vote(1, sug.uuid)
}}><ThumbsUp /></button }}><ThumbsUp /></button
> >
<p class="font-semibold text-light-pine-text dark:text-dark-pine-text">{sug.upvote}</p> <p class="font-semibold text-light-pine-text dark:text-dark-pine-text">{sug.upvote}</p>
@ -56,7 +55,7 @@
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"} 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)} disabled={!!picked_suggestions.includes(sug.uuid)}
onclick={async () => { onclick={async () => {
await vote(idx, -1, sug.uuid) await vote(-1, sug.uuid)
}}><ThumbsDown /></button }}><ThumbsDown /></button
> >
</div> </div>

View file

@ -1,8 +1,8 @@
import { get_coords, type Coordinates } from "./gps" import { type Coordinates } from "./gps"
import { parseSong, parseSuggestion, type FetchError, type Song, type Suggestion } from "./types" import { parseSong, parseSuggestion, type FetchError, type Song, type Suggestion } from "./types"
export const joinRoom = async function (roomId: string, coords: Coordinates): Promise<[FetchError | null, string]> { 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}`) let res = await fetch(`/api/join?room=${roomId}&lat=${coords.latitude}&lon=${coords.longitude}&pin=${pin}`)
if (res.status != 200) { if (res.status != 200) {
return [{ code: 400, message: "Cannot join the room" }, ""] return [{ code: 400, message: "Cannot join the room" }, ""]

View file

@ -23,12 +23,12 @@
<span class="lilita-one-regular text-6xl font-bold">ChillBox</span> <span class="lilita-one-regular text-6xl font-bold">ChillBox</span>
</div> </div>
<img src="/smerdo_radar_bonus.gif" alt="radar" class="h-64 w-64" /> <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> <span class="animate-pulse text-sm italic">Scanning for rooms near you...</span>
<button <button
onclick={() => { onclick={() => {
window.location.href = "/room/new" 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" 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"
> >

View file

@ -112,5 +112,7 @@
<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> <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> </div>
<img class="absolute right-1 bottom-1" src="/api/room/qrcode?room=1000&pin=1234" />
<!-- @PERETTO fix here pls -->
</div> </div>
{/if} {/if}

View file

@ -31,7 +31,7 @@
} }
let sugg, queue, index let sugg, queue, index
;[returnError] = await joinRoom(data.roomId, coords) ;[returnError] = await joinRoom(data.roomId, coords, data.pin)
if (returnError) { if (returnError) {
return return
} }

View file

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

View file

@ -9,9 +9,7 @@
let name: string = $state() let name: string = $state()
let range: number = $state() let range: number = $state()
let pin: number = $state()
const privateStyle = "bg-red-500"
const publicStyle = "bg-green-500"
async function createRoom() { async function createRoom() {
if (creating) { if (creating) {
@ -22,16 +20,12 @@
return return
} }
let pin
if (privateRoom) {
pin = Math.floor(Math.random() * 10000)
} else {
pin = ""
}
creating = true creating = true
const res = await fetch(`/api/room/new?name=${encodeURIComponent(name)}&coords=${coord.latitude},${coord.longitude}&range=${encodeURIComponent(range ?? "100")}&pin=${pin}`, { method: "POST" }) 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() const json = await res.json()
@ -50,8 +44,10 @@
<div class="flex flex-col gap-3 w-1/2"> <div class="flex flex-col gap-3 w-1/2">
<input <input
bind:value={name} bind:value={name}
placeholder="Room name" placeholder="Room name (Required)"
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" 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 <input
@ -67,21 +63,16 @@
<span>{coord.latitude},{coord.longitude}</span> <span>{coord.latitude},{coord.longitude}</span>
</p> </p>
<div class="flex flex-row items-center gap-2"> <input
Public Room: bind:value={pin}
<button class="cursor-pointer w-10 h-10 rounded-lg duration-200 flex items-center justify-center {privateRoom ? privateStyle : publicStyle}" onclick={() => (privateRoom = !privateRoom)}> type="number"
{#if privateRoom} max="9999"
<X /> placeholder="PIN (none if public)"
{:else} 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"
<Check /> />
{/if}
</button>
{#if !privateRoom}
<p class="text-sm italic">The room is flagged as public, everyone can join</p>
{/if}
</div>
<button <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" 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} onclick={createRoom}
> >

View file

Before

Width:  |  Height:  |  Size: 420 KiB

After

Width:  |  Height:  |  Size: 420 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 446 KiB

After

Width:  |  Height:  |  Size: 446 KiB

Before After
Before After