feat: add basic interface and cleanup

This commit is contained in:
Radu C. Martin 2025-04-11 19:37:03 +02:00
parent 573429acd2
commit c8abb8943e
4 changed files with 211 additions and 53 deletions

View file

@ -25,11 +25,15 @@ class DownloadService:
def download(self, url: str) -> Track: def download(self, url: str) -> Track:
info = self.ydl.extract_info(url, download=True) info = self.ydl.extract_info(url, download=True)
try:
filepath = info["requested_downloads"][-1]["filepath"] # type: ignore
except KeyError:
raise ValueError("Could not ")
track = Track( track = Track(
artist=info["artists"][0], # type: ignore artist=info.get("artist", None), # type: ignore
title=info["title"], # type: ignore title=info.get("title", None), # type: ignore
duration=info["duration"], # type: ignore duration=info.get("duration", None), # type: ignore
filepath=info["requested_downloads"][-1]["filepath"], # type: ignore filepath=filepath, # type: ignore
) )
print(f"Finished processing {track}") print(f"Finished processing {track}")

88
interface.html Normal file
View file

@ -0,0 +1,88 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<title>Music Player</title>
<style>
body { font-family: sans-serif; padding: 1rem; }
button { margin: 0.5rem; }
#queueList { margin-top: 1rem; }
</style>
</head>
<body>
<h1>Music Player</h1>
<div>
<h2>Player Controls</h2>
<button onclick="play()">Play</button>
<button onclick="pause()">Pause</button>
<button onclick="resume()">Resume</button>
<button onclick="stop()">Stop</button>
<button onclick="skip()">Skip</button>
</div>
<div>
<h2>Volume</h2>
<input type="range" min="0" max="1" step="0.01" id="volumeSlider" oninput="setVolume(this.value)">
<span id="volumeValue">0</span>
</div>
<div>
<h2>Queue</h2>
<input type="text" id="trackUrl" placeholder="Track URL">
<button onclick="addToQueue()">Add to Queue</button>
<div id="queueList"></div>
</div>
<div>
<h2>Player State</h2>
<pre id="playerState"></pre>
</div>
<script>
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(); };
// Init
updateVolume();
updateQueue();
updateState();
setInterval(updateState, 3000); // auto-refresh state
setInterval(updateQueue, 3000); // auto-refresh state
</script>
</body>
</html>

23
main.py
View file

@ -1,9 +1,10 @@
from enum import Enum from enum import Enum
from fastapi import FastAPI from fastapi import FastAPI
from fastapi.responses import HTMLResponse
from download_service import DownloadService from download_service import DownloadService
from music_player import MusicPlayer from music_player import MusicPlayer, PlayerState
class ChangePlayerState(Enum): class ChangePlayerState(Enum):
@ -25,9 +26,16 @@ player = MusicPlayer()
dl_service = DownloadService() dl_service = DownloadService()
# Interface
@app.get("/", response_class=HTMLResponse)
async def root():
with open("interface.html") as f:
return f.read()
# Experimental # Experimental
@app.get("/queue", tags=["queue"]) @app.get("/queue", tags=["queue"])
def play_music(): def get_queue():
return player.get_queue() return player.get_queue()
@ -57,6 +65,11 @@ def player_stop():
player.stop() player.stop()
@app.post("/player/skip", tags=["player"])
def player_skip():
player.next()
# Player # Player
@app.put("/player/volume", tags=["player"]) @app.put("/player/volume", tags=["player"])
def set_volume(volume: float): def set_volume(volume: float):
@ -68,6 +81,6 @@ def get_volume():
return {"volume": player.get_volume()} return {"volume": player.get_volume()}
@app.get("/player/state", tags=["player"]) @app.get("/player", tags=["player"])
def get_state(): def get_player_state() -> PlayerState:
return {"state": player.get_state(), "current_track": player.get_current_track()} return player.get_state()

View file

