feat: implement websockets
This commit is contained in:
parent
c8abb8943e
commit
bf3fceb833
3 changed files with 211 additions and 132 deletions
|
@ -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
121
main.py
|
@ -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"])
|
||||||
|
|
142
music_player.py
142
music_player.py
|
@ -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
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue