Merge pull request 'Main player UI redo' (#2) from player-ui-redo into main
Reviewed-on: #2
This commit is contained in:
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 |
@@ -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
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
|
Song Queue
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
Artists Page
|
Artists Page
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
place_in_seconds = self.place // 1000
|
||||||
if self.song.title and self.song.artist:
|
place_min, place_sec = divmod(place_in_seconds, 60)
|
||||||
|
|
||||||
# Convert seconds to MM:SS
|
place_min = max(0, place_min)
|
||||||
duration_min, duration_sec = divmod(self.song.duration, 60)
|
place_sec = max(0, place_sec)
|
||||||
duration_formatted = f"{duration_min:02d}:{duration_sec:02d}"
|
|
||||||
|
|
||||||
place_in_seconds = self.place // 1000
|
place_formatted = f"{place_min:02d}:{place_sec:02d}"
|
||||||
place_min, place_sec = divmod(place_in_seconds, 60)
|
|
||||||
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()
|
||||||
|
|||||||
@@ -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
@@ -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
|
||||||
Reference in new issue
Block a user