feat: add basic interface and cleanup
This commit is contained in:
parent
573429acd2
commit
c8abb8943e
4 changed files with 211 additions and 53 deletions
|
@ -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
88
interface.html
Normal 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
23
main.py
|
@ -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()
|
||||||
|
|
141
music_player.py
141
music_player.py
|
@ -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):
|
||||||
with self.lock:
|
self._state.volume = volume
|
||||||
pygame.mixer.music.set_volume(volume)
|
pygame.mixer.music.set_volume(volume)
|
||||||
|
self._state_changed = True
|
||||||
|
|
||||||
def get_volume(self):
|
def set_volume(self, volume: float) -> None:
|
||||||
return pygame.mixer.music.get_volume()
|
with self.lock:
|
||||||
|
self._set_volume(volume)
|
||||||
|
|
||||||
|
def get_volume(self) -> float:
|
||||||
|
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
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue