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
+203 -81

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
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
+105 -79
View File
@@ -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()
+82
View File
@@ -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;
}
+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