Compare commits
61 commits
scanner_pa
...
main
Author | SHA1 | Date | |
---|---|---|---|
![]() |
7443d600e7 | ||
![]() |
bf71a8103d | ||
f7351aecf8 | |||
436553809a | |||
fb49da2edd | |||
![]() |
9a12f39f3e | ||
![]() |
9ec601b263 | ||
![]() |
1763a6b96f | ||
360338ccb2 | |||
278a2d94a8 | |||
![]() |
c3d30a1cc8 | ||
![]() |
1063c239b6 | ||
![]() |
6a50dc4c86 | ||
![]() |
efcabd2ee4 | ||
746189862a | |||
01cf53c775 | |||
7587796934 | |||
a244ca30d4 | |||
f4a4c46fbc | |||
![]() |
85bb34328d | ||
![]() |
ba2b5f04c6 | ||
![]() |
45ccadb736 | ||
![]() |
350f44b194 | ||
a0360f821a | |||
2a63a527f0 | |||
![]() |
fb34d2e945 | ||
![]() |
cbf90d1d0a | ||
![]() |
7e52746b37 | ||
![]() |
ec36ea3feb | ||
![]() |
cd9d47ad8d | ||
![]() |
88f34c6257 | ||
![]() |
3ee764ad03 | ||
![]() |
a7374d6d94 | ||
![]() |
795a5a3a19 | ||
![]() |
5632209ace | ||
![]() |
e045e11023 | ||
![]() |
69cf462662 | ||
![]() |
543db8d25f | ||
![]() |
0c34586358 | ||
596061adfe | |||
2a8802a0b8 | |||
396c7b0cea | |||
656d1e40c7 | |||
124e426b21 | |||
d50979077a | |||
fe7c740573 | |||
![]() |
bb16817811 | ||
244e49d311 | |||
![]() |
964c226bde | ||
![]() |
2c1928822b | ||
cd5e52cb23 | |||
6bdbae1881 | |||
3a944767a8 | |||
b7aebadffe | |||
![]() |
f3101ad8ab | ||
d337f747f3 | |||
4bf93b565b | |||
78f03dec5d | |||
adc362562e | |||
ca0b441229 | |||
ebf3d29fa8 |
41
README.md
|
@ -1,3 +1,40 @@
|
|||
# team-1
|
||||
# ChillBox
|
||||
|
||||
Test
|
||||
> *A project by Pausetta.org, Simone Tesini, Francesco De Carlo, Leonardo Segala, Matteo Peretto*
|
||||
|
||||
**ChillBox** is a web app that lets you create a shared radio station with a democratic voting system, so everyone gets to enjoy their favorite music together.
|
||||
Perfect for venues like swimming pools, cafés, or even lively parties.
|
||||
|
||||
---
|
||||
|
||||
## 🎵 Voting System
|
||||
|
||||
Joining a ChillBox room is easy: users can either scan the QR code displayed on the host screen or use GPS to find nearby rooms.
|
||||
Hosts can set a location range, ensuring only people physically present can add or vote for songs.
|
||||
|
||||
---
|
||||
|
||||
## 📊 Ranking Algorithm
|
||||
|
||||
ChillBox uses a smart ranking algorithm to decide what plays next. The score of each song is based on:
|
||||
|
||||
* Votes from users
|
||||
* How recently similar songs (same genre or artist) have been played (less = better)
|
||||
* A bit of randomness to keep things interesting
|
||||
* A strong penalty for songs played too recently
|
||||
|
||||
---
|
||||
|
||||
## 👐 Hands-Off Experience
|
||||
|
||||
ChillBox is designed to be almost entirely hands-free.
|
||||
Once the host sets up a room and optionally connects a screen or projector
|
||||
(to show the current track, QR code, etc.), ChillBox takes care of the rest.
|
||||
|
||||
ChillBox comes with built-in automatic moderation to keep the music fair and on-theme.
|
||||
|
||||
* Users can’t vote for the same song multiple times.
|
||||
* A cooldown prevents users from spamming song requests.
|
||||
* Hosts can define preferred genres and overall mood, so no one can hijack your chill beach vibes with unexpected death metal.
|
||||
|
||||
That said, hosts still have access to essential controls, like pause and skip, if needed.
|
||||
|
|
15
SPEECH.md
Normal 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.
|
||||
|
|
@ -12,5 +12,6 @@ RUN pip install --no-cache-dir -r requirements.txt
|
|||
COPY . .
|
||||
|
||||
EXPOSE 5000
|
||||
CMD ["flask", "--app", "src.app", "run", "--debug", "--host=0.0.0.0"]
|
||||
# CMD ["flask", "--app", "src.app", "run", "--debug", "--host=0.0.0.0"]
|
||||
CMD ["python3", "src/app.py"]
|
||||
# flask --app src.app run --host=0.0.0.0 --port=5001 --debug
|
||||
|
|
|
@ -5,3 +5,4 @@ dotenv
|
|||
requests
|
||||
qrcode
|
||||
Pillow
|
||||
eventlet>=0.33
|
||||
|
|
|
@ -1,17 +1,17 @@
|
|||
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 .state import State
|
||||
from .connect import get_connection
|
||||
from .room import Room
|
||||
from .song import Song, init_db, get_song_by_title_artist, add_song_in_db, get_song_by_uuid
|
||||
from .song_fetch import query_search, yt_get_audio_url, yt_search_song
|
||||
from .qrcode_gen import generate_qr
|
||||
|
||||
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()
|
||||
|
||||
|
@ -28,11 +28,11 @@ init_db(state.db)
|
|||
|
||||
state.rooms[1000] = Room(
|
||||
id=1000,
|
||||
coord=(1.0, 5.5),
|
||||
name="Test Room",
|
||||
pin=None,
|
||||
tags=set(),
|
||||
range_size=100,
|
||||
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=[],
|
||||
|
@ -73,7 +73,7 @@ def on_leave(data):
|
|||
@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")
|
||||
|
@ -81,9 +81,23 @@ def join():
|
|||
if (room := state.rooms.get(int(room_id))) is None:
|
||||
return error("Invalid room")
|
||||
|
||||
if room.pin is not None and room.pin != 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}"}
|
||||
|
||||
|
||||
|
@ -140,8 +154,8 @@ def room_new():
|
|||
lat, lon = room_cords.split(",")
|
||||
|
||||
room = Room(
|
||||
id=max(state.rooms or [0]) + 1, #
|
||||
coord=(float(lat), float(lon)),
|
||||
id=max(state.rooms or [0]) + 1,
|
||||
coord=Coordinates(float(lat), float(lon)),
|
||||
range_size=int(room_range),
|
||||
name=room_name,
|
||||
pin=room_pin,
|
||||
|
@ -159,6 +173,14 @@ def room_new():
|
|||
|
||||
@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,
|
||||
|
@ -166,8 +188,10 @@ def room():
|
|||
"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
|
||||
]
|
||||
|
||||
|
||||
|
@ -189,6 +213,9 @@ def add_song():
|
|||
## 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()),
|
||||
|
@ -201,6 +228,15 @@ def add_song():
|
|||
|
||||
add_song_in_db(song)
|
||||
|
||||
if len(room.tags) > 0:
|
||||
tag_ok = False
|
||||
for tag in song.tags:
|
||||
if tag in room.tags:
|
||||
tag_ok = True
|
||||
|
||||
if not tag_ok:
|
||||
return error("Song genre does not belong to this room")
|
||||
|
||||
## add the song in the room if does not exists
|
||||
if song.uuid not in room.songs:
|
||||
room.songs[song.uuid] = (song, 1) # start with one vote
|
||||
|
@ -273,4 +309,4 @@ def get_audio_url():
|
|||
|
||||
|
||||
if __name__ == "__main__":
|
||||
socketio.run(app, debug=True)
|
||||
socketio.run(app, host="0.0.0.0", port=5000, debug=True)
|
||||
|
|
|
@ -1,9 +1,11 @@
|
|||
import math
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass
|
||||
class Coordinates:
|
||||
latitude: int
|
||||
longitude: int
|
||||
latitude: float
|
||||
longitude: float
|
||||
|
||||
|
||||
def distance_between_coords(lhs: Coordinates, rhs: Coordinates) -> float:
|
||||
|
|
|
@ -1,7 +1,8 @@
|
|||
import random
|
||||
from dataclasses import dataclass
|
||||
|
||||
from .song import Song
|
||||
from gps import Coordinates
|
||||
from song import Song
|
||||
|
||||
USER_SCORE_WEIGHT = 0.7
|
||||
ARTIST_WEIGHT = 0.1
|
||||
|
@ -30,7 +31,7 @@ class Rank:
|
|||
@dataclass
|
||||
class Room:
|
||||
id: int
|
||||
coord: tuple[float, float]
|
||||
coord: Coordinates
|
||||
name: str
|
||||
pin: int | None
|
||||
tags: set[str]
|
||||
|
|
|
@ -1,7 +1,7 @@
|
|||
from dataclasses import dataclass
|
||||
from sqlite3 import Cursor
|
||||
|
||||
from .connect import get_connection
|
||||
from connect import get_connection
|
||||
|
||||
|
||||
def init_db(db: Cursor):
|
||||
|
|
|
@ -1,10 +1,11 @@
|
|||
import requests
|
||||
import urllib.parse
|
||||
import os.path
|
||||
import os
|
||||
import os.path
|
||||
import sys
|
||||
import urllib.parse
|
||||
from dataclasses import dataclass
|
||||
|
||||
import requests
|
||||
|
||||
sys.path.append("/yt-dlp")
|
||||
import yt_dlp
|
||||
|
||||
|
@ -17,7 +18,7 @@ class SongInfo:
|
|||
tags: list[str]
|
||||
|
||||
|
||||
def _lastfm_search(query: str) -> tuple[str, 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"]},
|
||||
|
@ -25,7 +26,10 @@ def _lastfm_search(query: str) -> tuple[str, str]:
|
|||
|
||||
assert response.status_code == 200
|
||||
|
||||
track_info = response.json()["results"]["trackmatches"]["track"][0]
|
||||
tracks = response.json()["results"]["trackmatches"]["track"]
|
||||
if len(tracks) == 0:
|
||||
return None
|
||||
track_info = tracks[0]
|
||||
|
||||
return track_info["name"], track_info["artist"]
|
||||
|
||||
|
@ -42,11 +46,16 @@ def _lastfm_getinfo(name: str, artist: str) -> tuple[str, list[str]]: # ( image
|
|||
|
||||
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"],
|
||||
os.path.splitext(os.path.basename(image_url.path))[0],
|
||||
image_id,
|
||||
[t["name"] for t in track_info["toptags"]["tag"]],
|
||||
)
|
||||
|
||||
|
@ -68,14 +77,17 @@ def _yt_search(query: str) -> tuple[str, str]:
|
|||
|
||||
|
||||
def query_search(query: str) -> SongInfo | None:
|
||||
name, artist = _lastfm_search(query)
|
||||
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: # video id
|
||||
def yt_search_song(name: str, artist: str) -> str | None: # video id
|
||||
ydl_opts = {
|
||||
"format": "bestaudio",
|
||||
"default_search": "ytsearch1",
|
||||
|
@ -86,6 +98,9 @@ def yt_search_song(name: str, artist: str) -> str: # video id
|
|||
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"]
|
||||
|
||||
|
||||
|
|
|
@ -4,7 +4,7 @@ from sqlite3 import Cursor
|
|||
from flask import Flask
|
||||
from flask_socketio import SocketIO
|
||||
|
||||
from .room import Room
|
||||
from room import Room
|
||||
|
||||
|
||||
@dataclass
|
||||
|
|
|
@ -1,15 +1,13 @@
|
|||
<!doctype html>
|
||||
<html lang="en">
|
||||
|
||||
<head>
|
||||
<head>
|
||||
<meta charset="utf-8" />
|
||||
<link rel="icon" href="%sveltekit.assets%/favicon.svg" />
|
||||
<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">
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover" class="h-max">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
</body>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
|
|
@ -8,6 +8,10 @@
|
|||
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">
|
||||
|
@ -19,20 +23,22 @@
|
|||
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-white"></div>
|
||||
<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={`https://lastfm.freetls.fastly.net/i/u/174s/${song.image_id}.png`} alt="Song cover" />
|
||||
<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-2">{song.title} - {song.artist}</h1>
|
||||
<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">
|
||||
{#if i === 1}
|
||||
<p>No song in queue</p>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="flex h-[60vw] max-h-[250px] w-[60vw] max-w-[250px] items-center justify-center"></div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
|
|
|
@ -1,18 +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>
|
||||
|
||||
<button
|
||||
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"
|
||||
<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">64m</div>
|
||||
<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>
|
||||
</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>
|
||||
|
|
|
@ -1,19 +1,69 @@
|
|||
<script lang="ts">
|
||||
let { roomId } = $props()
|
||||
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() {
|
||||
let resp = await fetch(`/api/addsong?room=${roomId}&query=${input}`, { method: "POST" })
|
||||
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="bg-lime-500 flex h-full w-full flex-row items-center gap-2 rounded border-2 border-lime-600">
|
||||
<input type="text" placeholder="Song & Artist" class="font-bold outline-none text-white h-[50px] px-4 w-3/4 rounded" bind:value={input} />
|
||||
<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
|
||||
class="shadow-xl hover:scale-105 w-1/4 h-[40px] cursor-pointer bg-lime-600 border-lime-700 font-semibold text-white border-2 i-lucide-check rounded active:scale-90 duration-100"
|
||||
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>
|
||||
|
|
|
@ -1,41 +1,62 @@
|
|||
<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()
|
||||
|
||||
async function vote(idx: number, amount: number, songId: string) {
|
||||
suggestions[idx].upvote += amount
|
||||
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 <b>Add</b> button</p>
|
||||
<p>No suggestions yet! Try to add a new one using the Add button</p>
|
||||
{/if}
|
||||
|
||||
{#each suggestions as sug, idx}
|
||||
<div class="flex h-[80px] w-full flex-row gap-2 rounded border-2 border-indigo-600 bg-indigo-500 p-2 shadow-md duration-100 hover:bg-indigo-400">
|
||||
<div class="flex w-3/4 flex-row gap-2">
|
||||
<img class="w-[60px] min-w-[60px] rounded" src={`https://lastfm.freetls.fastly.net/i/u/174s/${sug.image_id}.png`} alt="Song cover" />
|
||||
<div class="text-white">
|
||||
<p>{sug.title}</p>
|
||||
{#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="grayscale"
|
||||
onclick={() => {
|
||||
vote(idx, 1, sug.uuid)
|
||||
}}>👍</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-white">{sug.upvote}</p>
|
||||
<p class="font-semibold text-light-pine-text dark:text-dark-pine-text">{sug.upvote}</p>
|
||||
<button
|
||||
class="duration-100 hover:scale-150"
|
||||
onclick={() => {
|
||||
vote(idx, -1, sug.uuid)
|
||||
}}><div class="rotate-180">👍</div></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>
|
||||
|
|
|
@ -10,19 +10,19 @@ const SongSchema = z.object({
|
|||
})
|
||||
export type Song = z.infer<typeof SongSchema>
|
||||
|
||||
export const parseSong = async function(song: any): Promise<Song> {
|
||||
export const parseSong = async function (song: any): Promise<Song> {
|
||||
let resp = await SongSchema.parseAsync(song)
|
||||
return resp
|
||||
}
|
||||
|
||||
export const createEmptySong = function(): Song {
|
||||
export const createEmptySong = function (): Song {
|
||||
return {
|
||||
uuid: "-1",
|
||||
title: "",
|
||||
artist: "",
|
||||
tags: [""],
|
||||
image_id: "",
|
||||
youtube_id: 0,
|
||||
youtube_id: "",
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -31,21 +31,22 @@ const SuggestionSchema = SongSchema.extend({
|
|||
})
|
||||
export type Suggestion = z.infer<typeof SuggestionSchema>
|
||||
|
||||
export const parseSuggestion = async function(sugg: any): Promise<Suggestion> {
|
||||
export const parseSuggestion = async function (sugg: any): Promise<Suggestion> {
|
||||
let resp = await SuggestionSchema.parseAsync(sugg)
|
||||
return resp
|
||||
}
|
||||
|
||||
const RoomSchema = z.object({
|
||||
id: z.string(),
|
||||
id: z.number(),
|
||||
name: z.string(),
|
||||
private: z.boolean(),
|
||||
coords: z.tuple([z.number(), z.number()]),
|
||||
range: z.number().int()
|
||||
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> {
|
||||
export const parseRoom = async function (room: any): Promise<Room> {
|
||||
let resp = await RoomSchema.parseAsync(room)
|
||||
return resp
|
||||
}
|
||||
|
|
|
@ -1,9 +1,10 @@
|
|||
import { type Coordinates } from "./gps"
|
||||
import { parseSong, parseSuggestion, type FetchError, type Song, type Suggestion } from "./types"
|
||||
|
||||
export const joinRoom = async function (roomId: string): Promise<[FetchError | null, string]> {
|
||||
let resp = await fetch("/api/join?room=" + roomId)
|
||||
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 (resp.status != 200) {
|
||||
if (res.status != 200) {
|
||||
return [{ code: 400, message: "Cannot join the room" }, ""]
|
||||
}
|
||||
|
||||
|
|
|
@ -4,6 +4,6 @@
|
|||
let { children } = $props()
|
||||
</script>
|
||||
|
||||
<div class="min-h-screen w-full bg-light-pine-base px-0 text-light-pine-text sm:px-20 md:px-40 lg:px-80 dark:bg-dark-pine-base dark:text-dark-pine-text">
|
||||
<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>
|
||||
|
|
|
@ -1,9 +1,20 @@
|
|||
<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 room: Room = { id: "asd", coords: [0.123, 0.456], name: "scatolame party", private: true, range: 124 }
|
||||
let rooms: Room[] = $state([])
|
||||
|
||||
onMount(async () => {
|
||||
let { coords, error } = await get_coords()
|
||||
if (error != null || coords == null) coords = { latitude: 46.6769043, longitude: 11.1851585 }
|
||||
|
||||
let res = await fetch(`/api/room?lat=${coords.latitude}&lon=${coords.longitude}`)
|
||||
let json = await res.json()
|
||||
for (let room of json) rooms.push(await parseRoom(room))
|
||||
})
|
||||
</script>
|
||||
|
||||
<div class="flex flex-col items-center gap-2">
|
||||
|
@ -12,46 +23,22 @@
|
|||
<span class="lilita-one-regular text-6xl font-bold">ChillBox</span>
|
||||
</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>
|
||||
|
||||
<button 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">
|
||||
<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">
|
||||
<RoomComponent {room}></RoomComponent>
|
||||
<RoomComponent {room}></RoomComponent>
|
||||
<RoomComponent {room}></RoomComponent>
|
||||
<RoomComponent {room}></RoomComponent>
|
||||
<RoomComponent {room}></RoomComponent>
|
||||
<RoomComponent {room}></RoomComponent>
|
||||
<RoomComponent {room}></RoomComponent>
|
||||
<RoomComponent {room}></RoomComponent>
|
||||
<RoomComponent {room}></RoomComponent>
|
||||
<RoomComponent {room}></RoomComponent>
|
||||
<RoomComponent {room}></RoomComponent>
|
||||
<RoomComponent {room}></RoomComponent>
|
||||
{#each rooms as room}
|
||||
<RoomComponent {room} />
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
<!-- <div class="h-full w-full flex-row justify-center p-4"> -->
|
||||
<!-- <div class="relative min-h-screen justify-center justify-items-center"> -->
|
||||
<!-- <h1>Scan your nearby rooms</h1> -->
|
||||
<!-- <img src="/smerdoradar.gif" alt="radar" class="h-64 w-64" /> -->
|
||||
<!-- <div class="max-h-50 w-full overflow-y-auto"> -->
|
||||
<!-- <RoomComponent {room} /> -->
|
||||
<!-- <RoomComponent {room} /> -->
|
||||
<!-- <RoomComponent {room} /> -->
|
||||
<!-- <RoomComponent {room} /> -->
|
||||
<!-- <RoomComponent {room} /> -->
|
||||
<!-- <RoomComponent {room} /> -->
|
||||
<!-- <RoomComponent {room} /> -->
|
||||
<!-- <RoomComponent {room} /> -->
|
||||
<!-- <RoomComponent {room} /> -->
|
||||
<!-- </div> -->
|
||||
<!-- <div class="fixed right-0 bottom-0"> -->
|
||||
<!-- <button class="mt-4 justify-end rounded bg-blue-500 px-6 py-2 text-white transition-colors hover:bg-blue-600 active:bg-blue-700"> + </button> -->
|
||||
<!-- </div> -->
|
||||
<!-- </div> -->
|
||||
<!-- </div> -->
|
||||
|
|
|
@ -15,14 +15,37 @@
|
|||
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
|
||||
audioController.play()
|
||||
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)
|
||||
|
@ -30,12 +53,21 @@
|
|||
queueSongs = songs
|
||||
playingIndex = index
|
||||
|
||||
if (audioController) {
|
||||
audioController.src = await getStreamingUrl(currentPlaying.uuid)
|
||||
audioController.play()
|
||||
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)
|
||||
|
@ -44,6 +76,20 @@
|
|||
|
||||
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>
|
||||
|
||||
|
@ -52,7 +98,21 @@
|
|||
{:else}
|
||||
<div class="flex w-full flex-col items-center justify-center p-4 lg:p-10">
|
||||
<QueueSlider {queueSongs} {playingIndex} />
|
||||
<button onclick={playNext}>Next</button>
|
||||
<audio controls autoplay bind:this={audioController} onended={playOnEnd}></audio>
|
||||
|
||||
<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}
|
||||
|
|
|
@ -8,6 +8,7 @@
|
|||
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()
|
||||
|
||||
|
@ -23,8 +24,14 @@
|
|||
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)
|
||||
;[returnError] = await joinRoom(data.roomId, coords, data.pin)
|
||||
if (returnError) {
|
||||
return
|
||||
}
|
||||
|
@ -69,7 +76,7 @@
|
|||
{#if returnError}
|
||||
<Error {returnError} />
|
||||
{:else}
|
||||
<div class="flex w-full flex-col items-center justify-center p-4 lg:p-10">
|
||||
<div class="flex w-full flex-col items-center justify-center px-2 py-4 lg:p-10">
|
||||
<QueueSlider {queueSongs} {playingIndex} />
|
||||
<div class="w-full py-6 lg:w-[30vw]">
|
||||
<SuggestionInput roomId={data.roomId} />
|
||||
|
|
|
@ -1,13 +1,8 @@
|
|||
import { error, type Load, type ServerLoad } from "@sveltejs/kit"
|
||||
import type { PageLoad } from "./$types"
|
||||
import type { FetchError } from "$lib/util"
|
||||
import { parseSuggestion, type Suggestion } from "$lib/types"
|
||||
|
||||
export const load: PageLoad = async ({ params }) => {
|
||||
if (params.id) {
|
||||
export const load: PageLoad = ({ params, url }) => {
|
||||
return {
|
||||
roomId: params.id,
|
||||
roomId: params.id || "",
|
||||
pin: url.searchParams.get("pin") || "",
|
||||
}
|
||||
}
|
||||
error(400, "Please provide a room id")
|
||||
}
|
||||
|
|
83
frontend/src/routes/room/create/+page.svelte
Normal 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>
|
BIN
frontend/static/android-chrome-192x192.png
Normal file
After Width: | Height: | Size: 27 KiB |
BIN
frontend/static/android-chrome-512x512.png
Normal file
After Width: | Height: | Size: 142 KiB |
BIN
frontend/static/apple-touch-icon.png
Normal file
After Width: | Height: | Size: 24 KiB |
BIN
frontend/static/favicon-16x16.png
Normal file
After Width: | Height: | Size: 768 B |
BIN
frontend/static/favicon-32x32.png
Normal file
After Width: | Height: | Size: 1.9 KiB |
BIN
frontend/static/favicon.ico
Normal file
After Width: | Height: | Size: 15 KiB |
|
@ -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 |
|
@ -8,12 +8,12 @@
|
|||
"orientation": "portrait-primary",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icon-512.png",
|
||||
"src": "/android-chrome-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
},
|
||||
{
|
||||
"src": "/icon-192.png",
|
||||
"src": "/android-chrome-192x192.png",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png"
|
||||
}
|
||||
|
|
Before Width: | Height: | Size: 420 KiB After Width: | Height: | Size: 420 KiB |
Before Width: | Height: | Size: 446 KiB After Width: | Height: | Size: 446 KiB |