diff --git a/.attachments/player.png b/.attachments/player.png index 08f7512..1af698f 100644 Binary files a/.attachments/player.png and b/.attachments/player.png differ diff --git a/.attachments/queue.png b/.attachments/queue.png new file mode 100644 index 0000000..b166576 Binary files /dev/null and b/.attachments/queue.png differ diff --git a/README.md b/README.md index ff04739..95b2c2f 100644 --- a/README.md +++ b/README.md @@ -18,9 +18,13 @@ This program requires a file simply named `auth` that contains your username and # 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 diff --git a/app.py b/app.py index fcfc654..3ce44e3 100644 --- a/app.py +++ b/app.py @@ -34,10 +34,10 @@ from textual import on, events, work from textual.binding import Binding from textual.reactive import reactive 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.containers import Horizontal, Vertical, Container -from textual.screen import Screen +from textual.containers import Horizontal, Vertical, Container, Center +from textual.screen import Screen, ModalScreen from textual.widgets.option_list import Option from textual.timer import Timer @@ -89,7 +89,6 @@ class AlbumSongs(Screen): song_index = self._find_selected_song() app.play_song(self.songs[song_index]) app.song_queue = self.songs[song_index + 1:] - app.update_queue_table() def action_queue(self) -> None: app.queue_song(self._find_selected_song()) @@ -110,7 +109,6 @@ class AlbumSongs(Screen): random.shuffle(q) app.play_song(q.pop(0)) app.song_queue = q - app.update_queue_table() class ArtistAlbums(Screen): BINDINGS = [ @@ -686,7 +684,6 @@ class PlaylistSongs(Screen): song_index = self._find_selected_song() app.play_song(self.songs[song_index]) app.song_queue = self.songs[song_index + 1:] - app.update_queue_table() def action_queue(self) -> None: song_id: int = self._find_selected_song() @@ -703,7 +700,6 @@ class PlaylistSongs(Screen): random.shuffle(q) app.play_song(q.pop(0)) app.song_queue = q - app.update_queue_table() class Favourites(Screen): BINDINGS = [ @@ -745,7 +741,6 @@ class Favourites(Screen): song_index = self._find_selected_song() app.play_song(self.songs[song_index]) app.song_queue = self.songs[song_index + 1:] - app.update_queue_table() def action_queue(self) -> None: song_index = self._find_selected_song() @@ -762,7 +757,6 @@ class Favourites(Screen): random.shuffle(q) app.play_song(q.pop(0)) app.song_queue = q - app.update_queue_table() class CoverArtPixels(Container): @@ -881,38 +875,61 @@ class QueueTable(Widget): 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 -class NowPlayingInfo(Container): - song = reactive[SubSonic.Song | None](None, layout=True) +# TODO : https://textual.textualize.io/widgets/data_table/#labeled-rows +class QueueScreen(ModalScreen): - # 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) def render(self) -> RenderResult: - if self.song: - if self.song.title and self.song.artist: + place_in_seconds = self.place // 1000 + place_min, place_sec = divmod(place_in_seconds, 60) - # Convert seconds to MM:SS - duration_min, duration_sec = divmod(self.song.duration, 60) - duration_formatted = f"{duration_min:02d}:{duration_sec:02d}" + place_min = max(0, place_min) + place_sec = max(0, place_sec) - place_in_seconds = self.place // 1000 - place_min, place_sec = divmod(place_in_seconds, 60) - 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 - return "Nothing Playing" +class TotalDuration(Static): + duration: reactive[int] = reactive(0) -class NowPlaying(Widget): - def compose(self) -> ComposeResult: - yield NowPlayingInfo(id="textinfo") - yield CoverArtPixels(id="coverart") + def render(self) -> RenderResult: + duration_min, duration_sec = divmod(self.duration, 60) + duration_formatted = f"{duration_min:02d}:{duration_sec:02d}" + + 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: - info = self.query_one(NowPlayingInfo) - info.song = song + if song.album and song.title and song.artist: + + 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: cover_art = self.query_one(CoverArtPixels) @@ -923,15 +940,6 @@ class NowPlaying(Widget): if 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: yield Header(icon="") 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("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") - 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: - app.sub_title = "Overview" + with Center(id='playerTrackTitleBox'): + 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) def screen_resumed(self, event: events.ScreenResume) -> None: - app.sub_title = "Overview" - queue_table = self.query_one(QueueTable) - queue_table.update_queue() + app.sub_title = "Player" 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 def listen_media_keys(queue: multiprocessing.Queue): @@ -998,7 +1029,7 @@ def listen_media_keys(queue: multiprocessing.Queue): pass logger.error(f'Media key listener failed: {e}') -class TextualSubpyPlayer(App): +class SubPy(App): BINDINGS = [ ("r", "rewind()", "⏪"), ("space", "play_pause_song()", "⏯"), @@ -1014,7 +1045,7 @@ class TextualSubpyPlayer(App): ] MODES = { - "main" : MainView, + "main" : Player, "artists" : ArtistsGUI, "albums" : Albums, "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.progess_timer = self.set_interval(0.3, self.update_progress, pause=False) - self.update_queue_table() 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: if self.music_command_queue.empty(): return @@ -1157,12 +1181,24 @@ class TextualSubpyPlayer(App): song_to_play = self.play_history.pop() self.play_song(song_to_play) - self.update_queue_table() def update_progress(self) -> None: - if isinstance(self.screen, MainView) and self.currently_playing: - now_playing = self.screen.query_one(NowPlaying) - now_playing.set_place(pygame.mixer.music.get_pos()) + if isinstance(self.screen, Player) and self.currently_playing: + current_place_label = self.screen.query_one(CurrentPlace) + 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: logging.debug(f'Queue\'d song "{song.title}" by {song.artist}.') @@ -1193,25 +1229,15 @@ class TextualSubpyPlayer(App): self.is_paused = False def update_now_playing(self) -> None: - if isinstance(self.screen, MainView) and self.currently_playing: - logging.debug("Updating now playing widget") - now_playing = self.screen.query_one(NowPlaying) - 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() + if isinstance(self.screen, Player) and self.currently_playing: + logging.debug("Updating CoverArtPixels widget") + self.screen.update_song(self.currently_playing) def update_user_queue(self, song: SubSonic.Song) -> None: self.user_queue.append(song) - self.update_queue_table() def update_song_queue(self, song: SubSonic.Song) -> None: self.song_queue.append(song) - self.update_queue_table() async def action_star_c_song(self) -> None: if self.currently_playing: @@ -1236,7 +1262,7 @@ if __name__ == '__main__': p = multiprocessing.Process(target=listen_media_keys, args=(q,)) p.start() - app = TextualSubpyPlayer() + app = SubPy() app.music_command_queue = q app.run() diff --git a/main.tcss b/main.tcss index dc79ffd..61fe177 100644 --- a/main.tcss +++ b/main.tcss @@ -87,3 +87,85 @@ width: 100%; 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; +} + diff --git a/roadmap.md b/roadmap.md new file mode 100644 index 0000000..0f33396 --- /dev/null +++ b/roadmap.md @@ -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