diff --git a/textual1/app.py b/textual1/app.py index 796b38a..eb39930 100644 --- a/textual1/app.py +++ b/textual1/app.py @@ -25,6 +25,7 @@ BITRATE = int(BITRATE) if BITRATE else BITRATE os.makedirs(".cache/art/", exist_ok=True) os.makedirs(".cache/audio/", exist_ok=True) +os.makedirs(".cache/art/artists/", exist_ok=True) logging.basicConfig(filename=LOG_FILE, level=LOGGING_LEVEL, format=LOG_FORMAT) @@ -39,7 +40,7 @@ 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 +from textual.widgets import Footer, Header, OptionList, DataTable, ProgressBar, Label, Input, Placeholder, Static, Rule from textual.widget import Widget from textual.containers import Horizontal, Vertical, Container from textual.screen import Screen @@ -47,7 +48,9 @@ from textual.widgets.option_list import Option from textual.timer import Timer from rich_pixels import Pixels -from PIL import Image +from PIL import Image, ImageFile + +ImageFile.LOAD_TRUNCATED_IMAGES = True u, p = [s.strip() for s in open('auth', 'r').read().split(',')] @@ -149,6 +152,90 @@ class ArtistAlbums(Screen): app.pop_screen() app.uninstall_screen("artistAlbums") +class ArtistInfoPanel(Widget): + + artist: SubSonic.Artist + + def compose(self) -> ComposeResult: + with Horizontal(): + yield ArtistArt(classes="artistInfoArt") + yield Placeholder(classes="artistInfoTextbox") + yield Rule() + + def set_artist(self, artist: SubSonic.Artist) -> None: + self.artist = artist + + artist_art = self.query_one(ArtistArt) + artist_art.get_artist_image(self.artist.id) + +class AlbumPanel(Widget, can_focus=True, can_focus_children=True): + + album: SubSonic.Album + + def compose(self) -> ComposeResult: + yield CoverArtPixels(classes="albumArt") + yield Label(classes="albumName") + + def set_album(self, album: SubSonic.Album) -> None: + self.album = album + + coverArt = self.query_one(CoverArtPixels) + coverArt.get_cover_art(self.album.id) + + label = self.query_one(Label) + label.update(self.album.album) + +class ArtistAlbums2(Screen): + BINDINGS = [ + ('escape', 'die()', "Back") + ] + + artist: SubSonic.Artist + albums: list[SubSonic.Album] + + def __init__(self, artist: SubSonic.Artist): + self.artist = artist + super().__init__() + + def compose(self) -> ComposeResult: + with Horizontal(id="headerbs"): + yield Label(Text("F1 ", style="bold orange").append_text(Text("Player", style="white")), classes="headerb") + yield Label(Text("* F2 ", style="bold orange").append_text(Text("Artists", 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("F5 ", style="bold orange").append_text(Text("Playlists", style="white")), classes="headerb") + yield ArtistInfoPanel(classes="artistInfo") + with Horizontal(): + yield AlbumPanel(classes="albumPanel") + yield AlbumPanel(classes="albumPanel") + yield AlbumPanel(classes="albumPanel") + yield AlbumPanel(classes="albumPanel") + with Horizontal(): + yield AlbumPanel(classes="albumPanel") + yield AlbumPanel(classes="albumPanel") + yield AlbumPanel(classes="albumPanel") + yield AlbumPanel(classes="albumPanel") + yield Footer() + + def on_mount(self) -> None: + self.albums = conn.getArtistAlbums(self.artist.id) + + artist_info_panel = self.query_one(ArtistInfoPanel) + artist_info_panel.set_artist(self.artist) + + albums_displayed = 0 + album_panels = self.query(AlbumPanel) + for album_panel in album_panels: + album_panel.set_album(self.albums[albums_displayed]) + albums_displayed += 1 + + if albums_displayed >= len(self.albums) - 1: + return + + def action_die(self) -> None: + app.pop_screen() + app.uninstall_screen("artistAlbums") + class Artists(Screen): artists: list[SubSonic.Artist] @@ -190,6 +277,126 @@ class Artists(Screen): if search_value in artist.name.lower(): artist_list.add_option(Option(artist.name, artist.id)) +class ArtistLabel(Label, can_focus=True): + + def render(self) -> RenderResult: + if self.has_focus: + return Text(str(self.content), style="white on blue") + else: + return self.content + +class ArtistArt(Static): + + art = reactive[Image.Image | None](None) + + def render(self) -> RenderResult: + if self.art: + width, height = self.size + logging.debug(f'{width=}, {height=}') + + height *= 2 + + if width > height: + width = height + elif height > width: + height = width + + return Pixels.from_image(self.art, resize=(width, height)) + + return '' + + @work(exclusive=True, thread=True) + def get_artist_image(self, artist_id: str) -> None: + artist_id = 'ar-' + artist_id + + image = None + cache_path = f".cache/art/artists/{artist_id}.png" + + if CACHE and os.path.exists(cache_path): + try: + self.art = Image.open(cache_path) + except Exception as e: + logging.error(f"Failed to load cached artist art {cache_path}: {e}") + + if image is None: + try: + artist_art_data = conn.getCoverArtRaw(artist_id, size=300) + image = Image.open(io.BytesIO(artist_art_data)) + if CACHE: + try: + image.save(cache_path, 'PNG') + except Exception as e: + logging.error(f"Failed to save artist art to cache {cache_path}: {e}") + self.art = image + except Exception as e: + logging.error(f"Failed to get artist art: {e}") + self.art = None + +class ArtistPanel(Widget, can_focus=True, can_focus_children=True): + + artist: SubSonic.Artist + + def compose(self) -> ComposeResult: + yield ArtistArt(classes="artistArt") + yield ArtistLabel(classes="artistName") + + def set_artist(self, artist: SubSonic.Artist) -> None: + self.artist = artist + + art: ArtistArt = self.query_one(ArtistArt) + art.get_artist_image(self.artist.id) + + label = self.query_one(Label) + label.update(self.artist.name) + + @on(events.Click) + def clicked(self, event: events.Click) -> None: + logging.debug(f'ArtistPanel {self.artist.name} was clicked.') + app.install_screen(ArtistAlbums2(self.artist), 'artistAlbums') + app.push_screen('artistAlbums') + +class Artists2(Screen): + + artists: list[SubSonic.Artist] + + def compose(self) -> ComposeResult: + yield Header(icon='') + with Horizontal(id="headerbs"): + yield Label(Text("F1 ", style="bold orange").append_text(Text("Player", style="white")), classes="headerb") + yield Label(Text("* F2 ", style="bold orange").append_text(Text("Artists", 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("F5 ", style="bold orange").append_text(Text("Playlists", style="white")), classes="headerb") + with Horizontal(): + yield ArtistPanel(classes="artistPanel") + yield ArtistPanel(classes="artistPanel") + yield ArtistPanel(classes="artistPanel") + yield ArtistPanel(classes="artistPanel") + yield ArtistPanel(classes="artistPanel") + with Horizontal(): + yield ArtistPanel(classes="artistPanel") + yield ArtistPanel(classes="artistPanel") + yield ArtistPanel(classes="artistPanel") + yield ArtistPanel(classes="artistPanel") + yield ArtistPanel(classes="artistPanel") + with Horizontal(): + yield ArtistPanel(classes="artistPanel") + yield ArtistPanel(classes="artistPanel") + yield ArtistPanel(classes="artistPanel") + yield ArtistPanel(classes="artistPanel") + yield ArtistPanel(classes="artistPanel") + yield Footer() + + def on_mount(self) -> None: + app.sub_title = 'Artists2' + self.artists = conn.getArtists() + + artists_displayed = 0 + artist_panels = self.query(ArtistPanel) + for artist_panel in artist_panels: + artist_panel.set_artist(self.artists[artists_displayed]) + artists_displayed += 1 + class Albums(Screen): albums: list[SubSonic.Album] @@ -405,7 +612,7 @@ class CoverArtPixels(Container): if self.cover: width, height = self.size - height += 100 + height *= 2 if width > height: width = height @@ -440,7 +647,6 @@ class CoverArtPixels(Container): except Exception as e: logging.error(f"Failed to get cover art: {e}") self.cover = None - return class QueueTable(Widget): @@ -610,7 +816,7 @@ class TextualSubpyPlayer(App): MODES = { "main" : MainView, - "artists" : Artists, + "artists" : Artists2, "albums" : Albums, "favourites" : Favourites, "playlists" : Playlists diff --git a/textual1/main.tcss b/textual1/main.tcss index 4f4ca65..682f96b 100644 --- a/textual1/main.tcss +++ b/textual1/main.tcss @@ -34,3 +34,51 @@ #headerbs { height: 1; } + +.artistPanel { + width: 20%; + height: 100%; + padding: 0 1; +} + +.artistArt { + height: 1fr; + content-align: center middle; +} + +.artistName { + width: 100%; + text-align: center; +} + +.artistInfo { + width: 1fr; + height: 1.25fr; +} + +.artistInfoArt { + width: 1fr; + height: 100%; + content-align: center middle; +} + +.artistInfoTextbox { + width: 4fr; +} + +.albumPanel { + width: 1fr; + height: 1fr; + padding: 0 1; +} + +.albumArt { + height: 1fr; + content-align: center middle; +} + +.albumName { + text-wrap: wrap; + text-align: center; + width: 100%; +}