New SongView, UV & More (#6)
Honestly, I don't remember everything I've done in this branch. Important things: - Started using UV for dependency / python version management - Fixed the stupid queue skipping bug (still can't believe it was a race condition) - New SongView widget that multiple screens share - New features on most views with songs! - Songs can be shared - Songs can be added to playlists - Songs can be rated - Upgraded my Navidrome server version & had to change some stuff for it Co-authored-by: Jackson Hodge <jackson.d.hodge@jbhunt.com> Reviewed-on: #6
This commit is contained in:
1 parent
d3de6ee914
commit
2295af7b56
10 files changed
+763
-201
No files matched your search
@@ -29,6 +29,8 @@ os.environ['PYGAME_HIDE_SUPPORT_PROMPT'] = "hide"
|
||||
import pygame
|
||||
import pynput
|
||||
|
||||
from widgets import SongTableColumnConfig, SongTable
|
||||
|
||||
from rich.text import Text
|
||||
|
||||
from textual import on, events, work
|
||||
@@ -47,13 +49,8 @@ from PIL import Image, ImageFile
|
||||
|
||||
ImageFile.LOAD_TRUNCATED_IMAGES = True
|
||||
|
||||
user, passw = [s.strip() for s in open('auth', 'r').read().split(',')]
|
||||
|
||||
MUSIC_SERVER_URL = 'https://music.0x01fe.net'
|
||||
|
||||
conn = SubSonic.SubpyConn(user, passw, MUSIC_SERVER_URL)
|
||||
|
||||
|
||||
pygame.mixer.init()
|
||||
|
||||
SONG_LIKED_CHAR = '♥'
|
||||
@@ -64,62 +61,23 @@ THREE_BARS_CHAR = '≡'
|
||||
|
||||
class AlbumSongs(Screen):
|
||||
BINDINGS = [
|
||||
('escape', 'die()', "Back"),
|
||||
('p', 'play()', 'Play Song'),
|
||||
('q' 'queue()', 'Queue Song'),
|
||||
('m', 'shuffle_mix()', 'Shuffle Play')
|
||||
('escape', 'die()', "Back")
|
||||
]
|
||||
|
||||
selected_song_id: str | None = None
|
||||
songs: list[SubSonic.Song]
|
||||
|
||||
def __init__(self, album_id: str):
|
||||
self.album_id = album_id
|
||||
super().__init__()
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield DataTable(id="albumSongs")
|
||||
songs = conn.getAlbumSongs(self.album_id)
|
||||
yield SongTable(SongTableColumnConfig(track_number=True, album=False), songs, id="albumSongs")
|
||||
yield Footer()
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self.songs = conn.getAlbumSongs(self.album_id)
|
||||
songsTable: DataTable = self.query_one(DataTable)
|
||||
|
||||
songsTable.cursor_type = 'row'
|
||||
songsTable.add_columns("#", "Title", "Artist", "Duration", "Bitrate")
|
||||
|
||||
for song in self.songs:
|
||||
songsTable.add_row(song.trackNumber, song.title, song.artist, song.duration, song.bitRate, key=song.id)
|
||||
|
||||
@on(DataTable.RowHighlighted)
|
||||
def row_highlighted(self, event: DataTable.RowHighlighted) -> None:
|
||||
self.selected_song_id = event.row_key.value
|
||||
|
||||
def action_play(self) -> None:
|
||||
song_index = self._find_selected_song()
|
||||
app.play_song(self.songs[song_index])
|
||||
app.song_queue = self.songs[song_index + 1:]
|
||||
|
||||
def action_queue(self) -> None:
|
||||
app.queue_song(self._find_selected_song())
|
||||
|
||||
# Returns index of selected song
|
||||
def _find_selected_song(self) -> int:
|
||||
for i, song in enumerate(self.songs):
|
||||
if song.id == self.selected_song_id:
|
||||
return i
|
||||
|
||||
# Pops and uninstalls self
|
||||
def action_die(self) -> None:
|
||||
app.pop_screen()
|
||||
app.uninstall_screen("albumSongs")
|
||||
|
||||
def action_shuffle_mix(self) -> None:
|
||||
q = self.songs.copy()
|
||||
random.shuffle(q)
|
||||
app.play_song(q.pop(0))
|
||||
app.song_queue = q
|
||||
|
||||
class ArtistAlbums(Screen):
|
||||
BINDINGS = [
|
||||
('escape', 'die()', "Back")
|
||||
@@ -315,7 +273,7 @@ class Artists(Screen):
|
||||
yield Footer()
|
||||
|
||||
def on_mount(self) -> None:
|
||||
app.sub_title = 'Artists'
|
||||
self.app.sub_title = 'Artists'
|
||||
self.artists = conn.getArtists()
|
||||
artistList = self.query_one(OptionList)
|
||||
|
||||
@@ -377,7 +335,8 @@ class ArtistArt(Static):
|
||||
|
||||
if CACHE and os.path.exists(cache_path):
|
||||
try:
|
||||
self.art = Image.open(cache_path)
|
||||
iamge = Image.open(cache_path)
|
||||
self.art = image
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to load cached artist art {cache_path}: {e}")
|
||||
|
||||
@@ -674,7 +633,7 @@ class Albums(Screen):
|
||||
dt.clear()
|
||||
|
||||
for album in albums[:space]:
|
||||
dt.add_row(album.title, album.artist, album.songCount, album.playCount, album.duration, key=album.id)
|
||||
dt.add_row(album.title, album.artist, album.songCount, album.playCount, album.duration_f, key=album.id)
|
||||
|
||||
self.last_mode = self.mode
|
||||
|
||||
@@ -703,11 +662,35 @@ class Albums(Screen):
|
||||
|
||||
self._update_datatable(force_update=True)
|
||||
|
||||
class PlaylistNameForm(ModalScreen[str]):
|
||||
"""Screen for the creation of a playlist, merely gets the name"""
|
||||
|
||||
BINDINGS = [
|
||||
('escape', 'die', 'Back')
|
||||
]
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
with Container(id='playlistFormModal'):
|
||||
yield Label("Please enter your playlist name:")
|
||||
yield Input()
|
||||
with Container(id='PSbutton'):
|
||||
yield Button("Enter")
|
||||
|
||||
@on(Button.Pressed)
|
||||
def submit(self, event: Button.Pressed) -> None:
|
||||
playlist_name = self.query_one(Input).value
|
||||
self.dismiss(playlist_name)
|
||||
|
||||
def action_die(self) -> None:
|
||||
app.pop_screen()
|
||||
|
||||
class Playlists(Screen):
|
||||
|
||||
playlists: list[SubSonic.Playlist] | None = None
|
||||
selected_playlist_id: str = None
|
||||
BINDINGS = [
|
||||
('C', 'create_playlist()', 'Create Playlist')
|
||||
]
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Header(icon="")
|
||||
@@ -729,7 +712,13 @@ class Playlists(Screen):
|
||||
playlistTable.add_columns("Name", "Songs", "Time", "Owner")
|
||||
|
||||
for playlist in self.playlists:
|
||||
playlistTable.add_row(playlist.name, playlist.songCount, playlist.duration, playlist.owner, key=playlist.id)
|
||||
playlistTable.add_row(
|
||||
playlist.name,
|
||||
playlist.songCount,
|
||||
playlist.duration,
|
||||
playlist.owner,
|
||||
key=playlist.id
|
||||
)
|
||||
|
||||
@on(events.ScreenResume)
|
||||
def on_screen_resume(self, event: events.ScreenResume) -> None:
|
||||
@@ -744,6 +733,23 @@ class Playlists(Screen):
|
||||
app.install_screen(PlaylistSongs(self._find_selected_playlist()), "playlistSongs")
|
||||
app.push_screen('playlistSongs')
|
||||
|
||||
# Needs to popup a modal text box form?
|
||||
def action_create_playlist(self) -> None:
|
||||
|
||||
def create_playlist_api(playlist_name: str) -> None:
|
||||
new_playlist = conn.createPlaylist(playlist_name)
|
||||
playlistTable = self.query_one(DataTable)
|
||||
playlistTable.add_row(
|
||||
new_playlist.name,
|
||||
new_playlist.songCount,
|
||||
new_playlist.duration,
|
||||
new_playlist.owner,
|
||||
key=new_playlist.id
|
||||
)
|
||||
self.playlists.append(new_playlist)
|
||||
|
||||
app.push_screen(PlaylistNameForm(), create_playlist_api)
|
||||
|
||||
def _find_selected_playlist(self) -> SubSonic.Playlist:
|
||||
for playlist in self.playlists:
|
||||
if playlist.id == self.selected_playlist_id:
|
||||
@@ -751,68 +757,29 @@ class Playlists(Screen):
|
||||
|
||||
class PlaylistSongs(Screen):
|
||||
BINDINGS = [
|
||||
('escape', 'die()', "Back"),
|
||||
('p', 'play()', 'Play Song'),
|
||||
('q', 'queue()', 'Queue Song'),
|
||||
('m', 'shuffle_mix()', 'Shuffle Play')
|
||||
('escape', 'die()', "Back")
|
||||
]
|
||||
|
||||
selected_song_id: str | None = None
|
||||
songs: list[SubSonic.Song]
|
||||
|
||||
def __init__(self, playlist: SubSonic.Playlist):
|
||||
self.playlist = playlist
|
||||
self.songs = conn.getPlaylist(playlist.id)
|
||||
super().__init__()
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
w = self.app.screen.size.width
|
||||
self.sub_title = self.playlist.name
|
||||
songs = conn.getPlaylist(self.playlist.id)
|
||||
yield Header(icon="")
|
||||
yield DataTable()
|
||||
yield SongTable(songs=songs, max_col_width=(w // 3) - 5)
|
||||
yield Footer()
|
||||
|
||||
def on_mount(self) -> None:
|
||||
songTable: DataTable = self.query_one(DataTable)
|
||||
|
||||
songTable.cursor_type = "row"
|
||||
songTable.add_columns("Title", "Album", "Artist", "Plays", "Duration", "Bitrate")
|
||||
|
||||
for i, song in enumerate(self.songs):
|
||||
songTable.add_row(song.title, song.album, song.artist, song.playCount, song.duration, song.bitRate, key=str(i))
|
||||
|
||||
# Pops and uninstalls self
|
||||
def action_die(self) -> None:
|
||||
app.pop_screen()
|
||||
app.uninstall_screen("playlistSongs")
|
||||
|
||||
@on(DataTable.RowHighlighted)
|
||||
def row_highlighted(self, event: DataTable.RowHighlighted) -> None:
|
||||
self.selected_song_id = self.songs[int(event.row_key.value)].id
|
||||
|
||||
def action_play(self) -> None:
|
||||
song_index = self._find_selected_song()
|
||||
app.play_song(self.songs[song_index])
|
||||
app.song_queue = self.songs[song_index + 1:]
|
||||
|
||||
def action_queue(self) -> None:
|
||||
song_id: int = self._find_selected_song()
|
||||
app.queue_song(self.songs[song_id])
|
||||
|
||||
# Returns index of selected song
|
||||
def _find_selected_song(self) -> int:
|
||||
for i, song in enumerate(self.songs):
|
||||
if song.id == self.selected_song_id:
|
||||
return i
|
||||
|
||||
def action_shuffle_mix(self) -> None:
|
||||
q = self.songs.copy()
|
||||
random.shuffle(q)
|
||||
app.play_song(q.pop(0))
|
||||
app.song_queue = q
|
||||
|
||||
class SongsScreen(Screen):
|
||||
BINDINGS = [
|
||||
("p", "play_song()", "Play Song"),
|
||||
("p", "play()", "Play Song"),
|
||||
("q", "queue_song()", "Queue Song"),
|
||||
("m", "shuffle_mix()", 'Shuffle Play'),
|
||||
('a', "page_backward()", "Prev. Page"),
|
||||
@@ -865,6 +832,21 @@ class SongsScreen(Screen):
|
||||
def on_screen_resume(self, event: events.ScreenResume) -> None:
|
||||
app.sub_title = "Songs"
|
||||
|
||||
@on(DataTable.RowHighlighted)
|
||||
def row_highlighted(self, event: DataTable.RowHighlighted) -> None:
|
||||
self.selected_song_id = event.row_key.value
|
||||
|
||||
def _find_selected_song(self) -> int | None:
|
||||
for i, song in enumerate(self.songs):
|
||||
if song.id == self.selected_song_id:
|
||||
return i
|
||||
return None
|
||||
|
||||
def action_play(self) -> None:
|
||||
song_index = self._find_selected_song()
|
||||
app.play_song(self.songs[song_index])
|
||||
app.song_queue = self.songs[song_index + 1:]
|
||||
|
||||
def _get_offset(self) -> int:
|
||||
return (self.page[self.mode]) * 30
|
||||
|
||||
@@ -907,7 +889,7 @@ class SongsScreen(Screen):
|
||||
dt.clear()
|
||||
|
||||
for song in self.songs[offset:offset + space]:
|
||||
dt.add_row(song.title, song.album, song.artist, song.playCount, song.duration)
|
||||
dt.add_row(song.title, song.album, song.artist, song.playCount, song.duration, key=song.id)
|
||||
|
||||
def update_all(self, dt: DataTable) -> None:
|
||||
space = 30
|
||||
@@ -920,7 +902,7 @@ class SongsScreen(Screen):
|
||||
dt.add_columns("Title", "Album", "Artist", "Plays", "Duration")
|
||||
|
||||
for song in self.songs:
|
||||
dt.add_row(song.title, song.album, song.artist, song.playCount, song.duration)
|
||||
dt.add_row(song.title, song.album, song.artist, song.playCount, song.duration, key=song.id)
|
||||
|
||||
class CoverArtPixels(Container):
|
||||
|
||||
@@ -948,7 +930,8 @@ class CoverArtPixels(Container):
|
||||
|
||||
if CACHE and os.path.exists(cache_path):
|
||||
try:
|
||||
self.cover = Image.open(cache_path)
|
||||
image = Image.open(cache_path)
|
||||
self.cover = image
|
||||
except Exception as e:
|
||||
logging.error(f"Failed to load cached cover art {cache_path}: {e}")
|
||||
|
||||
@@ -1263,12 +1246,14 @@ class SubPy(App):
|
||||
"playlists" : Playlists
|
||||
}
|
||||
|
||||
conn: SubSonic.SubpyConn
|
||||
currently_playing: SubSonic.Song | None = reactive(SubSonic.Song())
|
||||
is_playing: bool = False
|
||||
song_queue: list[SubSonic.Song] = reactive([])
|
||||
user_queue: list[SubSonic.Song] = reactive([])
|
||||
play_history: list[SubSonic.Song] = []
|
||||
is_paused: bool = False
|
||||
is_loading: bool = False
|
||||
place: int = 0
|
||||
|
||||
navidrome: Navidrome.NavidromeAPI
|
||||
@@ -1278,6 +1263,8 @@ class SubPy(App):
|
||||
check_playing_timer: Timer | None = None
|
||||
check_music_commands: Timer
|
||||
|
||||
_prefetch_started_for: str | None = None
|
||||
|
||||
"""
|
||||
Modes:
|
||||
0: standard queue, is done when queue is done
|
||||
@@ -1286,6 +1273,10 @@ class SubPy(App):
|
||||
"""
|
||||
queue_mode: int = 0
|
||||
|
||||
def __init__(self, conn: SubSonic.SubpyConn):
|
||||
self.conn = conn
|
||||
super().__init__()
|
||||
|
||||
def on_mount(self) -> None:
|
||||
self.title = "SubPy"
|
||||
self.switch_mode('main')
|
||||
@@ -1294,9 +1285,35 @@ class SubPy(App):
|
||||
|
||||
self.navidrome = Navidrome.NavidromeAPI(user, passw, MUSIC_SERVER_URL)
|
||||
|
||||
@work(exclusive=False, thread=True)
|
||||
def _prefetch_next_song(self, song: SubSonic.Song) -> None:
|
||||
audio_cache_path = f'.cache/audio/{song.id}.ogg'
|
||||
|
||||
if os.path.exists(audio_cache_path):
|
||||
logging.debug(f'Prefetch: "{song.title}" already cached.')
|
||||
return
|
||||
|
||||
logging.debug(f'Prefetch: downloading "{song.title}" by {song.artist}...')
|
||||
try:
|
||||
if BITRATE:
|
||||
r = self.conn.stream(song.id, maxBitRate=BITRATE)
|
||||
else:
|
||||
r = self.conn.stream(song.id)
|
||||
|
||||
audio_content: bytes = r.content
|
||||
|
||||
if CACHE:
|
||||
self._cache_audio(audio_cache_path, audio_content)
|
||||
|
||||
logging.debug(f'Prefetch: "{song.title}" cached successfully.')
|
||||
except Exception as e:
|
||||
logging.error(f'Prefetch failed for "{song.title}": {e}')
|
||||
|
||||
@work(exclusive=True)
|
||||
async def play_song(self, song: SubSonic.Song) -> None:
|
||||
self.is_loading = True
|
||||
self.currently_playing = song
|
||||
self._prefetch_started_for = None
|
||||
self.is_paused = False
|
||||
self.is_playing = True
|
||||
logging.debug(f"Playing song {song.title} by {song.artist}.")
|
||||
@@ -1329,6 +1346,8 @@ class SubPy(App):
|
||||
pygame.mixer.music.load(audio_data)
|
||||
pygame.mixer.music.play()
|
||||
|
||||
self.is_loading = False
|
||||
|
||||
self.check_playing_timer = self.set_interval(1 / 5, self.check_done_playing, pause=False)
|
||||
self.progess_timer = self.set_interval(0.3, self.update_progress, pause=False)
|
||||
|
||||
@@ -1358,12 +1377,16 @@ class SubPy(App):
|
||||
file.write(content)
|
||||
|
||||
def check_done_playing(self) -> None:
|
||||
if self.is_loading:
|
||||
return
|
||||
|
||||
if not pygame.mixer.music.get_busy() and not self.is_paused and self.currently_playing:
|
||||
if self.check_playing_timer:
|
||||
self.check_playing_timer.stop()
|
||||
|
||||
if self.queue_mode == 2:
|
||||
self.play_song(app.currently_playing)
|
||||
|
||||
if self.check_playing_timer:
|
||||
self.check_playing_timer.stop()
|
||||
return
|
||||
|
||||
self.play_history.append(self.currently_playing)
|
||||
@@ -1386,10 +1409,9 @@ class SubPy(App):
|
||||
if next_song:
|
||||
self.play_song(next_song)
|
||||
|
||||
if self.check_playing_timer:
|
||||
self.check_playing_timer.stop()
|
||||
|
||||
def action_skip(self) -> None:
|
||||
if self.is_loading:
|
||||
return
|
||||
|
||||
if self.check_playing_timer:
|
||||
self.check_playing_timer.stop()
|
||||
@@ -1427,22 +1449,36 @@ class SubPy(App):
|
||||
self.play_song(song_to_play)
|
||||
|
||||
def update_progress(self) -> None:
|
||||
if isinstance(self.screen, Player) and self.currently_playing:
|
||||
current_place_label = self.screen.query_one(CurrentPlace)
|
||||
if self.currently_playing:
|
||||
place = pygame.mixer.music.get_pos()
|
||||
current_place_label.place = place
|
||||
|
||||
duration_label = self.screen.query_one(TotalDuration)
|
||||
duration_label.duration = self.currently_playing.duration
|
||||
|
||||
progressbar: ProgressBar = self.screen.query_one(ProgressBar)
|
||||
|
||||
progress: int = round(place/1000)
|
||||
|
||||
progressbar.update(
|
||||
total = self.currently_playing.duration,
|
||||
progress=progress
|
||||
)
|
||||
if self.currently_playing.duration > 0 and progress >= self.currently_playing.duration * 0.8:
|
||||
next_song: SubSonic.Song | None = None
|
||||
if self.user_queue:
|
||||
next_song = self.user_queue[0]
|
||||
elif self.song_queue:
|
||||
next_song = self.song_queue[0]
|
||||
elif self.queue_mode == 2:
|
||||
next_song = self.currently_playing
|
||||
|
||||
if next_song and self._prefetch_started_for != next_song.id:
|
||||
self._prefetch_started_for = next_song.id
|
||||
self._prefetch_next_song(next_song)
|
||||
|
||||
if isinstance(self.screen, Player):
|
||||
current_place_label = self.screen.query_one(CurrentPlace)
|
||||
current_place_label.place = place
|
||||
|
||||
duration_label = self.screen.query_one(TotalDuration)
|
||||
duration_label.duration = self.currently_playing.duration
|
||||
|
||||
progressbar: ProgressBar = self.screen.query_one(ProgressBar)
|
||||
|
||||
progressbar.update(
|
||||
total = self.currently_playing.duration,
|
||||
progress=progress
|
||||
)
|
||||
|
||||
def queue_song(self, song: SubSonic.Song) -> None:
|
||||
logging.debug(f'Queue\'d song "{song.title}" by {song.artist}.')
|
||||
@@ -1502,11 +1538,19 @@ if __name__ == '__main__':
|
||||
|
||||
logging.basicConfig(filename=LOG_FILE, level=LOGGING_LEVEL, format=LOG_FORMAT)
|
||||
|
||||
q = multiprocessing.Queue(-1)
|
||||
q = multiprocessing.Queue(0)
|
||||
p = multiprocessing.Process(target=listen_media_keys, args=(q,))
|
||||
p.start()
|
||||
|
||||
app = SubPy()
|
||||
user, passw = [s.strip() for s in open('auth', 'r').read().split(',')]
|
||||
conn = SubSonic.SubpyConn(
|
||||
user,
|
||||
passw,
|
||||
MUSIC_SERVER_URL,
|
||||
client_name="SubPy"
|
||||
)
|
||||
|
||||
app = SubPy(conn)
|
||||
app.music_command_queue = q
|
||||
|
||||
app.run()
|
||||
|
||||
Reference in new issue
Block a user