@ -3,20 +3,26 @@ import time
from enum import Enum from enum import Enum
import pygame import pygame
from pydantic import BaseModel, Field from pydantic import BaseModel
class PlayerState(Enum):
Playing = "Playing"
Paused = "Paused"
Stopped = "Stopped"
class Track(BaseModel): class Track(BaseModel):
artist: str artist: str
title: str title: str
duration: int duration: int
filepath: str = Field(hidden=True) # don't show it in API responses filepath: str
class PlaybackState(Enum):
Playing = "Playing"
Paused = "Paused"
Stopped = "Stopped"
class PlayerState(BaseModel):
playback_state: PlaybackState = PlaybackState.Stopped
track: Track | None = None
volume: float
class Queue: class Queue:
@ -40,94 +46,141 @@ class Queue:
class MusicPlayer: class MusicPlayer:
def __init__(self): def __init__(self):
# Player State
self._state = PlayerState(volume=1)
self._state_changed: bool = False
# Sound initialization
pygame.init() pygame.init()
pygame.mixer.init() pygame.mixer.init()
pygame.mixer.music.set_endevent(pygame.USEREVENT) pygame.mixer.music.set_endevent(pygame.USEREVENT)
pygame.mixer.music.set_volume(self._state.volume)
# Threading # Threading
self.lock = threading.Lock() self.lock = threading.Lock()
self._running = True self._running = True
self.thread = threading.Thread(target=self._loop, daemon=True) self._thread = threading.Thread(target=self._loop, daemon=True)
self.thread.start() self._thread.start()
# Music Player
self.queue: Queue = Queue() # Queue
self.current_track: Track | None = None self._queue: Queue = Queue()
self._state: PlayerState = PlayerState.Stopped
# State change flags
self._queue_changed: bool = False self._queue_changed: bool = False
self._track_changed: bool = False
def _loop(self): 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
if self.current_track: self._handle_track_finished()
print(f"Track {self.current_track.title} ended")
self._play_next_track() self._play_next_track()
time.sleep(0.1) time.sleep(0.1)
def _set_playback_state(self, value: PlaybackState):
self._state.playback_state = value
self._state_changed = True
def _set_track(self, track: Track | None):
self._state.track = track
self._state_changed = True
def _handle_track_finished(self) -> None:
print(f"Finished playing {self._state.track}")
self._set_track(None)
self._set_playback_state(PlaybackState.Stopped)
def _load_track(self, track: Track): def _load_track(self, track: Track):
self.current_track = track self._set_track(track)
pygame.mixer.music.unload() pygame.mixer.music.unload()
pygame.mixer.music.load(self.current_track.filepath) pygame.mixer.music.load(track.filepath)
def _queue_add(self, track):
self._queue.add(track)
self._queue_changed = True
def _queue_next(self) -> Track | None:
next_track = self._queue.next()
self._queue_changed = True
return next_track
def _start_playback(self):
pygame.mixer.music.play()
self._set_playback_state(PlaybackState.Playing)
def _pause_playback(self):
pygame.mixer.music.pause()
self._set_playback_state(PlaybackState.Paused)
def _resume_playback(self):
pygame.mixer.music.unpause()
self._set_playback_state(PlaybackState.Playing)
def _stop_playback(self):
pygame.mixer.music.stop()
self._set_playback_state(PlaybackState.Stopped)
self._set_track(None)
def _play_next_track(self): def _play_next_track(self):
next_track = self.queue.next() next_track = self._queue_next()
if next_track: if next_track:
self._load_track(next_track) self._load_track(next_track)
pygame.mixer.music.play() self._start_playback()
self._state = PlayerState.Playing
def add_to_queue(self, track: Track): 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) 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.current_track: if que_len == 0 and not self._state.track:
self._play_next_track() self._play_next_track()
def play(self): def play(self):
with self.lock: with self.lock:
if self.current_track: if self._state.playback_state == PlaybackState.Playing:
pygame.mixer.music.play() return
self._state = PlayerState.Playing if self._state.track:
self._start_playback()
else: else:
self._play_next_track() self._play_next_track()
def pause(self): def pause(self):
with self.lock: with self.lock:
pygame.mixer.music.pause() self._pause_playback()
self._state = PlayerState.Paused
def resume(self): def resume(self):
with self.lock: with self.lock:
pygame.mixer.music.unpause() self._resume_playback()
self._state = PlayerState.Playing
def stop(self): def stop(self):
with self.lock: with self.lock:
pygame.mixer.music.stop() self._stop_playback()
self._state = PlayerState.Stopped
self.current_track = None def next(self):
with self.lock:
self._play_next_track()
def shutdown(self): def shutdown(self):
with self.lock: with self.lock:
self._running = False self._running = False
self.thread.join() self._thread.join()
pygame.mixer.quit() pygame.mixer.quit()
def get_queue(self) -> Queue: def get_queue(self) -> list[Track]:
return self.queue._queue return self._queue._queue
def set_volume(self, volume: float): def _set_volume(self, volume: float):
self._state.volume = volume
pygame.mixer.music.set_volume(volume)
self._state_changed = True
def set_volume(self, volume: float) -> None:
with self.lock: with self.lock:
pygame.mixer.music.set_volume(volume) self._set_volume(volume)
def get_volume(self): def get_volume(self) -> float:
return pygame.mixer.music.get_volume() return self._state.volume
def get_state(self) -> PlayerState: def get_state(self) -> PlayerState:
return self._state return self._state
def get_current_track(self) -> Track | None: def get_current_track(self) -> Track | None:
return self.current_track return self._state.track