feat: implement websockets

This commit is contained in:
Radu C. Martin 2025-04-14 10:10:01 +02:00
parent c8abb8943e
commit bf3fceb833
3 changed files with 211 additions and 132 deletions

View file

@ -14,9 +14,7 @@
<div> <div>
<h2>Player Controls</h2> <h2>Player Controls</h2>
<button onclick="play()">Play</button> <button onclick="play()">Play/Pause</button>
<button onclick="pause()">Pause</button>
<button onclick="resume()">Resume</button>
<button onclick="stop()">Stop</button> <button onclick="stop()">Stop</button>
<button onclick="skip()">Skip</button> <button onclick="skip()">Skip</button>
</div> </div>
@ -43,45 +41,53 @@
const api = (endpoint, options = {}) => const api = (endpoint, options = {}) =>
fetch(endpoint, options).then(res => res.json()).catch(console.error); 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) => { const setVolume = async (val) => {
await api(`/player/volume?volume=${val}`, { method: "PUT" }); await api(`/player/volume?volume=${val}`, { method: "PUT" });
document.getElementById("volumeValue").textContent = val; 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 addToQueue = async () => {
const url = document.getElementById("trackUrl").value; const url = document.getElementById("trackUrl").value;
await api(`/queue?url=${encodeURIComponent(url)}`, { method: "POST" }); await api(`/queue?url=${encodeURIComponent(url)}`, { method: "POST" });
updateQueue(); updateQueue();
}; };
const play = async () => { await api("/player/play", { method: "POST" }); updateState(); }; const play = async () => { await api("/player/play", { method: "POST" });};
const pause = async () => { await api("/player/pause", { method: "POST" }); updateState(); }; const stop = async () => { await api("/player/stop", { method: "POST" });};
const resume = async () => { await api("/player/resume", { method: "POST" }); updateState(); }; const skip = async () => { await api("/player/skip", { method: "POST" });};
const stop = async () => { await api("/player/stop", { method: "POST" }); updateState(); };
const skip = async () => { await api("/player/skip", { method: "POST" }); updateState(); updateQueue(); }; // 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
</script> </script>
</body> </body>
</html> </html>

121
main.py
View file

@ -1,7 +1,9 @@
import asyncio
from enum import Enum from enum import Enum
from fastapi import FastAPI from fastapi import FastAPI, WebSocket, WebSocketDisconnect
from fastapi.responses import HTMLResponse from fastapi.responses import HTMLResponse
from pydantic import BaseModel
from download_service import DownloadService from download_service import DownloadService
from music_player import MusicPlayer, PlayerState from music_player import MusicPlayer, PlayerState
@ -14,8 +16,47 @@ class ChangePlayerState(Enum):
stop = "stop" 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 = [ tags_metadata = [
{"name": "player", "description": "Interact with the Music Player"}, {"name": "player", "description": "Interact with the Music Player"},
{"name": "experimental"}, {"name": "experimental"},
@ -24,6 +65,7 @@ tags_metadata = [
app = FastAPI(openapi_tags=tags_metadata) app = FastAPI(openapi_tags=tags_metadata)
player = MusicPlayer() player = MusicPlayer()
dl_service = DownloadService() dl_service = DownloadService()
ws_manager = ConnectionManager()
# Interface # Interface
@ -33,47 +75,80 @@ async def root():
return f.read() 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"]) @app.get("/queue", tags=["queue"])
def get_queue(): def get_queue():
return player.get_queue() return player.get_queue()
@app.post("/queue", tags=["queue"]) @app.post("/queue", tags=["queue"])
def post_to_queue(url: str): async def post_to_queue(url: str):
track = dl_service.download(url) track = dl_service.download(url)
player.add_to_queue(track) await player.add_to_queue(track)
@app.post("/player/play", tags=["player"]) @app.post("/player/play", tags=["player"])
def player_play(): async def player_play():
player.play() await player.play()
@app.post("/player/pause", tags=["player"])
def player_pause():
player.pause()
@app.post("/player/resume", tags=["player"])
def player_resume():
player.resume()
@app.post("/player/stop", tags=["player"]) @app.post("/player/stop", tags=["player"])
def player_stop(): async def player_stop():
player.stop() await player.stop()
@app.post("/player/skip", tags=["player"]) @app.post("/player/skip", tags=["player"])
def player_skip(): async def player_skip():
player.next() await player.next()
# Player # Player
@app.put("/player/volume", tags=["player"]) @app.put("/player/volume", tags=["player"])
def set_volume(volume: float): async def set_volume(volume: float):
player.set_volume(volume) await player.set_volume(volume)
@app.get("/player/volume", tags=["player"]) @app.get("/player/volume", tags=["player"])

View file

@ -1,3 +1,4 @@
import asyncio
import threading import threading
import time import time
from enum import Enum from enum import Enum
@ -7,13 +8,13 @@ from pydantic import BaseModel
class Track(BaseModel): class Track(BaseModel):
artist: str artist: str | None
title: str title: str | None
duration: int duration: int | None
filepath: str filepath: str
class PlaybackState(Enum): class PlaybackState(str, Enum):
Playing = "Playing" Playing = "Playing"
Paused = "Paused" Paused = "Paused"
Stopped = "Stopped" Stopped = "Stopped"
@ -25,30 +26,28 @@ class PlayerState(BaseModel):
volume: float volume: float
class Queue: class Queue(BaseModel):
queue: list[Track] items: list[Track] = []
def __init__(self) -> None:
self._queue: list[Track] = []
def next(self) -> Track | None: def next(self) -> Track | None:
if len(self._queue) > 0: if len(self.items) > 0:
return self._queue.pop(0) return self.items.pop(0)
else: else:
return None return None
def add(self, track: Track) -> None: def add(self, track: Track) -> None:
self._queue.append(track) self.items.append(track)
def len(self) -> int: def len(self) -> int:
return len(self._queue) return len(self.items)
class MusicPlayer: class MusicPlayer:
def __init__(self): def __init__(self):
# Player State # Player State
self._state = PlayerState(volume=1) 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 # Sound initialization
pygame.init() pygame.init()
@ -64,99 +63,97 @@ class MusicPlayer:
# Queue # Queue
self._queue: Queue = 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: while self._running:
for event in pygame.event.get(): for event in pygame.event.get():
if event.type == pygame.USEREVENT: # a song just ended if event.type == pygame.USEREVENT: # a song just ended
self._handle_track_finished() await self._handle_track_finished()
self._play_next_track() await self._play_next_track()
time.sleep(0.1) time.sleep(0.1)
def _set_playback_state(self, value: PlaybackState): async def _set_playback_state(self, value: PlaybackState):
async with self._state_lock:
self._state.playback_state = value self._state.playback_state = value
self._state_changed = True self._state_event.set()
def _set_track(self, track: Track | None): async def _set_track(self, track: Track | None):
async with self._state_lock:
self._state.track = track self._state.track = track
self._state_changed = True self._state_event.set()
def _handle_track_finished(self) -> None: async def _handle_track_finished(self) -> None:
print(f"Finished playing {self._state.track}") print(f"Finished playing {self._state.track}")
self._set_track(None) await self._set_track(None)
self._set_playback_state(PlaybackState.Stopped) await self._set_playback_state(PlaybackState.Stopped)
def _load_track(self, track: Track): async def _load_track(self, track: Track):
self._set_track(track) await self._set_track(track)
pygame.mixer.music.unload() pygame.mixer.music.unload()
pygame.mixer.music.load(track.filepath) pygame.mixer.music.load(track.filepath)
def _queue_add(self, track): async def _queue_add(self, track):
async with self._queue_lock:
self._queue.add(track) self._queue.add(track)
self._queue_changed = True self._queue_event.set()
def _queue_next(self) -> Track | None: async def _queue_next(self) -> Track | None:
async with self._queue_lock:
next_track = self._queue.next() next_track = self._queue.next()
self._queue_changed = True self._queue_event.set()
return next_track return next_track
def _start_playback(self): async def _start_playback(self):
pygame.mixer.music.play() 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() 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() 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() pygame.mixer.music.stop()
self._set_playback_state(PlaybackState.Stopped) await self._set_playback_state(PlaybackState.Stopped)
self._set_track(None) await self._set_track(None)
def _play_next_track(self): async def _play_next_track(self):
next_track = self._queue_next() next_track = await self._queue_next()
if next_track: if next_track:
self._load_track(next_track) await self._load_track(next_track)
self._start_playback() await self._start_playback()
def add_to_queue(self, track: Track): async def add_to_queue(self, track: Track):
with self.lock: with self.lock:
que_len = self._queue.len() 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 queue is empty and no corrent track, start playing
if que_len == 0 and not self._state.track: 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: with self.lock:
if self._state.playback_state == PlaybackState.Playing: match self._state.playback_state:
return case PlaybackState.Playing:
if self._state.track: await self._pause_playback()
self._start_playback() case PlaybackState.Paused:
else: await self._resume_playback()
self._play_next_track() case PlaybackState.Stopped:
await self._play_next_track()
def pause(self): async def stop(self):
with self.lock: with self.lock:
self._pause_playback() await self._stop_playback()
def resume(self): async def next(self):
with self.lock: with self.lock:
self._resume_playback() await self._play_next_track()
def stop(self):
with self.lock:
self._stop_playback()
def next(self):
with self.lock:
self._play_next_track()
def shutdown(self): def shutdown(self):
with self.lock: with self.lock:
@ -164,17 +161,18 @@ class MusicPlayer:
self._thread.join() self._thread.join()
pygame.mixer.quit() pygame.mixer.quit()
def get_queue(self) -> list[Track]: def get_queue(self) -> Queue:
return self._queue._queue return self._queue
def _set_volume(self, volume: float): async def _set_volume(self, volume: float):
async with self._state_lock:
self._state.volume = volume self._state.volume = volume
pygame.mixer.music.set_volume(volume) pygame.mixer.music.set_volume(volume)
self._state_changed = True self._state_event.set()
def set_volume(self, volume: float) -> None: async def set_volume(self, volume: float) -> None:
with self.lock: with self.lock:
self._set_volume(volume) await self._set_volume(volume)
def get_volume(self) -> float: def get_volume(self) -> float:
return self._state.volume return self._state.volume