diff --git a/interface.html b/interface.html index 10b51a6..a5c0888 100644 --- a/interface.html +++ b/interface.html @@ -14,9 +14,7 @@

Player Controls

- - - +
@@ -43,45 +41,53 @@ const api = (endpoint, options = {}) => fetch(endpoint, options).then(res => res.json()).catch(console.error); - const updateVolume = async () => { - const res = await api("/player/volume"); - document.getElementById("volumeSlider").value = res.volume; - document.getElementById("volumeValue").textContent = res.volume; - }; - const setVolume = async (val) => { await api(`/player/volume?volume=${val}`, { method: "PUT" }); document.getElementById("volumeValue").textContent = val; }; - const updateQueue = async () => { - const queue = await api("/queue"); - document.getElementById("queueList").textContent = JSON.stringify(queue, null, 2); - }; - - const updateState = async () => { - const state = await api("/player"); - document.getElementById("playerState").textContent = JSON.stringify(state, null, 2); - }; - const addToQueue = async () => { const url = document.getElementById("trackUrl").value; await api(`/queue?url=${encodeURIComponent(url)}`, { method: "POST" }); updateQueue(); }; - const play = async () => { await api("/player/play", { method: "POST" }); updateState(); }; - const pause = async () => { await api("/player/pause", { method: "POST" }); updateState(); }; - const resume = async () => { await api("/player/resume", { method: "POST" }); updateState(); }; - const stop = async () => { await api("/player/stop", { method: "POST" }); updateState(); }; - const skip = async () => { await api("/player/skip", { method: "POST" }); updateState(); updateQueue(); }; + const play = async () => { await api("/player/play", { method: "POST" });}; + const stop = async () => { await api("/player/stop", { method: "POST" });}; + const skip = async () => { await api("/player/skip", { method: "POST" });}; + + // WebSocket connections + let playerSocket, queueSocket; + + function connectWebSockets() { + const proto = location.protocol === "https:" ? "wss" : "ws"; + const base = `${proto}://${location.host}`; + + playerSocket = new WebSocket(`${base}/player`); + queueSocket = new WebSocket(`${base}/queue`); + + playerSocket.onopen = () => playerSocket.send("ping"); + queueSocket.onopen = () => queueSocket.send("ping"); + + playerSocket.onmessage = (event) => { + const state = JSON.parse(event.data); + document.getElementById("playerState").textContent = JSON.stringify(state, null, 2); + document.getElementById("volumeSlider").value = state.volume; + document.getElementById("volumeValue").textContent = state.volume; + }; + + queueSocket.onmessage = (event) => { + const queue = JSON.parse(event.data); + document.getElementById("queueList").textContent = JSON.stringify(queue.items, null, 2); + }; + + playerSocket.onerror = queueSocket.onerror = console.error; + playerSocket.onclose = () => setTimeout(connectWebSockets, 1000); + queueSocket.onclose = () => setTimeout(connectWebSockets, 1000); + } + + connectWebSockets(); - // Init - updateVolume(); - updateQueue(); - updateState(); - setInterval(updateState, 3000); // auto-refresh state - setInterval(updateQueue, 3000); // auto-refresh state diff --git a/main.py b/main.py index dba7306..c61b874 100644 --- a/main.py +++ b/main.py @@ -1,7 +1,9 @@ +import asyncio from enum import Enum -from fastapi import FastAPI +from fastapi import FastAPI, WebSocket, WebSocketDisconnect from fastapi.responses import HTMLResponse +from pydantic import BaseModel from download_service import DownloadService from music_player import MusicPlayer, PlayerState @@ -14,8 +16,47 @@ class ChangePlayerState(Enum): stop = "stop" -queue: list[str] = [] +class WSConnectionType(Enum): + state = "state" + queue = "queue" + +class ConnectionManager: + def __init__(self) -> None: + self.active_connections: dict[str, set[WebSocket]] = { + WSConnectionType.state.value: set(), + WSConnectionType.queue.value: set(), + } + + async def connect(self, websocket: WebSocket, type: WSConnectionType): + await websocket.accept() + self.active_connections[type.value].add(websocket) + + async def send(self, ws: WebSocket, message: BaseModel): + try: + await ws.send_json(message.model_dump()) + except Exception: + self.disconnect(ws) + + async def broadcast(self, ws_type: WSConnectionType, message: BaseModel): + broken = set() + for ws in self.active_connections[ws_type.value]: + try: + await ws.send_json(message.model_dump()) + except Exception: + broken.add(ws) + for ws in broken: + self.disconnect(ws) + + def disconnect(self, websocket: WebSocket): + if websocket in self.active_connections[WSConnectionType.state.value]: + self.active_connections[WSConnectionType.state.value].remove(websocket) + + if websocket in self.active_connections[WSConnectionType.queue.value]: + self.active_connections[WSConnectionType.queue.value].remove(websocket) + + +# Setup tags_metadata = [ {"name": "player", "description": "Interact with the Music Player"}, {"name": "experimental"}, @@ -24,6 +65,7 @@ tags_metadata = [ app = FastAPI(openapi_tags=tags_metadata) player = MusicPlayer() dl_service = DownloadService() +ws_manager = ConnectionManager() # Interface @@ -33,47 +75,80 @@ async def root(): return f.read() -# Experimental +@app.on_event("startup") +async def start_event_loop(): + asyncio.create_task(state_broadcast_loop()) + asyncio.create_task(queue_broadcast_loop()) + + +async def state_broadcast_loop(): + while True: + await player._state_event.wait() + await ws_manager.broadcast(WSConnectionType.state, player.get_state()) + player._state_event.clear() + + +async def queue_broadcast_loop(): + while True: + await player._queue_event.wait() + await ws_manager.broadcast(WSConnectionType.queue, player.get_queue()) + player._queue_event.clear() + + +# Status updates +@app.websocket("/player") +async def websocket_player(websocket: WebSocket): + await ws_manager.connect(websocket, WSConnectionType.state) + try: + while True: + await websocket.receive_text() + await ws_manager.send(websocket, player.get_state()) + except WebSocketDisconnect: + ws_manager.disconnect(websocket) + + +# Queue updates +@app.websocket("/queue") +async def websocket_queue(websocket: WebSocket): + await ws_manager.connect(websocket, WSConnectionType.queue) + try: + while True: + await websocket.receive_text() + await ws_manager.send(websocket, player.get_queue()) + except WebSocketDisconnect: + ws_manager.disconnect(websocket) + + @app.get("/queue", tags=["queue"]) def get_queue(): return player.get_queue() @app.post("/queue", tags=["queue"]) -def post_to_queue(url: str): +async def post_to_queue(url: str): track = dl_service.download(url) - player.add_to_queue(track) + await player.add_to_queue(track) @app.post("/player/play", tags=["player"]) -def player_play(): - player.play() - - -@app.post("/player/pause", tags=["player"]) -def player_pause(): - player.pause() - - -@app.post("/player/resume", tags=["player"]) -def player_resume(): - player.resume() +async def player_play(): + await player.play() @app.post("/player/stop", tags=["player"]) -def player_stop(): - player.stop() +async def player_stop(): + await player.stop() @app.post("/player/skip", tags=["player"]) -def player_skip(): - player.next() +async def player_skip(): + await player.next() # Player @app.put("/player/volume", tags=["player"]) -def set_volume(volume: float): - player.set_volume(volume) +async def set_volume(volume: float): + await player.set_volume(volume) @app.get("/player/volume", tags=["player"]) diff --git a/music_player.py b/music_player.py index 737fbf8..eed20d4 100644 --- a/music_player.py +++ b/music_player.py @@ -1,3 +1,4 @@ +import asyncio import threading import time from enum import Enum @@ -7,13 +8,13 @@ from pydantic import BaseModel class Track(BaseModel): - artist: str - title: str - duration: int + artist: str | None + title: str | None + duration: int | None filepath: str -class PlaybackState(Enum): +class PlaybackState(str, Enum): Playing = "Playing" Paused = "Paused" Stopped = "Stopped" @@ -25,30 +26,28 @@ class PlayerState(BaseModel): volume: float -class Queue: - queue: list[Track] - - def __init__(self) -> None: - self._queue: list[Track] = [] +class Queue(BaseModel): + items: list[Track] = [] def next(self) -> Track | None: - if len(self._queue) > 0: - return self._queue.pop(0) + if len(self.items) > 0: + return self.items.pop(0) else: return None def add(self, track: Track) -> None: - self._queue.append(track) + self.items.append(track) def len(self) -> int: - return len(self._queue) + return len(self.items) class MusicPlayer: def __init__(self): # Player State self._state = PlayerState(volume=1) - self._state_changed: bool = False + self._state_lock: asyncio.Lock = asyncio.Lock() + self._state_event: asyncio.Event = asyncio.Event() # Sound initialization pygame.init() @@ -64,99 +63,97 @@ class MusicPlayer: # Queue self._queue: Queue = Queue() - self._queue_changed: bool = False + self._queue_lock: asyncio.Lock = asyncio.Lock() + self._queue_event: asyncio.Event = asyncio.Event() - def _loop(self): + async def _loop(self): while self._running: for event in pygame.event.get(): if event.type == pygame.USEREVENT: # a song just ended - self._handle_track_finished() - self._play_next_track() + await self._handle_track_finished() + await self._play_next_track() time.sleep(0.1) - def _set_playback_state(self, value: PlaybackState): - self._state.playback_state = value - self._state_changed = True + async def _set_playback_state(self, value: PlaybackState): + async with self._state_lock: + self._state.playback_state = value + self._state_event.set() - def _set_track(self, track: Track | None): - self._state.track = track - self._state_changed = True + async def _set_track(self, track: Track | None): + async with self._state_lock: + self._state.track = track + self._state_event.set() - def _handle_track_finished(self) -> None: + async def _handle_track_finished(self) -> None: print(f"Finished playing {self._state.track}") - self._set_track(None) - self._set_playback_state(PlaybackState.Stopped) + await self._set_track(None) + await self._set_playback_state(PlaybackState.Stopped) - def _load_track(self, track: Track): - self._set_track(track) + async def _load_track(self, track: Track): + await self._set_track(track) pygame.mixer.music.unload() pygame.mixer.music.load(track.filepath) - def _queue_add(self, track): - self._queue.add(track) - self._queue_changed = True + async def _queue_add(self, track): + async with self._queue_lock: + self._queue.add(track) + self._queue_event.set() - def _queue_next(self) -> Track | None: - next_track = self._queue.next() - self._queue_changed = True - return next_track + async def _queue_next(self) -> Track | None: + async with self._queue_lock: + next_track = self._queue.next() + self._queue_event.set() + return next_track - def _start_playback(self): + async def _start_playback(self): pygame.mixer.music.play() - self._set_playback_state(PlaybackState.Playing) + await self._set_playback_state(PlaybackState.Playing) - def _pause_playback(self): + async def _pause_playback(self): pygame.mixer.music.pause() - self._set_playback_state(PlaybackState.Paused) + await self._set_playback_state(PlaybackState.Paused) - def _resume_playback(self): + async def _resume_playback(self): pygame.mixer.music.unpause() - self._set_playback_state(PlaybackState.Playing) + await self._set_playback_state(PlaybackState.Playing) - def _stop_playback(self): + async def _stop_playback(self): pygame.mixer.music.stop() - self._set_playback_state(PlaybackState.Stopped) - self._set_track(None) + await self._set_playback_state(PlaybackState.Stopped) + await self._set_track(None) - def _play_next_track(self): - next_track = self._queue_next() + async def _play_next_track(self): + next_track = await self._queue_next() if next_track: - self._load_track(next_track) - self._start_playback() + await self._load_track(next_track) + await self._start_playback() - def add_to_queue(self, track: Track): + async def add_to_queue(self, track: Track): with self.lock: que_len = self._queue.len() - self._queue_add(track) + await self._queue_add(track) # If queue is empty and no corrent track, start playing if que_len == 0 and not self._state.track: - self._play_next_track() + await self._play_next_track() - def play(self): + async def play(self): with self.lock: - if self._state.playback_state == PlaybackState.Playing: - return - if self._state.track: - self._start_playback() - else: - self._play_next_track() + match self._state.playback_state: + case PlaybackState.Playing: + await self._pause_playback() + case PlaybackState.Paused: + await self._resume_playback() + case PlaybackState.Stopped: + await self._play_next_track() - def pause(self): + async def stop(self): with self.lock: - self._pause_playback() + await self._stop_playback() - def resume(self): + async def next(self): with self.lock: - self._resume_playback() - - def stop(self): - with self.lock: - self._stop_playback() - - def next(self): - with self.lock: - self._play_next_track() + await self._play_next_track() def shutdown(self): with self.lock: @@ -164,17 +161,18 @@ class MusicPlayer: self._thread.join() pygame.mixer.quit() - def get_queue(self) -> list[Track]: - return self._queue._queue + def get_queue(self) -> Queue: + return self._queue - def _set_volume(self, volume: float): - self._state.volume = volume - pygame.mixer.music.set_volume(volume) - self._state_changed = True + async def _set_volume(self, volume: float): + async with self._state_lock: + self._state.volume = volume + pygame.mixer.music.set_volume(volume) + self._state_event.set() - def set_volume(self, volume: float) -> None: + async def set_volume(self, volume: float) -> None: with self.lock: - self._set_volume(volume) + await self._set_volume(volume) def get_volume(self) -> float: return self._state.volume