Merge pull request 'Main player UI redo' (#2) from player-ui-redo into main

Reviewed-on: #2
This commit is contained in:
0x01FE committed 2025-10-08 10:27:44 -05:00
commit a2e53f1bc7
6 files changed
+202 -80

No files matched your search

Binary file not shown.

Before

Width:  |  Height:  |  Size: 127 KiB

After

Width:  |  Height:  |  Size: 60 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

+6 -2
View File
@@ -18,9 +18,13 @@ This program requires a file simply named `auth` that contains your username and
# Screenshots # Screenshots
Main View with queue and cover art Player
![Main music player view](.attachments/player.png) ![Music player with media control buttons, a progress bar, and cover art](.attachments/player.png)
Song Queue
![Modal screen infront of the player with a table holding songs in the queue](.attachments/queue.png)
Artists Page Artists Page
+104 -78
View File
@@ -34,10 +34,10 @@ from textual import on, events, work
from textual.binding import Binding from textual.binding import Binding
from textual.reactive import reactive from textual.reactive import reactive
from textual.app import App, ComposeResult, RenderResult from textual.app import App, ComposeResult, RenderResult
from textual.widgets import Footer, Header, OptionList, DataTable, ProgressBar, Label, Input, Placeholder, Static, Rule from textual.widgets import Footer, Header, OptionList, DataTable, ProgressBar, Label, Input, Placeholder, Static, Rule, Button
from textual.widget import Widget from textual.widget import Widget
from textual.containers import Horizontal, Vertical, Container from textual.containers import Horizontal, Vertical, Container, Center
from textual.screen import Screen from textual.screen import Screen, ModalScreen
from textual.widgets.option_list import Option from textual.widgets.option_list import Option
from textual.timer import Timer from textual.timer import Timer
@@ -89,7 +89,6 @@ class AlbumSongs(Screen):
song_index = self._find_selected_song() song_index = self._find_selected_song()
app.play_song(self.songs[song_index]) app.play_song(self.songs[song_index])
app.song_queue = self.songs[song_index + 1:] app.song_queue = self.songs[song_index + 1:]
app.update_queue_table()
def action_queue(self) -> None: def action_queue(self) -> None:
app.queue_song(self._find_selected_song()) app.queue_song(self._find_selected_song())
@@ -110,7 +109,6 @@ class AlbumSongs(Screen):
random.shuffle(q) random.shuffle(q)
app.play_song(q.pop(0)) app.play_song(q.pop(0))
app.song_queue = q app.song_queue = q
app.update_queue_table()
class ArtistAlbums(Screen): class ArtistAlbums(Screen):
BINDINGS = [ BINDINGS = [
@@ -686,7 +684,6 @@ class PlaylistSongs(Screen):
song_index = self._find_selected_song() song_index = self._find_selected_song()
app.play_song(self.songs[song_index]) app.play_song(self.songs[song_index])
app.song_queue = self.songs[song_index + 1:] app.song_queue = self.songs[song_index + 1:]
app.update_queue_table()
def action_queue(self) -> None: def action_queue(self) -> None:
song_id: int = self._find_selected_song() song_id: int = self._find_selected_song()
@@ -703,7 +700,6 @@ class PlaylistSongs(Screen):
random.shuffle(q) random.shuffle(q)
app.play_song(q.pop(0)) app.play_song(q.pop(0))
app.song_queue = q app.song_queue = q
app.update_queue_table()
class Favourites(Screen): class Favourites(Screen):
BINDINGS = [ BINDINGS = [
@@ -745,7 +741,6 @@ class Favourites(Screen):
song_index = self._find_selected_song() song_index = self._find_selected_song()
app.play_song(self.songs[song_index]) app.play_song(self.songs[song_index])
app.song_queue = self.songs[song_index + 1:] app.song_queue = self.songs[song_index + 1:]
app.update_queue_table()
def action_queue(self) -> None: def action_queue(self) -> None:
song_index = self._find_selected_song() song_index = self._find_selected_song()
@@ -762,7 +757,6 @@ class Favourites(Screen):
random.shuffle(q) random.shuffle(q)
app.play_song(q.pop(0)) app.play_song(q.pop(0))
app.song_queue = q app.song_queue = q
app.update_queue_table()
class CoverArtPixels(Container): class CoverArtPixels(Container):
@@ -881,38 +875,61 @@ class QueueTable(Widget):
asyncio.create_task(asyncio.to_thread(conn.star, song.id)) asyncio.create_task(asyncio.to_thread(conn.star, song.id))
# Fine to be a static renderable because it's not big enough to need a scroll # TODO : https://textual.textualize.io/widgets/data_table/#labeled-rows
class NowPlayingInfo(Container): class QueueScreen(ModalScreen):
song = reactive[SubSonic.Song | None](None, layout=True)
# Place is from pygame.mixer.music.get_pos and is in ms BINDINGS = [
('escape', 'back()', 'Go Back')
]
def compose(self) -> ComposeResult:
with Container(id='queueModal'):
yield QueueTable(id='queue')
yield Footer()
def action_back(self) -> None:
app.pop_screen()
class CurrentPlace(Static):
place: reactive[int] = reactive(0) place: reactive[int] = reactive(0)
def render(self) -> RenderResult: def render(self) -> RenderResult:
if self.song:
if self.song.title and self.song.artist:
# Convert seconds to MM:SS
duration_min, duration_sec = divmod(self.song.duration, 60)
duration_formatted = f"{duration_min:02d}:{duration_sec:02d}"
place_in_seconds = self.place // 1000 place_in_seconds = self.place // 1000
place_min, place_sec = divmod(place_in_seconds, 60) place_min, place_sec = divmod(place_in_seconds, 60)
place_min = max(0, place_min)
place_sec = max(0, place_sec)
place_formatted = f"{place_min:02d}:{place_sec:02d}" place_formatted = f"{place_min:02d}:{place_sec:02d}"
s = f"Song: {self.song.title}\nAlbum: {self.song.album}\nArtist: {self.song.artist}\nProgress: {place_formatted} / {duration_formatted}\n" return place_formatted
return s class TotalDuration(Static):
return "Nothing Playing" duration: reactive[int] = reactive(0)
class NowPlaying(Widget): def render(self) -> RenderResult:
def compose(self) -> ComposeResult: duration_min, duration_sec = divmod(self.duration, 60)
yield NowPlayingInfo(id="textinfo") duration_formatted = f"{duration_min:02d}:{duration_sec:02d}"
yield CoverArtPixels(id="coverart")
return duration_formatted
class Player(Screen):
CSS_PATH = 'main.tcss'
BINDINGS = [
('q', 'view_queue()', 'View Queue')
]
def on_mount(self) -> None:
app.sub_title = "Player"
def update_song(self, song: SubSonic.Song) -> None: def update_song(self, song: SubSonic.Song) -> None:
info = self.query_one(NowPlayingInfo) if song.album and song.title and song.artist:
info.song = song
track_title_label: Label = self.query_one('#trackTitle', Label)
track_artist_album_label: Label = self.query_one('#trackAlbumArtist', Label)
track_title_label.update(song.title)
track_artist_album_label.update(f'{song.album} by {song.artist}')
if song.albumId: if song.albumId:
cover_art = self.query_one(CoverArtPixels) cover_art = self.query_one(CoverArtPixels)
@@ -923,15 +940,6 @@ class NowPlaying(Widget):
if cover_id: if cover_id:
cover_art.get_cover_art(cover_id) cover_art.get_cover_art(cover_id)
def set_place(self, place: int) -> None:
info = self.query_one(NowPlayingInfo)
info.place = place
# https://textual.textualize.io/widgets/data_table/#labeled-rows
# Add this ^ to queue sometime plz
class MainView(Screen):
CSS_PATH = 'main.tcss'
def compose(self) -> ComposeResult: def compose(self) -> ComposeResult:
yield Header(icon="") yield Header(icon="")
with Horizontal(id="headerbs"): with Horizontal(id="headerbs"):
@@ -940,25 +948,48 @@ class MainView(Screen):
yield Label(Text("F3 ", style="bold orange").append_text(Text("Albums", style="white")), classes="headerb") yield Label(Text("F3 ", style="bold orange").append_text(Text("Albums", style="white")), classes="headerb")
yield Label(Text("F4 ", style="bold orange").append_text(Text("Favourites", style="white")), classes="headerb") yield Label(Text("F4 ", style="bold orange").append_text(Text("Favourites", style="white")), classes="headerb")
yield Label(Text("F5 ", style="bold orange").append_text(Text("Playlists", style="white")), classes="headerb") yield Label(Text("F5 ", style="bold orange").append_text(Text("Playlists", style="white")), classes="headerb")
with Horizontal():
with Vertical(classes="info", id='info'):
yield NowPlaying(id="now-playing")
# yield ProgressBar()
with Vertical(classes="column"):
yield Label("Queue")
yield QueueTable(id="queue")
yield Footer()
def on_mount(self) -> None: with Center(id='playerTrackTitleBox'):
app.sub_title = "Overview" yield Label(id='trackTitle', classes='playerTrackinfo')
with Center():
yield Label(id='trackAlbumArtist', classes='playerTrackinfo')
with Container(id='playerArtc'):
yield CoverArtPixels(classes='playerArt')
with Center():
with Horizontal(id='progressBarWrapper'):
yield CurrentPlace(classes='playerProgressBarTime')
yield ProgressBar(classes='playerProgressBar')
yield TotalDuration(classes='playerProgressBarTotalTime')
with Horizontal(id='playerButtons'):
yield Button('|<', id='prev_track', classes='playerButton')
yield Button('|>/||', id='play_pause_track', classes='playerButton')
yield Button('>|', id='next_track', classes='playerButton')
yield Footer()
@on(events.ScreenResume) @on(events.ScreenResume)
def screen_resumed(self, event: events.ScreenResume) -> None: def screen_resumed(self, event: events.ScreenResume) -> None:
app.sub_title = "Overview" app.sub_title = "Player"
queue_table = self.query_one(QueueTable)
queue_table.update_queue()
app.update_now_playing() app.update_now_playing()
@on(Button.Pressed, "#prev_track")
def prev_track_pressed(self) -> None:
app.action_rewind()
@on(Button.Pressed, "#play_pause_track")
def play_pause_track_pressed(self) -> None:
app.action_play_pause_song()
@on(Button.Pressed, "#next_track")
def next_track_pressed(self) -> None:
app.action_skip()
def action_view_queue(self) -> None:
# app.install_screen(QueueScreen, 'queue')
app.push_screen(QueueScreen())
# Startup keyboard listener for media keys # Startup keyboard listener for media keys
def listen_media_keys(queue: multiprocessing.Queue): def listen_media_keys(queue: multiprocessing.Queue):
@@ -998,7 +1029,7 @@ def listen_media_keys(queue: multiprocessing.Queue):
pass pass
logger.error(f'Media key listener failed: {e}') logger.error(f'Media key listener failed: {e}')
class TextualSubpyPlayer(App): class SubPy(App):
BINDINGS = [ BINDINGS = [
("r", "rewind()", "⏪"), ("r", "rewind()", "⏪"),
("space", "play_pause_song()", "⏯"), ("space", "play_pause_song()", "⏯"),
@@ -1014,7 +1045,7 @@ class TextualSubpyPlayer(App):
] ]
MODES = { MODES = {
"main" : MainView, "main" : Player,
"artists" : ArtistsGUI, "artists" : ArtistsGUI,
"albums" : Albums, "albums" : Albums,
"favourites" : Favourites, "favourites" : Favourites,
@@ -1078,15 +1109,8 @@ class TextualSubpyPlayer(App):
self.check_playing_timer = self.set_interval(1 / 5, self.check_done_playing, pause=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) self.progess_timer = self.set_interval(0.3, self.update_progress, pause=False)
self.update_queue_table()
self.update_now_playing() self.update_now_playing()
# playingLabel: Label = self.query_one(Label)
# playingLabel.
# Set callback loop for progress bar
# self.progress_timer = self.set_interval(1 / 10, self.update_progress, pause=False)
def _music_player_loop(self) -> None: def _music_player_loop(self) -> None:
if self.music_command_queue.empty(): if self.music_command_queue.empty():
return return
@@ -1157,12 +1181,24 @@ class TextualSubpyPlayer(App):
song_to_play = self.play_history.pop() song_to_play = self.play_history.pop()
self.play_song(song_to_play) self.play_song(song_to_play)
self.update_queue_table()
def update_progress(self) -> None: def update_progress(self) -> None:
if isinstance(self.screen, MainView) and self.currently_playing: if isinstance(self.screen, Player) and self.currently_playing:
now_playing = self.screen.query_one(NowPlaying) current_place_label = self.screen.query_one(CurrentPlace)
now_playing.set_place(pygame.mixer.music.get_pos()) 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
)
def queue_song(self, song: SubSonic.Song) -> None: def queue_song(self, song: SubSonic.Song) -> None:
logging.debug(f'Queue\'d song "{song.title}" by {song.artist}.') logging.debug(f'Queue\'d song "{song.title}" by {song.artist}.')
@@ -1193,25 +1229,15 @@ class TextualSubpyPlayer(App):
self.is_paused = False self.is_paused = False
def update_now_playing(self) -> None: def update_now_playing(self) -> None:
if isinstance(self.screen, MainView) and self.currently_playing: if isinstance(self.screen, Player) and self.currently_playing:
logging.debug("Updating now playing widget") logging.debug("Updating CoverArtPixels widget")
now_playing = self.screen.query_one(NowPlaying) self.screen.update_song(self.currently_playing)
now_playing.update_song(self.currently_playing)
def update_queue_table(self) -> None:
if isinstance(self.screen, MainView):
logging.debug("Updating queue through app")
queue_table = self.screen.query_one(QueueTable)
queue_table.update_queue()
def update_user_queue(self, song: SubSonic.Song) -> None: def update_user_queue(self, song: SubSonic.Song) -> None:
self.user_queue.append(song) self.user_queue.append(song)
self.update_queue_table()
def update_song_queue(self, song: SubSonic.Song) -> None: def update_song_queue(self, song: SubSonic.Song) -> None:
self.song_queue.append(song) self.song_queue.append(song)
self.update_queue_table()
async def action_star_c_song(self) -> None: async def action_star_c_song(self) -> None:
if self.currently_playing: if self.currently_playing:
@@ -1236,7 +1262,7 @@ if __name__ == '__main__':
p = multiprocessing.Process(target=listen_media_keys, args=(q,)) p = multiprocessing.Process(target=listen_media_keys, args=(q,))
p.start() p.start()
app = TextualSubpyPlayer() app = SubPy()
app.music_command_queue = q app.music_command_queue = q
app.run() app.run()
+82
View File
@@ -87,3 +87,85 @@
width: 100%; width: 100%;
text-align: center; text-align: center;
} }
# Player Screen
#playerTrackTitleBox {
padding: 1 0 0 0;
}
#playerArtc {
align: center middle;
# width: 1fr;
}
.playerTrackinfo {
# text-align: center;
# content-align: center middle;
height: 1;
width: auto;
}
.playerArt {
content-align: center middle;
height: 1fr;
width: 0.5fr;
padding: 1 0 0 0;
}
#progressBarWrapper {
height: 3;
width: 0.75fr;
padding: 1 0 0 0;
}
.playerProgressBarTime {
width: 0.1fr;
text-align: center;
}
.playerProgressBarTotalTime {
width: 0.1fr;
text-align: center;
}
#percentage {
visibility: hidden;
}
#eta {
visibility: hidden;
}
.playerProgressBar {
height: 3;
width: 0.75fr;
content-align: center middle;
}
.playerProgressBar #bar {
width: auto;
}
#playerButtons {
height: 0.25fr;
padding-bottom: 1;
align: center middle;
}
.playerButton {
height: 5;
max-width: 12;
max-height: 12;
}
QueueScreen {
align: center middle;
}
#queueModal {
height: 75%;
width: 75%;
border: grey;
}
+10
View File
@@ -0,0 +1,10 @@
# Planned Features
- Add a songs tab (should replace F4 Favourites, favourite songs will still be there)
- A view of all songs should be a paged datatable
- Shuffle artist (shuffle play all the songs by an artist)
- better playlist UI
- Better albums UI
- Config for no fancy images UI
- Auto cache deletion (if not played in x number of days remove)
- Accurate play time tracking server (optionally) linked to player