From c8abb8943ed0c13a8ed35d5526efd2f416fb476c Mon Sep 17 00:00:00 2001 From: "Radu C. Martin" Date: Fri, 11 Apr 2025 19:37:03 +0200 Subject: [PATCH] feat: add basic interface and cleanup --- download_service.py | 12 ++-- interface.html | 88 +++++++++++++++++++++++++++ main.py | 23 ++++++-- music_player.py | 141 ++++++++++++++++++++++++++++++-------------- 4 files changed, 211 insertions(+), 53 deletions(-) create mode 100644 interface.html diff --git a/download_service.py b/download_service.py index dc7b96f..0636905 100644 --- a/download_service.py +++ b/download_service.py @@ -25,11 +25,15 @@ class DownloadService: def download(self, url: str) -> Track: 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( - artist=info["artists"][0], # type: ignore - title=info["title"], # type: ignore - duration=info["duration"], # type: ignore - filepath=info["requested_downloads"][-1]["filepath"], # type: ignore + artist=info.get("artist", None), # type: ignore + title=info.get("title", None), # type: ignore + duration=info.get("duration", None), # type: ignore + filepath=filepath, # type: ignore ) print(f"Finished processing {track}") diff --git a/interface.html b/interface.html new file mode 100644 index 0000000..10b51a6 --- /dev/null +++ b/interface.html @@ -0,0 +1,88 @@ + + + + + Music Player + + + +

Music Player

+ +
+

Player Controls

+ + + + + +
+ +
+

Volume

+ + 0 +
+ +
+

Queue

+ + +
+
+ +
+

Player State

+

+  
+ + + + + diff --git a/main.py b/main.py index e54970b..dba7306 100644 --- a/main.py +++ b/main.py @@ -1,9 +1,10 @@ from enum import Enum from fastapi import FastAPI +from fastapi.responses import HTMLResponse from download_service import DownloadService -from music_player import MusicPlayer +from music_player import MusicPlayer, PlayerState class ChangePlayerState(Enum): @@ -25,9 +26,16 @@ player = MusicPlayer() dl_service = DownloadService() +# Interface +@app.get("/", response_class=HTMLResponse) +async def root(): + with open("interface.html") as f: + return f.read() + + # Experimental @app.get("/queue", tags=["queue"]) -def play_music(): +def get_queue(): return player.get_queue() @@ -57,6 +65,11 @@ def player_stop(): player.stop() +@app.post("/player/skip", tags=["player"]) +def player_skip(): + player.next() + + # Player @app.put("/player/volume", tags=["player"]) def set_volume(volume: float): @@ -68,6 +81,6 @@ def get_volume(): return {"volume": player.get_volume()} -@app.get("/player/state", tags=["player"]) -def get_state(): - return {"state": player.get_state(), "current_track": player.get_current_track()} +@app.get("/player", tags=["player"]) +def get_player_state() -> PlayerState: + return player.get_state() diff --git a/music_player.py b/music_player.py index 94aac96..737fbf8 100644 --- a/music_player.py +++ b/music_player.py @@ -3,20 +3,26 @@ import time from enum import Enum import pygame -from pydantic import BaseModel, Field - - -class PlayerState(Enum): - Playing = "Playing" - Paused = "Paused" - Stopped = "Stopped" +from pydantic import BaseModel class Track(BaseModel): artist: str title: str 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: @@ -40,94 +46,141 @@ class Queue: class MusicPlayer: def __init__(self): + # Player State + self._state = PlayerState(volume=1) + self._state_changed: bool = False + + # Sound initialization pygame.init() pygame.mixer.init() pygame.mixer.music.set_endevent(pygame.USEREVENT) + pygame.mixer.music.set_volume(self._state.volume) + # Threading self.lock = threading.Lock() self._running = True - self.thread = threading.Thread(target=self._loop, daemon=True) - self.thread.start() - # Music Player - self.queue: Queue = Queue() - self.current_track: Track | None = None - self._state: PlayerState = PlayerState.Stopped - # State change flags + self._thread = threading.Thread(target=self._loop, daemon=True) + self._thread.start() + + # Queue + self._queue: Queue = Queue() self._queue_changed: bool = False - self._track_changed: bool = False def _loop(self): while self._running: for event in pygame.event.get(): if event.type == pygame.USEREVENT: # a song just ended - if self.current_track: - print(f"Track {self.current_track.title} ended") + self._handle_track_finished() self._play_next_track() 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): - self.current_track = track + self._set_track(track) 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): - next_track = self.queue.next() + next_track = self._queue_next() if next_track: self._load_track(next_track) - pygame.mixer.music.play() - self._state = PlayerState.Playing + self._start_playback() def add_to_queue(self, track: Track): with self.lock: - que_len = self.queue.len() - self.queue.add(track) + que_len = self._queue.len() + self._queue_add(track) # 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() def play(self): with self.lock: - if self.current_track: - pygame.mixer.music.play() - self._state = PlayerState.Playing + if self._state.playback_state == PlaybackState.Playing: + return + if self._state.track: + self._start_playback() else: self._play_next_track() def pause(self): with self.lock: - pygame.mixer.music.pause() - self._state = PlayerState.Paused + self._pause_playback() def resume(self): with self.lock: - pygame.mixer.music.unpause() - self._state = PlayerState.Playing + self._resume_playback() def stop(self): with self.lock: - pygame.mixer.music.stop() - self._state = PlayerState.Stopped - self.current_track = None + self._stop_playback() + + def next(self): + with self.lock: + self._play_next_track() def shutdown(self): with self.lock: self._running = False - self.thread.join() + self._thread.join() pygame.mixer.quit() - def get_queue(self) -> Queue: - return self.queue._queue + def get_queue(self) -> list[Track]: + 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: - pygame.mixer.music.set_volume(volume) + self._set_volume(volume) - def get_volume(self): - return pygame.mixer.music.get_volume() + def get_volume(self) -> float: + return self._state.volume def get_state(self) -> PlayerState: return self._state def get_current_track(self) -> Track | None: - return self.current_track + return self._state.track