Compare commits

..

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

14 changed files with 67 additions and 169 deletions

View file

@ -1,40 +1,3 @@
# ChillBox # team-1
> *A project by Pausetta.org, Simone Tesini, Francesco De Carlo, Leonardo Segala, Matteo Peretto* Test
**ChillBox** is a web app that lets you create a shared radio station with a democratic voting system, so everyone gets to enjoy their favorite music together.
Perfect for venues like swimming pools, cafés, or even lively parties.
---
## 🎵 Voting System
Joining a ChillBox room is easy: users can either scan the QR code displayed on the host screen or use GPS to find nearby rooms.
Hosts can set a location range, ensuring only people physically present can add or vote for songs.
---
## 📊 Ranking Algorithm
ChillBox uses a smart ranking algorithm to decide what plays next. The score of each song is based on:
* Votes from users
* How recently similar songs (same genre or artist) have been played (less = better)
* A bit of randomness to keep things interesting
* A strong penalty for songs played too recently
---
## 👐 Hands-Off Experience
ChillBox is designed to be almost entirely hands-free.
Once the host sets up a room and optionally connects a screen or projector
(to show the current track, QR code, etc.), ChillBox takes care of the rest.
ChillBox comes with built-in automatic moderation to keep the music fair and on-theme.
* Users cant vote for the same song multiple times.
* A cooldown prevents users from spamming song requests.
* Hosts can define preferred genres and overall mood, so no one can hijack your chill beach vibes with unexpected death metal.
That said, hosts still have access to essential controls, like pause and skip, if needed.

View file

@ -1,15 +0,0 @@
# speech
## Home screen
We start here in the home page.
We can see this little radar animation, which means that the app is looking for nearby ChillBox rooms to join.
It uses GPS for this feature.
## Join room
When we join a room, the server checks our location and checks if it's within a specified range.
That way, you must physically be in the location to actually be able to add new songs
## Talk about the host
As you can see here (and hear) on the left, the host is already playing some music.
Now i will add a song on the client side and it will pop up in the list.

View file

@ -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="Lido Scena", name="Test Room",
pin=1234, pin=None,
tags=set(["chill", "raggaetton", "spanish", "latino", "mexican", "rock"]), tags=set(),
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("pin") code = request.args.get("code")
if room_id is None: if room_id is None:
return error("Missing room id") return error("Missing room id")
@ -81,11 +81,8 @@ def join():
if (room := state.rooms.get(int(room_id))) is None: if (room := state.rooms.get(int(room_id))) is None:
return error("Invalid room") return error("Invalid room")
if room.pin is not None: if room.pin is not None and room.pin != code:
if code is None: return error("Invalid code")
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,45 +1,19 @@
<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>
<div <a
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" 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"
href="/room/{room.id}"
> >
<button <div class="flex flex-row">
class="flex flex-row items-center" {room.name}
onclick={() => { {room.private ? "🔒" : ""}
if (!room.private) { </div>
window.location.href = "/room/" + room.id <div class="grow"></div>
return <div class="flex flex-row items-center gap-2">
} <div class="font-mono">{Math.round(room.distance)}m</div>
showPinModal = !showPinModal <div class="rounded bg-light-pine-blue px-2 py-0.5 text-dark-pine-text dark:bg-dark-pine-blue">Join</div>
}} </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,39 +1,20 @@
<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 loading: boolean = $state(false) let disabled: 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() {
loading = true disabled = true
const res = await fetch(`/api/addsong?room=${roomId}&query=${input}`, { method: "POST" }) await fetch(`/api/addsong?room=${roomId}&query=${input}`, { method: "POST" })
const json = await res.json()
input = "" input = ""
loading = false disabled = 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 ${loading ? "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 ${disabled ? "disabled" : ""}`}
> >
<input <input
type="text" type="text"
@ -41,29 +22,20 @@
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={loading} {disabled}
/> />
{#if loading} {#if disabled}
<span class="animate-spin"> <span class="animate-spin">
<LoaderCircle /> <LoaderCircle />
</span> </span>
{/if} {/if}
<button <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}
disabled={cooldowned} >Add</button
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,8 +7,9 @@
let picked_suggestions: string[] = $state([]) let picked_suggestions: string[] = $state([])
async function vote(amount: number, songId: string) { async function vote(idx: number, 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")
@ -25,7 +26,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} {#each suggestions as sug, idx}
<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"
> >
@ -47,7 +48,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(1, sug.uuid) await vote(idx, 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>
@ -55,7 +56,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(-1, sug.uuid) await vote(idx, -1, sug.uuid)
}}><ThumbsDown /></button }}><ThumbsDown /></button
> >
</div> </div>

View file

@ -1,8 +1,8 @@
import { type Coordinates } from "./gps" import { get_coords, 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, pin: string): Promise<[FetchError | null, string]> { export const joinRoom = async function (roomId: string, coords: Coordinates): Promise<[FetchError | null, string]> {
let res = await fetch(`/api/join?room=${roomId}&lat=${coords.latitude}&lon=${coords.longitude}&pin=${pin}`) let res = await fetch(`/api/join?room=${roomId}&lat=${coords.latitude}&lon=${coords.longitude}`)
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="/radar_bonus.gif" alt="radar" class="h-64 w-64" /> <img src="/smerdo_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/create" window.location.href = "/room/new"
}} }}
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,7 +112,5 @@
<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, data.pin) ;[returnError] = await joinRoom(data.roomId, coords)
if (returnError) { if (returnError) {
return return
} }

View file

@ -3,6 +3,5 @@ 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,7 +9,9 @@
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) {
@ -20,12 +22,16 @@
return return
} }
let pin
if (privateRoom) {
pin = Math.floor(Math.random() * 10000)
} else {
pin = ""
}
creating = true creating = true
const res = await fetch( const res = await fetch(`/api/room/new?name=${encodeURIComponent(name)}&coords=${coord.latitude},${coord.longitude}&range=${encodeURIComponent(range ?? "100")}&pin=${pin}`, { method: "POST" })
`/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()
@ -44,10 +50,8 @@
<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 (Required)" placeholder="Room name"
class="{name 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"
? ''
: '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
@ -63,16 +67,21 @@
<span>{coord.latitude},{coord.longitude}</span> <span>{coord.latitude},{coord.longitude}</span>
</p> </p>
<input <div class="flex flex-row items-center gap-2">
bind:value={pin} Public Room:
type="number" <button class="cursor-pointer w-10 h-10 rounded-lg duration-200 flex items-center justify-center {privateRoom ? privateStyle : publicStyle}" onclick={() => (privateRoom = !privateRoom)}>
max="9999" {#if privateRoom}
placeholder="PIN (none if public)" <X />
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" {:else}
/> <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: 446 KiB

After

Width:  |  Height:  |  Size: 446 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 420 KiB

After

Width:  |  Height:  |  Size: 420 KiB

Before After
Before After