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>
<h2>Player Controls</h2>
<button onclick="play()">Play</button>
<button onclick="pause()">Pause</button>
<button onclick="resume()">Resume</button>
<button onclick="play()">Play/Pause</button>
<button onclick="stop()">Stop</button>
<button onclick="skip()">Skip</button>
</div>
@ -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
</script>
</body>
</html>

121
main.py
View file

@ -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"])

View file

@ -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