diff --git a/src/disconnectable_iface.py b/src/disconnectable_iface.py index dcf4373..d48eb7c 100644 --- a/src/disconnectable_iface.py +++ b/src/disconnectable_iface.py @@ -18,6 +18,9 @@ # SPDX-License-Identifier: GPL-3.0-or-later +from typing import List, Tuple, Any + + class IDisconnectable: """ A class that provides automatic resource cleanup for GTK widgets and other objects. @@ -32,16 +35,14 @@ class IDisconnectable: 1. Inherit from IDisconnectable alongside your main class: >>> class MyWidget(Gtk.Box, IDisconnectable): - ... def __init__(self): - ... Gtk.Box.__init__(self) - ... IDisconnectable.__init__(self) + ... def __init__(self): + ... Gtk.Box.__init__(self) + ... IDisconnectable.__init__(self) 2. When connecting signals, store them in self.signals as a tuple of the object and the handler id: - >>> self.signals.append(( - ... some_object, - ... some_object.connect("signal-name", callback))) + >>> self.signals.append((some_object, some_object.connect("signal-name", callback))) 3. When creating bindings, store them in self.bindings: @@ -54,13 +55,35 @@ class IDisconnectable: 5. Call disconnect_all() when the widget is being destroyed: """ + def __init__(self) -> None: - self.signals = [] - self.bindings = [] - self.disconnectables = [] + self.signals: List[Tuple[Any, int]] = [] + self.bindings: List[Any] = [] + self.disconnectables: List["IDisconnectable"] = [] + + def connect_signal( + self, g_object: Any, signal_name: str, callback_func: Any, *args + ) -> None: + """Connect a signal and track it for later disconnection. + + Args: + g_object: The GObject to connect the signal to + signal_name (str): Name of the signal to connect + callback_func: The callback function to execute when signal is emitted + *args: Additional arguments to pass to the callback function + """ + self.signals.append(( + g_object, + g_object.connect(signal_name, callback_func, *args), + )) def disconnect_all(self, *_args) -> None: - """Disconnects all signals so that the class can be deleted""" + """Disconnect all tracked signals and child disconnectable objects. + + This method should be called when the widget is being removed to ensure + proper cleanup. It disconnects all tracked signal connections and + recursively calls disconnect_all on child disconnectable objects. + """ for obj, signal_id in self.signals: if obj.handler_is_connected(signal_id): @@ -85,5 +108,8 @@ def disconnect_all(self, *_args) -> None: self.bindings = [] self.disconnectables = [] + def __repr__(self, *args) -> str | None: + return self.__gtype_name__ if self.__gtype_name__ else None + # def __del__(self): # print(f"DELETING {self}") diff --git a/src/lib/__init__.py b/src/lib/__init__.py index 2ccf316..fd2c7ba 100644 --- a/src/lib/__init__.py +++ b/src/lib/__init__.py @@ -2,3 +2,4 @@ from .utils import * from .secret_storage import SecretStore from .discord_rpc import * +from .cache import HTCache diff --git a/src/lib/cache.py b/src/lib/cache.py new file mode 100644 index 0000000..b17f2d0 --- /dev/null +++ b/src/lib/cache.py @@ -0,0 +1,112 @@ +# cache.py +# +# Copyright 2025 Nokse +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +# SPDX-License-Identifier: GPL-3.0-or-later + +from typing import Dict, Any + +from tidalapi.artist import Artist +from tidalapi.album import Album +from tidalapi.media import Track +from tidalapi.playlist import Playlist +from tidalapi.mix import Mix + + +class HTCache: + artists: Dict[str, Artist] = {} + albums: Dict[str, Album] = {} + tracks: Dict[str, Track] = {} + playlists: Dict[str, Playlist] = {} + mixes: Dict[str, Mix] = {} + + def __init__(self, session: Any) -> None: + self.session = session + + def get_artist(self, artist_id: str) -> Artist: + """Get an artist from cache or fetch from TIDAL API if not cached. + + Args: + artist_id (str): The TIDAL artist ID + + Returns: + Artist: The artist object from TIDAL API + """ + if artist_id in self.artists: + return self.artists[artist_id] + artist = Artist(self.session, artist_id) + self.artists[artist_id] = artist + return artist + + def get_album(self, album_id: str) -> Album: + """Get an album from cache or fetch from TIDAL API if not cached. + + Args: + album_id (str): The TIDAL album ID + + Returns: + Album: The album object from TIDAL API + """ + if album_id in self.albums: + return self.albums[album_id] + album = Album(self.session, album_id) + self.albums[album_id] = album + return album + + def get_track(self, track_id: str) -> Track: + """Get a track from cache or fetch from TIDAL API if not cached. + + Args: + track_id (str): The TIDAL track ID + + Returns: + Track: The track object from TIDAL API + """ + if track_id in self.tracks: + return self.tracks[track_id] + track = Track(self.session, track_id) + self.tracks[track_id] = track + return track + + def get_playlist(self, playlist_id: str) -> Playlist: + """Get a playlist from cache or fetch from TIDAL API if not cached. + + Args: + playlist_id (str): The TIDAL playlist ID + + Returns: + Playlist: The playlist object from TIDAL API + """ + if playlist_id in self.playlists: + return self.playlists[playlist_id] + playlist = Playlist(self.session, playlist_id) + self.playlists[playlist_id] = playlist + return playlist + + def get_mix(self, mix_id: str) -> Mix: + """Get a mix from cache or fetch from TIDAL API if not cached. + + Args: + mix_id (str): The TIDAL mix ID + + Returns: + Mix: The mix object from TIDAL API + """ + if mix_id in self.mixes: + return self.mixes[mix_id] + mix = Mix(self.session, mix_id) + self.mixes[mix_id] = mix + return mix diff --git a/src/lib/discord_rpc.py b/src/lib/discord_rpc.py index dcfee97..2aa73b0 100644 --- a/src/lib/discord_rpc.py +++ b/src/lib/discord_rpc.py @@ -25,7 +25,15 @@ class State(Enum): disconnect_thread: threading.Thread | None = None -def connect(): +def connect() -> bool: + """Connect to Discord Rich Presence IPC. + + Attempts to establish a connection to Discord's IPC server for + Rich Presence functionality. + + Returns: + bool: True if connection successful, False otherwise + """ global state if not has_pypresence: @@ -45,7 +53,14 @@ def connect(): return True -def disconnect(): +def disconnect() -> bool: + """Disconnect from Discord Rich Presence IPC. + + Closes the connection to Discord's IPC server and updates the state. + + Returns: + bool: True if disconnection successful, False otherwise + """ global state if not has_pypresence: @@ -62,7 +77,15 @@ def disconnect(): return True -def set_activity(track: Track | None = None, offset_ms: int = 0): +def set_activity(track: Track | None = None, offset_ms: int = 0) -> None: + """Set the Discord Rich Presence activity status. + + Updates Discord with the current playing track information and playback position. + + Args: + track: The currently playing Track object, or None to clear activity + offset_ms: Current playback position in milliseconds (default: 0) + """ global state global disconnect_thread @@ -90,7 +113,7 @@ def set_activity(track: Track | None = None, offset_ms: int = 0): ) state = State.IDLE - def disconnect_function(): + def disconnect_function() -> None: for _ in range(5 * 60): time.sleep(1) if state != State.IDLE: diff --git a/src/lib/player_object.py b/src/lib/player_object.py index dff77ef..47bd451 100644 --- a/src/lib/player_object.py +++ b/src/lib/player_object.py @@ -21,6 +21,7 @@ import threading from enum import IntEnum from pathlib import Path +from typing import List, Union, Any from tidalapi.mix import Mix from tidalapi.artist import Artist @@ -68,8 +69,11 @@ class PlayerObject(GObject.GObject): } def __init__( - self, preferred_sink=AudioSink.AUTO, normalize=False, quadratic_volume=False - ): + self, + preferred_sink: AudioSink = AudioSink.AUTO, + normalize: bool = False, + quadratic_volume: bool = False, + ) -> None: GObject.GObject.__init__(self) Gst.init(None) @@ -107,36 +111,36 @@ def __init__( self._playing = False self._repeat_type = RepeatType.NONE - self.id_list = [] + self.id_list: List[str] = [] - self.queue = [] - self.current_mix_album_playlist = None - self._tracks_to_play = [] - self.tracks_to_play = [] - self._shuffled_tracks_to_play = [] - self.played_songs = [] + self.queue: List[Track] = [] + self.current_mix_album_playlist: Union[Mix, Album, Playlist] | None = None + self._tracks_to_play: List[Track] = [] + self.tracks_to_play: List[Track] = [] + self._shuffled_tracks_to_play: List[Track] = [] + self.played_songs: List[Track] = [] self.playing_track: Track | None = None - self.song_album = None + self.song_album: Album | None = None self.duration = self.query_duration() - self.manifest = None - self.stream = None - self.update_timer = None + self.manifest: Any | None = None + self.stream: Any | None = None + self.update_timer: Any | None = None @GObject.Property(type=bool, default=False) - def playing(self): + def playing(self) -> bool: return self._playing @playing.setter - def playing(self, _playing): + def playing(self, _playing: bool) -> None: self._playing = _playing self.notify("playing") @GObject.Property(type=bool, default=False) - def shuffle(self): + def shuffle(self) -> bool: return self._shuffle @shuffle.setter - def shuffle(self, _shuffle): + def shuffle(self, _shuffle: bool) -> None: if self._shuffle == _shuffle: return @@ -146,15 +150,15 @@ def shuffle(self, _shuffle): self.emit("song-changed") @GObject.Property(type=int, default=0) - def repeat_type(self): + def repeat_type(self) -> RepeatType: return self._repeat_type @repeat_type.setter - def repeat_type(self, _repeat_type): + def repeat_type(self, _repeat_type: RepeatType) -> None: self._repeat_type = _repeat_type self.notify("repeat-type") - def _setup_audio_sink(self, sink_type): + def _setup_audio_sink(self, sink_type: AudioSink) -> None: """Configure the audio sink using parse_launch for simplicity.""" sink_map = { AudioSink.AUTO: "autoaudiosink", @@ -193,11 +197,15 @@ def _setup_audio_sink(self, sink_type): "audio-sink", Gst.ElementFactory.make("autoaudiosink", None) ) - def change_audio_sink(self, sink_type): - """Change the audio sink while maintaining playback state.""" - was_playing = self.playing - position = self.query_position() - duration = self.query_duration() + def change_audio_sink(self, sink_type: AudioSink) -> None: + """Change the audio sink while maintaining playback state. + + Args: + sink_type (int): The audio sink `AudioSink` enum + """ + was_playing: bool = self.playing + position: int = self.query_position() + duration: int = self.query_duration() self.pipeline.set_state(Gst.State.NULL) self._setup_audio_sink(sink_type) @@ -206,26 +214,33 @@ def change_audio_sink(self, sink_type): self.pipeline.set_state(Gst.State.PLAYING) self.seek(position / duration) - def _on_bus_eos(self, *args): + def _on_bus_eos(self, *args) -> None: """Handle end of stream.""" GLib.idle_add(self.play_next) - def _on_bus_error(self, bus, message): + def _on_bus_error(self, bus: Any, message: Any) -> None: """Handle pipeline errors.""" err, debug = message.parse_error() print(f"Error: {err.message}") print(f"Debug info: {debug}") - def _on_buffering_message(self, bus, message): - buffer_per = message.parse_buffering() + def _on_buffering_message(self, bus: Any, message: Any) -> None: + buffer_per: int = message.parse_buffering() mode, avg_in, avg_out, buff_left = message.parse_buffering_stats() self.emit("buffering", buffer_per) - def play_this(self, thing, index=0): - """Play tracks from a mix, album, playlist, or artist.""" + def play_this( + self, thing: Union[Mix, Album, Playlist, List[Track], Track], index: int = 0 + ) -> None: + """Play tracks from a mix, album, playlist, or artist. + + Args: + thing: An object (Mix, Album, Playlist, Artist, or list of Tracks) to play + index (int): The index of the track to start playing (default: 0) + """ self.current_mix_album_playlist = thing - tracks = self.get_track_list(thing) + tracks: List[Track] = self.get_track_list(thing) if not tracks: print("No tracks found to play") @@ -235,7 +250,7 @@ def play_this(self, thing, index=0): if not self._tracks_to_play: return - track = self._tracks_to_play.pop(0) + track: Track = self._tracks_to_play.pop(0) self.tracks_to_play = self._tracks_to_play self.played_songs = [] @@ -246,15 +261,30 @@ def play_this(self, thing, index=0): self.play() self.emit("song-changed") - def shuffle_this(self, thing): - """Same as play_this, but on shuffle""" - tracks = self.get_track_list(thing) + def shuffle_this( + self, thing: Union[Mix, Album, Playlist, List[Track], Track] + ) -> None: + """Same as play_this, but enables shuffle mode. + + Args: + thing: An object (Mix, Album, Playlist, Artist, or list of Tracks) to play + """ + tracks: List[Track] = self.get_track_list(thing) self.play_this(thing, random.randint(0, len(tracks))) self.shuffle = True - def get_track_list(self, thing): - """Convert various sources into a list of tracks.""" - tracks_list = None + def get_track_list( + self, thing: Union[Mix, Album, Playlist, Artist, List[Track], Track] + ) -> List[Track]: + """Convert various sources into a list of tracks. + + Args: + thing: A TIDAL object (Mix, Album, Playlist, Artist, or list of Tracks) + + Returns: + list: List of Track objects, or None if conversion failed + """ + tracks_list: List[Track] | None = None if isinstance(thing, Mix): tracks_list = thing.items() @@ -273,8 +303,8 @@ def get_track_list(self, thing): return tracks_list - def play(self): - """Start playback.""" + def play(self) -> None: + """Start playback of the current track.""" self.playing = True self.pipeline.set_state(Gst.State.PLAYING) if self.update_timer: @@ -286,26 +316,30 @@ def play(self): self.playing_track, self.query_position() / 1_000_000 ) - def pause(self): - """Pause playback.""" + def pause(self) -> None: + """Pause playback of the current track.""" self.playing = False self.pipeline.set_state(Gst.State.PAUSED) if self.discord_rpc_enabled: discord_rpc.set_activity() - def play_pause(self): + def play_pause(self) -> None: """Toggle between play and pause states.""" if self.playing: self.pause() else: self.play() - def play_track(self, track): - """Play a specific track.""" + def play_track(self, track: Track) -> None: + """Play a specific track immediately. + + Args: + track: The Track object to play + """ threading.Thread(target=self._play_track_thread, args=(track,)).start() - def _play_track_thread(self, track): + def _play_track_thread(self, track: Track) -> None: """Thread for loading and playing a track.""" self.stream = None @@ -340,6 +374,7 @@ def _play_track_thread(self, track): print(f"Error getting track URL: {e}") def apply_replaygain_tags(self): + """Apply ReplayGain normalization tags to the current track if enabled.""" audio_sink = self.playbin.get_property("audio-sink") if audio_sink: @@ -379,7 +414,7 @@ def _play_track_url(self, track, music_url): self.emit("song-changed") def play_next(self): - """Play the next track.""" + """Play the next track in the queue or playlist.""" if self._repeat_type == RepeatType.SONG: self.seek(0) self.apply_replaygain_tags() @@ -413,7 +448,7 @@ def play_next(self): self.play_track(track) def play_previous(self): - """Play the previous track.""" + """Play the previous track or restart current track if near beginning.""" # if not in the first 2 seconds of the track restart song if self.query_position() > 2 * Gst.SECOND: self.seek(0) @@ -437,14 +472,29 @@ def _update_shuffle_queue(self): self.tracks_to_play = self._tracks_to_play def add_to_queue(self, track): + """Add a track to the end of the play queue. + + Args: + track: The Track object to add to the queue + """ self.queue.append(track) self.emit("song-added-to-queue") def add_next(self, track): + """Add a track to the top of the queue. + + Args: + track: The Track object to play next + """ self.queue.insert(0, track) self.emit("song-added-to-queue") def query_volume(self): + """Get the current playback volume. + + Returns: + float: Current volume level (0.0 to 1.0), adjusted for quadratic scaling if enabled + """ volume = self.playbin.get_property("volume") if self.quadratic_volume: return volume ** (1 / 2) @@ -452,6 +502,11 @@ def query_volume(self): return volume def change_volume(self, value): + """Set the playback volume. + + Args: + value (float): Volume level (0.0 to 1.0), will be squared if quadratic volume is enabled + """ if self.quadratic_volume: self.playbin.set_property("volume", value**2) else: @@ -469,17 +524,32 @@ def _update_slider_callback(self): return self.playing def query_duration(self): - """Get the duration of the current track.""" + """Get the duration of the current track. + + Returns: + int: Duration in nanoseconds, or 0 if query failed + """ success, duration = self.playbin.query_duration(Gst.Format.TIME) return duration if success else 0 def query_position(self, default=0) -> int | None: - """Get the current playback position.""" + """Get the current playback position. + + Args: + default (int): Default value to return if query fails (default: 0) + + Returns: + int: Position in nanoseconds, or default value if query failed + """ success, position = self.playbin.query_position(Gst.Format.TIME) return position if success else default def seek(self, seek_fraction): - """Seek to a position in the current track.""" + """Seek to a position in the current track. + + Args: + seek_fraction (float): Position as a fraction of total duration (0.0 to 1.0) + """ position = int(seek_fraction * self.query_duration()) self.playbin.seek_simple( @@ -490,6 +560,11 @@ def seek(self, seek_fraction): discord_rpc.set_activity(self.playing_track, position / 1_000_000) def set_discord_rpc(self, enabled: bool = True): + """Enable or disable Discord Rich Presence integration. + + Args: + enabled (bool): Whether to enable Discord RPC (default: True) + """ self.discord_rpc_enabled = enabled if enabled and self.playing: discord_rpc.set_activity( @@ -501,6 +576,11 @@ def set_discord_rpc(self, enabled: bool = True): discord_rpc.disconnect() def get_index(self): + """Get the index of the currently playing track in the playlist. + + Returns: + int: Index of current track, or 0 if not found + """ for index, track_id in enumerate(self.id_list): if track_id == self.playing_track.id: return index diff --git a/src/lib/secret_storage.py b/src/lib/secret_storage.py index 8b8311c..1917b76 100644 --- a/src/lib/secret_storage.py +++ b/src/lib/secret_storage.py @@ -18,34 +18,40 @@ # SPDX-License-Identifier: LGPL-3.0-or-later from gi.repository import Secret, Xdp +from typing import Dict, Any, Tuple import json +import tidalapi class SecretStore: - def __init__(self, _session): + def __init__(self, session: tidalapi.Session) -> None: super().__init__() print("initializing secret store") self.version = "0.0" - self.session = _session + self.session: tidalapi.Session = session - self.token_dictionary = {} - self.attributes = {"version": Secret.SchemaAttributeType.STRING} + self.token_dictionary: Dict[str, str] = {} + self.attributes: Dict[str, Secret.SchemaAttributeType] = { + "version": Secret.SchemaAttributeType.STRING + } self.schema = Secret.Schema.new( "io.github.nokse22.high-tide", Secret.SchemaFlags.NONE, self.attributes ) - self.key = "high-tide-login" - + self.key: str = "high-tide-login" + # Ensure the Login keyring is unlocked (https://github.com/Nokse22/high-tide/issues/97) # This is also only possible outside of a flatpak. if not Xdp.Portal.running_under_flatpak(): service = Secret.Service.get_sync(Secret.ServiceFlags.NONE) if service: - collection = Secret.Collection.for_alias_sync(service, Secret.COLLECTION_DEFAULT, Secret.CollectionFlags.NONE) + collection = Secret.Collection.for_alias_sync( + service, Secret.COLLECTION_DEFAULT, Secret.CollectionFlags.NONE + ) if collection and collection.get_locked(): print("Collection is locked, attempting to unlock") service.unlock_sync([collection]) @@ -61,25 +67,39 @@ def __init__(self, _session): self.token_dictionary = {} - def get(self): + def get(self) -> Tuple[str, str, str]: + """Get the stored authentication tokens. + + Returns: + tuple: A tuple containing (token_type, access_token, refresh_token) + """ return ( self.token_dictionary["token-type"], self.token_dictionary["access-token"], self.token_dictionary["refresh-token"], - self.token_dictionary["expiry-time"], ) - def clear(self): + def clear(self) -> None: + """Clear all stored authentication tokens from memory and keyring. + + Removes tokens from the internal dictionary and deletes them from + the system keyring/secret storage. + """ self.token_dictionary.clear() self.save() Secret.password_clear_sync(self.schema, {}, None) - def save(self): - token_type = self.session.token_type - access_token = self.session.access_token - refresh_token = self.session.refresh_token - expiry_time = self.session.expiry_time + def save(self) -> None: + """Save the current session tokens to secure storage. + + Stores the session's token_type, access_token, and refresh_token + in the system keyring for persistent authentication. + """ + token_type: str = self.session.token_type + access_token: str = self.session.access_token + refresh_token: str = self.session.refresh_token + expiry_time: Any = self.session.expiry_time self.token_dictionary = { "token-type": token_type, @@ -88,7 +108,7 @@ def save(self): "expiry-time": str(expiry_time), } - json_data = json.dumps(self.token_dictionary, indent=2) + json_data: str = json.dumps(self.token_dictionary, indent=2) Secret.password_store_sync( self.schema, {}, Secret.COLLECTION_DEFAULT, self.key, json_data, None diff --git a/src/lib/utils.py b/src/lib/utils.py index f7ededd..51ebe95 100644 --- a/src/lib/utils.py +++ b/src/lib/utils.py @@ -1,3 +1,23 @@ +# utils.py +# +# Copyright 2025 Nokse +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +# SPDX-License-Identifier: GPL-3.0-or-later + +from typing import List, Any from gi.repository import Gdk, Adw from gi.repository import GLib from gi.repository import Gio @@ -15,6 +35,8 @@ from ..pages import HTMixPage from ..pages import HTPlaylistPage +from .cache import HTCache + import threading import requests import uuid @@ -25,16 +47,21 @@ from pathlib import Path -favourite_mixes = [] -favourite_tracks = [] -favourite_artists = [] -favourite_albums = [] -favourite_playlists = [] -playlist_and_favorite_playlists = [] -user_playlists = [] +favourite_mixes: List[Mix] = [] +favourite_tracks: List[Track] = [] +favourite_artists: List[Artist] = [] +favourite_albums: List[Album] = [] +favourite_playlists: List[Playlist] = [] +playlist_and_favorite_playlists: List[Playlist] = [] +user_playlists: List[Playlist] = [] + +def init() -> None: + """Initialize the utils module by setting up cache directories and global objects. -def init(): + Sets up the cache directory structure, creates necessary directories, + and initializes the global cache object for TIDAL API responses. + """ global CACHE_DIR CACHE_DIR = os.environ.get("XDG_CACHE_HOME") if CACHE_DIR == "" or CACHE_DIR is None or "high-tide" not in CACHE_DIR: @@ -49,9 +76,82 @@ def init(): global navigation_view global player_object global toast_overlay + global cache + session = None + cache = HTCache(session) + + +def get_artist(artist_id: str) -> Artist: + """Get an artist object by ID from the cache. + + Args: + artist_id: The TIDAL artist ID + + Returns: + Artist: The artist object from TIDAL API + """ + global cache + return cache.get_artist(artist_id) + + +def get_album(album_id: str) -> Album: + """Get an album object by ID from the cache. + + Args: + album_id: The TIDAL album ID + + Returns: + Album: The album object from TIDAL API + """ + global cache + return cache.get_album(album_id) + + +def get_track(track_id: str) -> Track: + """Get a track object by ID from the cache. + + Args: + track_id: The TIDAL track ID + + Returns: + Track: The track object from TIDAL API + """ + global cache + return cache.get_track(track_id) + +def get_playlist(playlist_id: str) -> Playlist: + """Get a playlist object by ID from the cache. -def get_favourites(): + Args: + playlist_id: The TIDAL playlist ID + + Returns: + Playlist: The playlist object from TIDAL API + """ + global cache + return cache.get_playlist(playlist_id) + + +def get_mix(mix_id: str) -> Mix: + """Get a mix object by ID from the cache. + + Args: + mix_id: The TIDAL mix ID + + Returns: + Mix: The mix object from TIDAL API + """ + global cache + return cache.get_mix(mix_id) + + +def get_favourites() -> None: + """Load all user favorites from TIDAL API and cache them globally. + + Retrieves and caches the user's favorite mixes, tracks, artists, albums, + playlists, and user-created playlists for quick access throughout the app. + """ global favourite_mixes global favourite_tracks global favourite_artists @@ -82,7 +182,15 @@ def get_favourites(): print(f"User Playlists: {len(user_playlists)}") -def is_favourited(item): +def is_favourited(item: Any) -> bool: + """Check if a TIDAL item is in the user's favorites. + + Args: + item: A TIDAL object (Track, Mix, Album, Artist, or Playlist) + + Returns: + bool: True if the item is favorited, False otherwise + """ global favourite_mixes global favourite_tracks global favourite_artists @@ -93,44 +201,54 @@ def is_favourited(item): for fav in favourite_tracks: if fav.id == item.id: return True - elif isinstance(item, Mix): - return # still not supported - + for fav in favourite_mixes: + if fav.id == item.id: + return True elif isinstance(item, Album): for fav in favourite_albums: if fav.id == item.id: return True - elif isinstance(item, Artist): for fav in favourite_artists: if fav.id == item.id: return True - elif isinstance(item, Playlist): - for fav in favourite_artists: + for fav in favourite_playlists: if fav.id == item.id: return True return False -def send_toast(toast_title, timeout): +def send_toast(toast_title: str, timeout: int) -> None: + """Display a toast notification to the user. + + Args: + toast_title (str): The message to display in the toast + timeout (int): Duration in seconds before the toast disappears + """ toast_overlay.add_toast(Adw.Toast(title=toast_title, timeout=timeout)) -def th_add_to_my_collection(btn, item): +def th_add_to_my_collection(btn: Any, item: Any) -> None: + """Thread function to add a TIDAL item to the user's favorites. + + Args: + btn: The favorite button widget (for UI updates) + item: The TIDAL item to add to favorites + """ if isinstance(item, Track): - result = session.user.favorites.add_track(item.id) + result = session.user.favorites.add_track(str(item.id)) elif isinstance(item, Mix): return # still not supported - result = session.user.favorites.add_mix(item.id) + result = session.user.favorites.add_mix(str(item.id)) elif isinstance(item, Album): - result = session.user.favorites.add_album(item.id) + result = session.user.favorites.add_album(str(item.id)) elif isinstance(item, Artist): - result = session.user.favorites.add_artist(item.id) + result = session.user.favorites.add_artist(str(item.id)) elif isinstance(item, Playlist): - result = session.user.favorites.add_playlist(item.id) + result = session.user.favorites.add_playlist(str(item.id)) else: result = False @@ -142,18 +260,24 @@ def th_add_to_my_collection(btn, item): send_toast(_("Failed to add item to my collection"), 2) -def th_remove_from_my_collection(btn, item): +def th_remove_from_my_collection(btn: Any, item: Any) -> None: + """Thread function to remove a TIDAL item from the user's favorites. + + Args: + btn: The favorite button widget (for UI updates) + item: The TIDAL item to remove from favorites + """ if isinstance(item, Track): - result = session.user.favorites.remove_track(item.id) + result = session.user.favorites.remove_track(str(item.id)) elif isinstance(item, Mix): return # still not supported - result = session.user.favorites.remove_mix(item.id) + result = session.user.favorites.remove_mix(str(item.id)) elif isinstance(item, Album): - result = session.user.favorites.remove_album(item.id) + result = session.user.favorites.remove_album(str(item.id)) elif isinstance(item, Artist): - result = session.user.favorites.remove_artist(item.id) + result = session.user.favorites.remove_artist(str(item.id)) elif isinstance(item, Playlist): - result = session.user.favorites.remove_playlist(item.id) + result = session.user.favorites.remove_playlist(str(item.id)) else: result = False @@ -164,17 +288,28 @@ def th_remove_from_my_collection(btn, item): send_toast(_("Failed to remove item from my collection"), 2) -def on_in_to_my_collection_button_clicked(btn, item): +def on_in_to_my_collection_button_clicked(btn: Any, item: Any) -> None: + """Handle favorite/unfavorite button clicks by starting appropriate thread. + + Args: + btn: The favorite button that was clicked + item: The TIDAL item to add or remove from favorites + """ if btn.get_icon_name() == "heart-outline-thick-symbolic": threading.Thread(target=th_add_to_my_collection, args=(btn, item)).start() else: threading.Thread(target=th_remove_from_my_collection, args=(btn, item)).start() -def share_this(item): - clipboard = Gdk.Display().get_default().get_clipboard() +def share_this(item: Any) -> None: + """Copy a TIDAL item's share URL to the system clipboard. - share_url = None + Args: + item: A TIDAL object with a share_url attribute + """ + clipboard: Gdk.Clipboard = Gdk.Display().get_default().get_clipboard() + + share_url: str | None = None if isinstance(item, Track): share_url = item.share_url @@ -193,7 +328,15 @@ def share_this(item): send_toast(_("Copied share URL in the clipboard"), 2) -def get_type(item): +def get_type(item: Any) -> str: + """Get the string type identifier for a TIDAL item. + + Args: + item: A TIDAL object (Track, Mix, Album, Artist, or Playlist) + + Returns: + str: The type as a lowercase string ("track", "mix", "album", "artist", or "playlist") + """ if isinstance(item, Track): return "track" elif isinstance(item, Mix): @@ -206,21 +349,28 @@ def get_type(item): return "playlist" -def open_uri(label, uri): +def open_uri(label: str, uri: str) -> bool: + """Open a URI by navigating to the appropriate page in the application. + + Args: + label: Display label for the URI (currently unused) + uri: A URI string in format "type:id" (e.g., "artist:123456") + """ uri_parts = uri.split(":") match uri_parts[0]: case "artist": - page = HTArtistPage(uri_parts[1]).load() + page = HTArtistPage.new_from_id(uri_parts[1]).load() navigation_view.push(page) case "album": - page = HTAlbumPage(uri_parts[1]).load() + page = HTAlbumPage.new_from_id(uri_parts[1]).load() navigation_view.push(page) + # TODO implement the rest? return True -def open_tidal_uri(uri): +def open_tidal_uri(uri: str) -> None: """Handles opening uri like tidal://track/1234""" if not uri.startswith("tidal://"): @@ -239,10 +389,10 @@ def open_tidal_uri(uri): match content_type: case "artist": - page = HTArtistPage(content_id).load() + page = HTArtistPage.new_from_id(content_id).load() navigation_view.push(page) case "album": - page = HTAlbumPage(content_id).load() + page = HTAlbumPage.new_from_id(content_id).load() navigation_view.push(page) case "track": threading.Thread(target=th_play_track, args=(content_id,)).start() @@ -257,13 +407,26 @@ def open_tidal_uri(uri): return False -def th_play_track(track_id): - track = session.track(track_id) +def th_play_track(track_id: str) -> None: + """Thread function to play a specific track by ID. + + Args: + track_id: The TIDAL track ID to play + """ + track: Track = session.track(track_id) player_object.play_this([track]) -def pretty_duration(secs): +def pretty_duration(secs: int | None) -> str: + """Format a duration in seconds to a human-readable string. + + Args: + secs (int): Duration in seconds + + Returns: + str: Formatted duration string (MM:SS or HH:MM:SS for durations over an hour) + """ if not secs: return "00:00" @@ -274,12 +437,20 @@ def pretty_duration(secs): if hours > 0: return f"{int(hours)}:{int(minutes):02}:{int(seconds):02}" else: - return f"{int(minutes):2}:{int(seconds):02}" + return f"{int(minutes):02}:{int(seconds):02}" return "00:00" -def get_best_dimensions(widget): +def get_best_dimensions(widget: Any) -> int: + """Determine the best image dimensions for a widget. + + Args: + widget: A GTK widget to measure + + Returns: + int: The best image dimension from available sizes (80, 160, 320, 640, 1280) + """ edge = widget.get_height() dimensions = [80, 160, 320, 640, 1280] # The function for fractional scaling is not available in GTKWidget @@ -292,7 +463,16 @@ def get_best_dimensions(widget): return next((x for x in dimensions if x > (edge * scale)), dimensions[-1]) -def get_image_url(item, dimensions=320): +def get_image_url(item: Any, dimensions: int = 320) -> str | None: + """Get the local file path for an item's image, downloading if necessary. + + Args: + item: A TIDAL object with image data + dimensions (int): The desired image dimensions (default: 320) + + Returns: + str: Path to the local image file, or None if download failed + """ if hasattr(item, "id"): file_path = Path(f"{IMG_DIR}/{item.id}_{dimensions}.jpg") else: @@ -316,8 +496,18 @@ def get_image_url(item, dimensions=320): return str(file_path) -def add_picture(widget, item, cancellable=Gio.Cancellable.new()): - """Retrieves and adds an picture""" +def add_picture( + widget: Any, item: Any, cancellable: Gio.Cancellable = Gio.Cancellable.new() +) -> None: + """Retrieve and set an image for a widget from a TIDAL item. + + Downloads the image if necessary and sets it on the widget using set_filename(). + + Args: + widget: A GTK widget that supports set_filename() + item: A TIDAL object with image data + cancellable: Optional GCancellable for canceling the operation + """ if cancellable is None: cancellable = Gio.Cancellable.new() @@ -334,17 +524,38 @@ def _add_picture(widget, file_path, cancellable): ) -def add_image(widget, item, cancellable=Gio.Cancellable.new()): - """Retrieves and adds an image""" +def add_image( + widget: Any, item: Any, cancellable: Gio.Cancellable = Gio.Cancellable.new() +) -> None: + """Retrieve and set an image for a widget from a TIDAL item. + + Downloads the image if necessary and sets it on the widget using set_from_file(). - def _add_image(widget, file_path, cancellable): + Args: + widget: A GTK widget that supports set_from_file() + item: A TIDAL object with image data + cancellable: Optional GCancellable for canceling the operation + """ + + def _add_image( + widget: Any, file_path: str | None, cancellable: Gio.Cancellable + ) -> None: if not cancellable.is_cancelled(): widget.set_from_file(file_path) GLib.idle_add(_add_image, widget, get_image_url(item), cancellable) -def get_video_cover_url(item, dimensions=640): +def get_video_cover_url(item: Any, dimensions: int = 320) -> str | None: + """Get the local file path for an item's video cover, downloading if necessary. + + Args: + item: A TIDAL object with video data + dimensions (int): The desired video dimensions (default: 640) + + Returns: + str: Path to the local video file, or None if download failed + """ if hasattr(item, "id"): file_path = Path(f"{IMG_DIR}/{item.id}_{dimensions}.mp4") else: @@ -369,19 +580,39 @@ def get_video_cover_url(item, dimensions=640): def add_video_cover( - widget, videoplayer, item, in_background, cancellable=Gio.Cancellable.new() -): - """Retrieves and adds an video""" + widget: Any, + videoplayer: Any, + item: Any, + in_bg: bool, + cancellable: Gio.Cancellable = Gio.Cancellable.new(), +) -> None: + """Retrieve and set a video cover for a video player widget from a TIDAL item. + + Downloads the video if necessary and configures the video player. + + Args: + widget: The container widget + videoplayer: The GtkMediaFile + item: A TIDAL object with video data + in_bg (bool): Whether the window is currently in background (not in focus) + cancellable: Optional GCancellable for canceling the operation + """ if cancellable is None: cancellable = Gio.Cancellable.new() - def _add_video_cover(widget, videoplayer, file_path, in_background, cancellable): + def _add_video_cover( + widget: Any, + videoplayer: Any, + file_path: str | None, + in_bg: bool, + cancellable: Gio.Cancellable, + ) -> None: if not cancellable.is_cancelled() and file_path: videoplayer.set_loop(True) videoplayer.set_filename(file_path) widget.set_paintable(videoplayer) - if not in_background: + if not in_bg: videoplayer.play() GLib.idle_add( @@ -389,15 +620,25 @@ def _add_video_cover(widget, videoplayer, file_path, in_background, cancellable) widget, videoplayer, get_video_cover_url(item, get_best_dimensions(widget)), - in_background, + in_bg, cancellable, ) -def add_image_to_avatar(widget, item, cancellable=Gio.Cancellable.new()): - """Same as the previous function, but for Adwaita's avatar widgets""" +def add_image_to_avatar( + widget: Any, item: Any, cancellable: Gio.Cancellable = Gio.Cancellable.new() +) -> None: + """Retrieve and set an image for an Adwaita Avatar widget from a TIDAL item. + + Args: + widget: An Adw.Avatar widget + item: A TIDAL object with image data + cancellable: Optional GCancellable for canceling the operation + """ - def _add_image_to_avatar(avatar_widget, file_path, cancellable): + def _add_image_to_avatar( + avatar_widget: Any, file_path: str | None, cancellable: Gio.Cancellable + ) -> None: if not cancellable.is_cancelled(): file = Gio.File.new_for_path(file_path) image = Gdk.Texture.new_from_file(file) @@ -406,7 +647,18 @@ def _add_image_to_avatar(avatar_widget, file_path, cancellable): GLib.idle_add(_add_image_to_avatar, widget, get_image_url(item), cancellable) -def replace_links(text): +def replace_links(text: str) -> str: + """Replace TIDAL wimpLink tags in text with clickable HTML links. + + Converts [wimpLink artistId="123"]Artist Name[/wimpLink] format links + to proper HTML anchor tags for display in markup-enabled widgets. + + Args: + text (str): Input text containing wimpLink tags + + Returns: + str: HTML-escaped text with wimpLink tags converted to anchor tags + """ # Define regular expression pattern to match [wimpLink ...]...[/wimpLink] tags pattern = r"\[wimpLink (artistId|albumId)="(\d+)"\]([^[]+)\[\/wimpLink\]" @@ -414,10 +666,10 @@ def replace_links(text): escaped_text = html.escape(text) # Define a function to replace the matched pattern with the desired format - def replace(match): - link_type = match.group(1) - id_value = match.group(2) - label = match.group(3) + def replace(match_obj: Any) -> str: + link_type = match_obj.group(1) + id_value = match_obj.group(2) + label = match_obj.group(3) if link_type == "artistId": return f'{label}' diff --git a/src/login.py b/src/login.py index 1d7ab00..5a5432a 100644 --- a/src/login.py +++ b/src/login.py @@ -22,6 +22,10 @@ from gi.repository import GLib from gi.repository import Gdk +from typing import Any + +import tidalapi + @Gtk.Template(resource_path="/io/github/nokse22/high-tide/ui/login.ui") class LoginDialog(Adw.Dialog): @@ -30,19 +34,19 @@ class LoginDialog(Adw.Dialog): link_button = Gtk.Template.Child() code_label = Gtk.Template.Child() - def __init__(self, _win, _session): + def __init__(self, win: Any, session: tidalapi.Session) -> None: super().__init__() - self.session = _session - self.win = _win + self.session = session + self.win = win - self.code = "" + self.code: str = "" login, future = self.session.login_oauth() - uri = login.verification_uri_complete + uri: str = login.verification_uri_complete - link = f"https://{uri}" + link: str = f"https://{uri}" self.code = uri[-5:] @@ -52,7 +56,7 @@ def __init__(self, _win, _session): GLib.timeout_add(600, self.check_login) - def check_login(self): + def check_login(self) -> bool: """Check if we are logged in Returns: @@ -66,6 +70,6 @@ def check_login(self): return True @Gtk.Template.Callback("on_copy_code_button_clicked") - def on_copy_code_button_clicked(self, btn): - clipboard = Gdk.Display().get_default().get_clipboard() + def on_copy_code_button_clicked(self, btn: Gtk.Button) -> None: + clipboard: Gdk.Clipboard = Gdk.Display().get_default().get_clipboard() clipboard.set(self.code) diff --git a/src/main.py b/src/main.py index 2cd000d..16ef12f 100644 --- a/src/main.py +++ b/src/main.py @@ -18,21 +18,24 @@ # SPDX-License-Identifier: GPL-3.0-or-later import sys +from typing import List, Any, Callable from gi.repository import Gtk, Gio, Adw from .window import HighTideWindow from .lib import utils -import threading - from gettext import gettext as _ -class TidalApplication(Adw.Application): - """The main application singleton class.""" +class HighTideApplication(Adw.Application): + """The main application singleton class. + + This class handles the main application lifecycle, manages global actions, + preferences, and provides the entry point for the High Tide TIDAL music player. + """ - def __init__(self): + def __init__(self) -> None: super().__init__( application_id="io.github.nokse22.high-tide", flags=Gio.ApplicationFlags.HANDLES_OPEN, @@ -44,43 +47,43 @@ def __init__(self): ) self.create_action("log-in", self.on_login_action) self.create_action("log-out", self.on_logout_action) - self.create_action("download", self.on_download, ["d"]) utils.init() - self.settings = Gio.Settings.new("io.github.nokse22.high-tide") + self.settings: Gio.Settings = Gio.Settings.new("io.github.nokse22.high-tide") - self.preferences = None + self.preferences: Gtk.Window | None = None - def do_open(self, files, n_files, hint): - self.win = self.props.active_window + def do_open(self, files: List[Gio.File], n_files: int, hint: str) -> None: + self.win: HighTideWindow | None = self.props.active_window if not self.win: self.do_activate() - uri = files[0].get_uri() + uri: str = files[0].get_uri() if uri: if self.win.is_logged_in: utils.open_tidal_uri(uri) else: self.win.queued_uri = uri - def on_download(self, *args): - threading.Thread(target=self.win.th_download_song).start() - - def on_login_action(self, *args): + def on_login_action(self, *args) -> None: + """Handle the login action by initiating a new login process.""" self.win.new_login() - def on_logout_action(self, *args): + def on_logout_action(self, *args) -> None: + """Handle the logout action by logging out the current user.""" self.win.logout() - def do_activate(self): - self.win = self.props.active_window + def do_activate(self) -> None: + """Activate the application by creating and presenting the main window.""" + self.win: HighTideWindow | None = self.props.active_window if not self.win: self.win = HighTideWindow(application=self) self.win.present() - def on_about_action(self, widget, *args): + def on_about_action(self, widget: Any, *args) -> None: + """Display the about dialog with application information""" about = Adw.AboutDialog( application_name="High Tide", application_icon="io.github.nokse22.high-tide", @@ -104,11 +107,11 @@ def on_about_action(self, widget, *args): about.present(self.props.active_window) - def on_preferences_action(self, widget, _): - """Callback for the app.preferences action.""" + def on_preferences_action(self, *args) -> None: + """Display the preferences window and bind settings to UI controls""" if not self.preferences: - builder = Gtk.Builder.new_from_resource( + builder: Gtk.Builder = Gtk.Builder.new_from_resource( "/io/github/nokse22/high-tide/ui/preferences.ui" ) @@ -126,7 +129,7 @@ def on_preferences_action(self, widget, _): "notify::selected", self.on_sink_changed ) - bg_row = builder.get_object("_background_row") + bg_row: Gtk.Widget = builder.get_object("_background_row") bg_row.set_active(self.settings.get_boolean("run-background")) self.settings.bind( "run-background", bg_row, "active", Gio.SettingsBindFlags.DEFAULT @@ -164,33 +167,42 @@ def on_preferences_action(self, widget, _): self.preferences.present(self.win) - def on_quality_changed(self, widget, *args): + def on_quality_changed(self, widget: Any, *args) -> None: self.win.select_quality(widget.get_selected()) - def on_sink_changed(self, widget, *args): + def on_sink_changed(self, widget: Any, *args) -> None: self.win.change_audio_sink(widget.get_selected()) - def on_normalize_changed(self, widget, *args): + def on_normalize_changed(self, widget: Any, *args) -> None: self.win.change_normalization(widget.get_active()) - def on_quadratic_volume_changed(self, widget, *args): + def on_quadratic_volume_changed(self, widget: Any, *args) -> None: self.win.change_quadratic_volume(widget.get_active()) - def on_video_covers_changed(self, widget, *args): + def on_video_covers_changed(self, widget: Any, *args) -> None: self.win.change_video_covers_enabled(widget.get_active()) - def on_discord_rpc_changed(self, widget, *args): + def on_discord_rpc_changed(self, widget: Any, *args) -> None: self.win.change_discord_rpc_enabled(widget.get_active()) - def create_action(self, name, callback, shortcuts=None): - action = Gio.SimpleAction.new(name, None) + def create_action( + self, name: str, callback: Callable, shortcuts: List[str] | None = None + ) -> None: + """Create a new application action with optional keyboard shortcuts. + + Args: + name: The action name + callback: The callback function to execute when action is triggered + shortcuts: Optional list of keyboard shortcut strings + """ + action: Gio.SimpleAction = Gio.SimpleAction.new(name, None) action.connect("activate", callback) self.add_action(action) if shortcuts: self.set_accels_for_action(f"app.{name}", shortcuts) -def main(version): +def main(version: str) -> int: """The application's entry point.""" - app = TidalApplication() + app: HighTideApplication = HighTideApplication() return app.run(sys.argv) diff --git a/src/mpris.py b/src/mpris.py index e56054e..fbcc65b 100644 --- a/src/mpris.py +++ b/src/mpris.py @@ -168,32 +168,49 @@ def __init__(self, player): self.player.connect("volume-changed", self._on_volume_changed) def Raise(self): + """Bring the High Tide application window to the foreground""" utils.window.present_with_time(Gdk.CURRENT_TIME) def Quit(self): + """Quit the High Tide application""" utils.window.quit() def Next(self): + """Skip to the next track in the playlist or queue""" self.player.play_next() def Previous(self): + """Skip to the previous track or restart the current track""" self.player.play_previous() def PlayPause(self): + """Toggle between play and pause states""" self.player.play_pause() def Play(self): + """Start or resume playback""" self.player.play() def Pause(self): + """Pause the current playback""" self.player.pause() def Stop(self): + """Stop playback (implemented as pause for TIDAL streams)""" self.player.pause() self._on_playing_changed() def Get(self, interface, property_name): + """Get the value of a specific MPRIS property. + + Args: + interface (str): The D-Bus interface name + property_name (str): The property name to retrieve + + Returns: + GLib.Variant: The property value wrapped in a GVariant + """ if property_name in [ "CanQuit", "CanRaise", @@ -222,6 +239,14 @@ def Get(self, interface, property_name): return GLib.Variant("b", False) def GetAll(self, interface): + """Get all properties for a specific MPRIS interface. + + Args: + interface (str): The D-Bus interface name + + Returns: + dict: Dictionary containing all properties and their values + """ ret = {} if interface == self.__MPRIS_IFACE: for property_name in ["CanQuit", "CanRaise", "Identity", "DesktopEntry"]: @@ -242,12 +267,28 @@ def GetAll(self, interface): return ret def Set(self, interface, property_name, new_value): + """Set the value of a specific MPRIS property. + + Args: + interface (str): The D-Bus interface name + property_name (str): The property name to set + new_value: The new value for the property + """ if property_name == "Volume": self.player.change_volume(new_value) def PropertiesChanged( self, interface_name, changed_properties, invalidated_properties ): + """Emit a PropertiesChanged signal on D-Bus. + + Notifies other applications that MPRIS properties have changed. + + Args: + interface_name (str): The interface that had properties changed + changed_properties (dict): Properties that changed with new values + invalidated_properties (list): Properties that were invalidated + """ self.__bus.emit_signal( None, self.__MPRIS_PATH, @@ -261,6 +302,11 @@ def PropertiesChanged( ) def Introspect(self): + """Return the D-Bus introspection XML for this interface. + + Returns: + str: The XML introspection data describing available methods and properties + """ return self.__doc__ def _get_status(self): diff --git a/src/new_playlist.py b/src/new_playlist.py index 41d9344..2e5e8d7 100644 --- a/src/new_playlist.py +++ b/src/new_playlist.py @@ -25,6 +25,12 @@ @Gtk.Template(resource_path="/io/github/nokse22/high-tide/ui/new_playlist.ui") class NewPlaylistWindow(Adw.Dialog): + """Dialog window for creating new playlists. + + Provides a form interface for users to enter playlist name and description. + Emits a 'create-playlist' signal when the user confirms creation. + """ + __gtype_name__ = "NewPlaylistWindow" __gsignals__ = { @@ -35,7 +41,7 @@ class NewPlaylistWindow(Adw.Dialog): playlist_description_entry = Gtk.Template.Child() create_button = Gtk.Template.Child() - def __init__(self): + def __init__(self) -> None: super().__init__() self.playlist_name_entry.connect( @@ -43,13 +49,13 @@ def __init__(self): ) @Gtk.Template.Callback("on_create_button_clicked") - def on_create_button_clicked_func(self, *args): - playlist_title = self.playlist_name_entry.get_text() - playlist_description = self.playlist_description_entry.get_text() + def on_create_button_clicked_func(self, *args) -> None: + playlist_title: str = self.playlist_name_entry.get_text() + playlist_description: str = self.playlist_description_entry.get_text() self.emit("create-playlist", playlist_title, playlist_description) - def on_title_text_inserted_func(self, *args): - playlist_title = self.playlist_name_entry.get_text() + def on_title_text_inserted_func(self, *args) -> None: + playlist_title: str = self.playlist_name_entry.get_text() print(f"!{playlist_title}!") if playlist_title != "": self.create_button.set_sensitive(True) diff --git a/src/pages/__init__.py b/src/pages/__init__.py index 26630a3..cd19a2f 100644 --- a/src/pages/__init__.py +++ b/src/pages/__init__.py @@ -1,4 +1,4 @@ -from .home_page import HTHomePage +from .generic_page import HTGenericPage from .from_function_page import HTFromFunctionPage from .explore_page import HTExplorePage from .artist_page import HTArtistPage diff --git a/src/pages/album_page.py b/src/pages/album_page.py index 410c420..aef4137 100644 --- a/src/pages/album_page.py +++ b/src/pages/album_page.py @@ -20,8 +20,6 @@ from gi.repository import Gtk from ..lib import utils from .page import Page -from tidalapi.album import Album -from ..disconnectable_iface import IDisconnectable import threading @@ -29,31 +27,29 @@ class HTAlbumPage(Page): - __gtype_name__ = "HTAlbumPage" - - """It is used to display an album""" + """A page to display an album""" - def __init__(self, _id): - IDisconnectable.__init__(self) - super().__init__() + __gtype_name__ = "HTAlbumPage" - self.id = _id + top_tracks = [] - def _th_load_page(self): - self.item = Album(utils.session, self.id) + def _load_async(self) -> None: + self.item = utils.get_album(self.id) + self.top_tracks = self.item.tracks(limit=50) + def _load_finish(self) -> None: self.set_title(self.item.name) builder = Gtk.Builder.new_from_resource( "/io/github/nokse22/high-tide/ui/pages_ui/tracks_list_template.ui" ) - page_content = builder.get_object("_main") + self.append(builder.get_object("_main")) auto_load = builder.get_object("_auto_load") auto_load.set_scrolled_window(self.scrolled_window) auto_load.set_function(self.item.tracks) - auto_load.th_load_items() + auto_load.set_items(self.top_tracks) builder.get_object("_title_label").set_label(self.item.name) builder.get_object("_first_subtitle_label").set_label( @@ -101,6 +97,3 @@ def _th_load_page(self): image = builder.get_object("_image") threading.Thread(target=utils.add_image, args=(image, self.item)).start() - - self.page_content.append(page_content) - self._page_loaded() diff --git a/src/pages/artist_page.py b/src/pages/artist_page.py index a7e9c38..9cb42ef 100644 --- a/src/pages/artist_page.py +++ b/src/pages/artist_page.py @@ -19,49 +19,37 @@ from gi.repository import Gtk +from .page import Page from ..lib import utils -from ..widgets import HTCarouselWidget -from ..widgets import HTTracksListWidget import threading -from .page import Page - -from tidalapi.artist import Artist - -from ..lib import utils -from ..disconnectable_iface import IDisconnectable - from gettext import gettext as _ class HTArtistPage(Page): - __gtype_name__ = "HTArtistPage" - - """It is used to display an artist page""" - - # TODO Add missing features: influences, appears on, credits and so on + """A page to display an artist""" - def __init__(self, _id): - IDisconnectable.__init__(self) - super().__init__() + __gtype_name__ = "HTArtistPage" - self.top_tracks = [] - self.id = _id - self.artist = None + def _load_async(self) -> None: + self.artist = utils.get_artist(self.id) - def _th_load_page(self): - self.artist = Artist(utils.session, self.id) + self.top_tracks = self.artist.get_top_tracks(limit=5) + self.albums = self.artist.get_albums(limit=10) + self.albums_ep_singles = self.artist.get_albums_ep_singles(limit=10) + self.albums_other = self.artist.get_albums_other(limit=10) + self.similar = self.artist.get_similar() + self.bio = self.artist.get_bio() + def _load_finish(self) -> None: self.set_title(self.artist.name) builder = Gtk.Builder.new_from_resource( "/io/github/nokse22/high-tide/ui/pages_ui/artist_page_template.ui" ) - page_content = builder.get_object("_main") - # top_tracks_list_box = builder.get_object("_top_tracks_list_box") - self.content_box = builder.get_object("_content_box") + self.append(builder.get_object("_main")) builder.get_object("_name_label").set_label(self.artist.name) @@ -102,105 +90,59 @@ def _th_load_page(self): builder.get_object("_first_subtitle_label").set_label(_("Artist")) - self.top_tracks = self.artist.get_top_tracks() - - tracks_list_widget = HTTracksListWidget(_("Top Tracks")) - self.disconnectables.append(tracks_list_widget) - tracks_list_widget.set_function(self.artist.get_top_tracks) - self.content_box.append(tracks_list_widget) - - self.make_content() - self.make_bio() - - self.page_content.append(page_content) - self._page_loaded() - - def make_content(self): - carousel = self.get_carousel(_("Albums")) - try: - albums = self.artist.get_albums(limit=10) - carousel.set_more_function("album", self.artist.get_albums) - except Exception as e: - print(e) - else: - if len(albums) != 0: - self.content_box.append(carousel) - carousel.set_items(albums, "album") - - carousel = self.get_carousel(_("EP & Singles")) - try: - albums = self.artist.get_albums_ep_singles(limit=10) - carousel.set_more_function("album", self.artist.get_albums_ep_singles) - except Exception as e: - print(e) - else: - if len(albums) != 0: - self.content_box.append(carousel) - carousel.set_items(albums, "album") - - carousel = self.get_carousel(_("Appears On")) - try: - albums = self.artist.get_albums_other(limit=10) - carousel.set_more_function("album", self.artist.get_albums_other) - except Exception as e: - print(e) - else: - if len(albums) != 0: - self.content_box.append(carousel) - carousel.set_items(albums, "album") - - carousel = self.get_carousel(_("Similar Artists")) - try: - artists = self.artist.get_similar() - except Exception as e: - print("could not find similar artists", e) - else: - if len(artists) != 0: - self.content_box.append(carousel) - carousel.set_items(artists, "artist") - - def make_bio(self): - try: - bio = self.artist.get_bio() - except Exception as e: - print(e) - else: - bio = utils.replace_links(bio) - label = Gtk.Label( + self.new_track_list_for( + _("Top Tracks"), self.top_tracks, self.artist.get_top_tracks + ) + + self.new_carousel_for(_("Albums"), self.albums, self.artist.get_albums) + + self.new_carousel_for( + _("EP & Singles"), self.albums_ep_singles, self.artist.get_albums_ep_singles + ) + + self.new_carousel_for( + _("Appears On"), self.albums_other, self.artist.get_albums_other + ) + + self.new_carousel_for(_("Similar Artists"), self.similar) + + if self.bio is None: + return + + bio = utils.replace_links(self.bio) + label = Gtk.Label( + wrap=True, + css_classes=[], + margin_start=12, + margin_end=12, + margin_bottom=24, + ) + label.set_markup(bio) + self.append( + Gtk.Label( wrap=True, - css_classes=[], + css_classes=["title-3"], margin_start=12, - margin_end=12, - margin_bottom=24, - ) - label.set_markup(bio) - self.content_box.append( - Gtk.Label( - wrap=True, - css_classes=["title-3"], - margin_start=12, - label=_("Bio"), - xalign=0, - margin_top=12, - margin_bottom=12, - ) + label=_("Bio"), + xalign=0, + margin_top=12, + margin_bottom=12, ) - self.content_box.append(label) - self.signals.append((label, label.connect("activate-link", utils.open_uri))) - - def on_row_selected(self, list_box, row): - index = int(row.get_name()) - utils.player_object.play_this(self.item, index) + ) + self.append(label) + self.signals.append((label, label.connect("activate-link", utils.open_uri))) - def on_play_button_clicked(self, btn): + def on_play_button_clicked(self, btn) -> None: utils.player_object.play_this(self.top_tracks, 0) - def on_shuffle_button_clicked(self, btn): + def on_shuffle_button_clicked(self, btn) -> None: utils.player_object.shuffle_this(self.top_tracks, 0) - def on_artist_radio_button_clicked(self, btn): + def on_artist_radio_button_clicked(self, btn) -> None: from .track_radio_page import HTHrackRadioPage - page = HTHrackRadioPage(self.artist, _("Radio of {}").format(self.artist.name)) + page = HTHrackRadioPage.new_from_id( + self.artist, _("Radio of {}").format(self.artist.name) + ) page.load() utils.navigation_view.push(page) diff --git a/src/pages/collection_page.py b/src/pages/collection_page.py index 330d9a3..3d3cf15 100644 --- a/src/pages/collection_page.py +++ b/src/pages/collection_page.py @@ -17,25 +17,20 @@ # # SPDX-License-Identifier: GPL-3.0-or-later -from tidalapi.artist import Artist -from tidalapi.mix import MixV2 -from tidalapi.album import Album -from tidalapi.media import Track -from tidalapi.playlist import Playlist - from .page import Page - from ..lib import utils from gettext import gettext as _ class HTCollectionPage(Page): + """A page to display the collection (the user's library)""" + __gtype_name__ = "HTCollectionPage" - """It is used for the collection""" + def _load_async(self) -> None: ... - def _th_load_page(self): + def _load_finish(self) -> None: self.set_tag("collection") self.set_title(_("Collection")) @@ -44,23 +39,3 @@ def _th_load_page(self): self.new_carousel_for(_("Albums"), utils.favourite_albums) self.new_carousel_for(_("Tracks"), utils.favourite_tracks) self.new_carousel_for(_("Artists"), utils.favourite_artists) - - self._page_loaded() - - def new_carousel_for(self, carousel_title, carousel_content): - if len(carousel_content) == 0: - return - - carousel = self.get_carousel(carousel_title) - self.page_content.append(carousel) - - if isinstance(carousel_content[0], MixV2): - carousel.set_items(carousel_content, "mix") - elif isinstance(carousel_content[0], Album): - carousel.set_items(carousel_content, "album") - elif isinstance(carousel_content[0], Artist): - carousel.set_items(carousel_content, "artist") - elif isinstance(carousel_content[0], Playlist): - carousel.set_items(carousel_content, "playlist") - elif isinstance(carousel_content[0], Track): - carousel.set_items(carousel_content, "track") diff --git a/src/pages/explore_page.py b/src/pages/explore_page.py index 9d7f5cc..93f9f22 100644 --- a/src/pages/explore_page.py +++ b/src/pages/explore_page.py @@ -19,45 +19,35 @@ from gi.repository import Gtk -from tidalapi.page import PageItem, PageLink -from tidalapi.artist import Artist -from tidalapi.album import Album -from tidalapi.mix import Mix -from tidalapi.playlist import Playlist - -from .page import Page +from . import HTGenericPage from .search_page import HTSearchPage from ..lib import utils -from ..disconnectable_iface import IDisconnectable from gettext import gettext as _ -class HTExplorePage(Page): - __gtype_name__ = "HTExplorePage" - - """It is used to display the explore page""" - - def __init__(self, _item=None, _name=None): - IDisconnectable.__init__(self) - super().__init__() +class HTExplorePage(HTGenericPage): + """A page to display the explore page""" - self.tries = 0 + __gtype_name__ = "HTExplorePage" - def _th_load_page(self): - self.set_tag("explore") - self.set_title(_("Explore")) + tries = 0 + def _load_async(self) -> None: try: - explore = utils.session.explore() + self.page = utils.session.explore() except Exception as e: print(e) self.tries += 1 if self.tries < 5: - self._th_load_page() + self._load_async() return + def _load_finish(self) -> None: + self.set_tag("explore") + self.set_title(_("Explore")) + builder = Gtk.Builder.new_from_resource( "/io/github/nokse22/high-tide/ui/search_entry.ui" ) @@ -67,54 +57,11 @@ def _th_load_page(self): search_entry.connect("activate", self.on_search_activated), )) - self.page_content.append(search_entry) - - for index, category in enumerate(explore.categories): - self._make_category(category) - - self._page_loaded() - - def _make_category(self, category): - if isinstance(category.items[0], PageLink): - carousel, flow_box_box = self.get_link_carousel( - category.title if category.title else _("More") - ) - - flow_box = Gtk.FlowBox(homogeneous=True, height_request=100) - flow_box_box.append(flow_box) - self.page_content.append(carousel) - else: - carousel, cards_box = self.get_link_carousel(category.title) - self.page_content.append(carousel) - - buttons_for_page = 0 + self.append(search_entry) - for index, item in enumerate(category.items): - if isinstance(item, PageItem): # Featured - try: - new_item = item.get() - except Exception as e: - print(e) - continue - cards_box.append(self.get_card(new_item)) - elif isinstance(item, PageLink): # Generes and moods - if buttons_for_page == 4: - flow_box = Gtk.FlowBox(homogeneous=True, height_request=100) - flow_box_box.append(flow_box) - buttons_for_page = 0 - button = self.get_page_link_card(item) - flow_box.append(button) - buttons_for_page += 1 - elif isinstance(item, Mix): # Mixes and for you - cards_box.append(self.get_card(item)) - elif isinstance(item, Album): - cards_box.append(self.get_card(item)) - elif isinstance(item, Artist): - cards_box.append(self.get_card(item)) - elif isinstance(item, Playlist): - cards_box.append(self.get_card(item)) + HTGenericPage._load_finish(self) - def on_search_activated(self, entry): + def on_search_activated(self, entry) -> None: query = entry.get_text() page = HTSearchPage(query).load() utils.navigation_view.push(page) diff --git a/src/pages/from_function_page.py b/src/pages/from_function_page.py index 7341e48..08ab844 100644 --- a/src/pages/from_function_page.py +++ b/src/pages/from_function_page.py @@ -25,11 +25,11 @@ class HTFromFunctionPage(Page): - __gtype_name__ = "HTFromFunctionPage" - """Used to display lists of albums/artists/mixes/playlists and tracks from a request function""" + __gtype_name__ = "HTFromFunctionPage" + def __init__(self, _title=""): IDisconnectable.__init__(self) super().__init__() @@ -37,21 +37,19 @@ def __init__(self, _title=""): self.set_title(_title) self.auto_load = HTAutoLoadWidget( - margin_start=12, - margin_end=12, - margin_top=12, - margin_bottom=12 + margin_start=12, margin_end=12, margin_top=12, margin_bottom=12 ) self.auto_load.set_scrolled_window(self.scrolled_window) - self.page_content.append(self.auto_load) + self.append(self.auto_load) - def _th_load_page(self): + def _load_async(self) -> None: self.auto_load.th_load_items() - self._page_loaded() - def set_function(self, function): + def _load_finish(self) -> None: ... + + def set_function(self, function) -> None: self.auto_load.set_function(function) - def set_items(self, items): + def set_items(self, items) -> None: self.auto_load.set_items(items) diff --git a/src/pages/generic_page.py b/src/pages/generic_page.py index 4c201f0..22f0a3a 100644 --- a/src/pages/generic_page.py +++ b/src/pages/generic_page.py @@ -19,74 +19,76 @@ from gi.repository import Gtk -from tidalapi.page import PageItem, PageLink, TextBlock -from tidalapi.artist import Artist -from tidalapi.mix import Mix -from tidalapi.album import Album +from tidalapi.page import TextBlock, PageLinks +from tidalapi.page import ItemList from tidalapi.media import Track -from tidalapi.playlist import Playlist from .page import Page -from ..widgets import HTTracksListWidget -from ..disconnectable_iface import IDisconnectable +from gettext import gettext as _ -class genericPage(Page): - __gtype_name__ = "genericPage" +class HTGenericPage(Page): + """A generic page that can display any TIDAL API page content. - """It is used for explore page categories page""" + This page dynamically renders content from TIDAL API page objects, + automatically creating appropriate widgets based on the content type + (tracks, carousels, shortcuts, etc.). It's used for displaying various + TIDAL pages like home, explore, genres, and search results. + """ - def __init__(self, _page_link): - IDisconnectable.__init__(self) - super().__init__() + __gtype_name__ = "HTGenericPage" - self.item = _page_link + function = None + page = None - def _th_load_page(self): - self.set_title(self.item.title) - generic_content = self.item.get() + @classmethod + def new_from_function(cls, function) -> "HTGenericPage": + """Create a new generic page instance from a function that returns page data. - for index, category in enumerate(generic_content.categories): - if isinstance(category, PageItem): - continue - items = [] + Args: + function: A callable that returns a TIDAL API page object when called + Returns: + HTGenericPage: A new instance configured with the provided function + """ + instance = cls() + + instance.function = function + + return instance + + def _load_async(self) -> None: + self.page = self.function() + + def _load_finish(self) -> None: + if self.page.title: + self.set_title(self.page.title) + else: + self.set_title("") + + for index, category in enumerate(self.page.categories): if isinstance(category.items[0], Track): - tracks_list_widget = HTTracksListWidget(category.title) - self.disconnectables.append(tracks_list_widget) - tracks_list_widget.set_tracks_list(category.items) - self.page_content.append(tracks_list_widget) + self.new_track_list_for(category.title, category.items) elif isinstance(category, TextBlock): - label = Gtk.Label( - justify=0, - xalign=0, - wrap=True, - margin_start=12, - margin_top=12, - margin_bottom=12, - margin_end=12, - label=category.text, + self.append( + Gtk.Label( + justify=0, + xalign=0, + wrap=True, + margin_start=12, + margin_top=12, + margin_bottom=12, + margin_end=12, + label=category.text, + ) + ) + elif isinstance(category, PageLinks): + self.new_link_carousel_for( + category.title if category.title else _("More"), category.items ) - self.page_content.append(label) else: - carousel = self.get_carousel(category.title) - self.page_content.append(carousel) - - for item in category.items: - if isinstance(item, PageItem): # Featured - carousel.append_card(self.get_card(item.get())) - elif isinstance(item, PageLink): # Generes and moods - items.append("\t" + item.title) - button = self.get_page_link_card(item) - carousel.append_card(button) - elif isinstance(item, Mix): # Mixes and for you - carousel.append_card(self.get_card(item)) - elif isinstance(item, Album): - carousel.append_card(self.get_card(item)) - elif isinstance(item, Artist): - carousel.append_card(self.get_card(item)) - elif isinstance(item, Playlist): - carousel.append_card(self.get_card(item)) - - self._page_loaded() + try: + self.new_carousel_for(category.title, category.items) + except Exception as e: + print(e) diff --git a/src/pages/home_page.py b/src/pages/home_page.py deleted file mode 100644 index ac1383b..0000000 --- a/src/pages/home_page.py +++ /dev/null @@ -1,71 +0,0 @@ -# home_page.py -# -# Copyright 2023 Nokse -# -# This program is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# This program is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with this program. If not, see . -# -# SPDX-License-Identifier: GPL-3.0-or-later - -from tidalapi.page import PageItem, PageLink -from tidalapi.mix import Mix -from tidalapi.artist import Artist -from tidalapi.album import Album -from tidalapi.media import Track -from tidalapi.playlist import Playlist - -from .page import Page -from ..widgets import HTTracksListWidget - -from ..lib import utils - -from gettext import gettext as _ - - -class HTHomePage(Page): - __gtype_name__ = "HTHomePage" - - def _th_load_page(self): - self.set_tag("home") - self.set_title(_("Home")) - - home = utils.session.home() - - for index, category in enumerate(home.categories): - try: - if isinstance(category.items[0], PageItem) or isinstance( - category.items[0], PageLink - ): - continue - - if isinstance(category.items[0], Track): - tracks_list_widget = HTTracksListWidget(category.title) - self.disconnectables.append(tracks_list_widget) - tracks_list_widget.set_tracks_list(category.items) - self.page_content.append(tracks_list_widget) - else: - carousel = self.get_carousel(category.title) - self.page_content.append(carousel) - - if isinstance(category.items[0], Mix): - carousel.set_items(category.items, "mix") - elif isinstance(category.items[0], Album): - carousel.set_items(category.items, "album") - elif isinstance(category.items[0], Artist): - carousel.set_items(category.items, "artist") - elif isinstance(category.items[0], Playlist): - carousel.set_items(category.items, "playlist") - except Exception as e: - print(e) - - self._page_loaded() diff --git a/src/pages/mix_page.py b/src/pages/mix_page.py index a04eb54..e07d961 100644 --- a/src/pages/mix_page.py +++ b/src/pages/mix_page.py @@ -19,41 +19,36 @@ from gi.repository import Gtk -from tidalapi.mix import MixV2, Mix - from ..lib import utils - -import threading from .page import Page -from ..lib import utils - -from ..disconnectable_iface import IDisconnectable +import threading class HTMixPage(Page): + """A page to display a mix""" + __gtype_name__ = "HTMixPage" - def __init__(self, _id): - IDisconnectable.__init__(self) - super().__init__() + tracks = None - self.id = _id + def _load_async(self) -> None: + self.item = utils.get_mix(self.id) - def _th_load_page(self): - self.item = Mix(utils.session, self.id) + self.tracks = self.item.items() + def _load_finish(self) -> None: self.set_title(self.item.title) builder = Gtk.Builder.new_from_resource( "/io/github/nokse22/high-tide/ui/pages_ui/tracks_list_template.ui" ) - page_content = builder.get_object("_main") + self.append(builder.get_object("_main")) auto_load = builder.get_object("_auto_load") auto_load.set_scrolled_window(self.scrolled_window) - auto_load.set_items(self.item.items()) + auto_load.set_items(self.tracks) builder.get_object("_title_label").set_label(self.item.title) builder.get_object("_first_subtitle_label").set_label(self.item.sub_title) @@ -73,7 +68,9 @@ def _th_load_page(self): in_my_collection_btn = builder.get_object("_in_my_collection_button") self.signals.append(( in_my_collection_btn, - in_my_collection_btn.connect("clicked", self.th_add_to_my_collection), + in_my_collection_btn.connect( + "clicked", utils.on_in_to_my_collection_button_clicked, self.item + ), )) builder.get_object("_share_button").set_visible(False) @@ -83,12 +80,3 @@ def _th_load_page(self): image = builder.get_object("_image") threading.Thread(target=utils.add_image, args=(image, self.item)).start() - - if isinstance(self.item, MixV2): - self.item = utils.session.mix(self.item.id) - - self.page_content.append(page_content) - self._page_loaded() - - def th_add_to_my_collection(self, btn): - utils.on_in_to_my_collection_button_clicked(btn, self.item) diff --git a/src/pages/not_logged_in_page.py b/src/pages/not_logged_in_page.py index da36dab..8680a6b 100644 --- a/src/pages/not_logged_in_page.py +++ b/src/pages/not_logged_in_page.py @@ -20,6 +20,7 @@ from gi.repository import Adw from gi.repository import Gtk + from .page import Page from gettext import gettext as _ @@ -27,25 +28,27 @@ class HTNotLoggedInPage(Page): __gtype_name__ = "HTNotLoggedInPage" - def _th_load_page(self): + def _load_async(self) -> None: + pass + + def _load_finish(self) -> None: self.set_title("Not Logged In") - login_button = Gtk.Button( + login_button: Gtk.Button = Gtk.Button( label=_("Login"), css_classes=["pill", "suggested-action"], action_name="app.log-in", halign=Gtk.Align.CENTER, ) - status_page = Adw.StatusPage( - title=_("Login first"), - description=_( - "To be able to use this app you need to login with your TIDAL account." - ), - icon_name="key-login-symbolic", - child=login_button, - valign=Gtk.Align.CENTER, - vexpand=True, + self.append( + Adw.StatusPage( + title=_("Login first"), + description=_( + "To be able to use this app, you need to login with your TIDAL account." + ), + icon_name="key-login-symbolic", + child=login_button, + valign=Gtk.Align.CENTER, + vexpand=True, + ) ) - - self.page_content.append(status_page) - self._page_loaded() diff --git a/src/pages/page.py b/src/pages/page.py index fc4c156..5afb92c 100644 --- a/src/pages/page.py +++ b/src/pages/page.py @@ -22,10 +22,12 @@ from gi.repository import GLib import threading +from tidalapi import Video -from ..widgets import HTGenericTrackWidget from ..widgets import HTCarouselWidget from ..widgets import HTCardWidget +from ..widgets import HTTracksListWidget +from ..widgets import HTAutoLoadWidget from ..lib import utils @@ -35,10 +37,34 @@ class Page(Adw.NavigationPage, IDisconnectable): + """Base class for all types of pages in the High Tide application. + + This class provides shared functionality for all page types, including + UI loading, content management, and common widget creation methods. + It handles the page lifecycle and provides utilities for displaying + carousels, track listings, and other common UI elements. + """ + __gtype_name__ = "Page" - """It's the base class for all types of pages, - it contains all the shared functions""" + id = None + + @classmethod + def new_from_id(cls, id): + """Create a new Page instance from the id should be used only on pages that + support it. + + Args: + id: The page id + + Returns: + Page: A new instance configured with the provided id + """ + instance = cls() + + instance.id = id + + return instance def __init__(self): IDisconnectable.__init__(self) @@ -46,8 +72,6 @@ def __init__(self): self.set_title(_("Loading...")) - self.page_content = Gtk.Box(vexpand=True, hexpand=True, orientation=1) - self.builder = Gtk.Builder.new_from_resource( "/io/github/nokse22/high-tide/ui/pages_ui/page_template.ui" ) @@ -60,49 +84,110 @@ def __init__(self): self.set_child(self.object) def load(self): - """Called when the page is created, it just starts a thread running - the actual function to load the page UI""" + """Load the page content asynchronously. + + Starts a background thread to fetch data via _load_async() and then updates + the UI via _load_finish(). + Shows a loading state until content is ready. + + Returns: + Page: Self for method chaining + """ + + def _loaded(): + self._load_finish() + self.content_stack.set_visible_child_name("content") - threading.Thread(target=self._th_load_page).start() + def _load(): + try: + self._load_async() + except Exception as e: + print(e) + return + + GLib.idle_add(_loaded) + + threading.Thread(target=_load).start() return self - def _th_load_page(self): - """Overwritten by each different page""" + def _load_async(self) -> None: + """Fetch all data for the page in a background thread. - return + This method should be overridden by subclasses to implement + their specific data loading logic. Called from a background thread. - def _page_loaded(self): - def _add_content_to_page(): - self.content_stack.set_visible_child_name("content") - self.content.append(self.page_content) + Raises: + NotImplementedError: Must be implemented by subclasses + """ + raise NotImplementedError + + def _load_finish(self) -> None: + """Update the UI with loaded data. + + This method should be overridden by subclasses to implement + their specific UI update logic. Called on the main thread after + _load_async() completes successfully. - GLib.idle_add(_add_content_to_page) + Raises: + NotImplementedError: Must be implemented by subclasses + """ + raise NotImplementedError - def get_card(self, item): + def append(self, widget) -> None: + """Append a widget to the page content. + + Automatically tracks disconnectable widgets for cleanup when the page + is removed from navigation. + + Args: + widget: The GTK widget to append to the page content + """ + if isinstance(widget, IDisconnectable): + self.disconnectables.append(widget) + self.content.append(widget) + + def get_card(self, item) -> HTCardWidget: + """Create a card widget for a TIDAL item. + + Args: + item: A TIDAL object (Track, Album, Artist, Playlist, etc.) + + Returns: + HTCardWidget: A card widget displaying the item + """ card = HTCardWidget(item) self.disconnectables.append(card) return card - def get_track_listing(self, track): - track_listing = HTGenericTrackWidget(track, False) - self.disconnectables.append(track_listing) - return track_listing - - def get_album_track_listing(self, track): - track_listing = HTGenericTrackWidget(track, True) - self.disconnectables.append(track_listing) - return track_listing + def on_play_button_clicked(self, btn) -> None: + """Handle play button clicks by starting playback of the page's item. - def on_play_button_clicked(self, btn): + Args: + btn: The play button widget that was clicked + """ utils.player_object.play_this(self.item) - def on_shuffle_button_clicked(self, btn): + def on_shuffle_button_clicked(self, btn) -> None: + """Handle shuffle button clicks by starting shuffled playback. + + Args: + btn: The shuffle button widget that was clicked + """ utils.player_object.shuffle_this(self.item) - def get_link_carousel(self, title): - """Similar to the last function but used to display links to other - pages like in the explore page to display genres...""" + def new_link_carousel_for(self, title, items) -> None: + """Create a carousel of page link buttons. + + Creates a horizontal scrollable carousel containing buttons that link + to other pages, commonly used for genre links and similar navigation. + + Args: + title (str): The title to display above the carousel + items: List of items with .title attribute and .get() method for navigation + """ + + # TODO make a separate widget for this cards_box = Gtk.Box() box = Gtk.Box( @@ -140,17 +225,30 @@ def get_link_carousel(self, title): self.signals.append(( prev_button, - prev_button.connect("clicked", self.carousel_go_prev, cards_box, 1), + prev_button.connect("clicked", self.carousel_go_prev, cards_box), )) self.signals.append(( next_button, - next_button.connect("clicked", self.carousel_go_next, cards_box, 1), + next_button.connect("clicked", self.carousel_go_next, cards_box), )) - return box, cards_box + buttons_for_page = 0 + + flow_box = Gtk.FlowBox(homogeneous=True, height_request=100) + cards_box.append(flow_box) + self.append(box) + + for index, item in enumerate(items): + if buttons_for_page == 4: + flow_box = Gtk.FlowBox(homogeneous=True, height_request=100) + cards_box.append(flow_box) + buttons_for_page = 0 + button = self.get_page_link_card(item) + flow_box.append(button) + buttons_for_page += 1 - def carousel_go_prev(self, btn, carousel, jump=2): + def carousel_go_prev(self, btn, carousel) -> None: pos = carousel.get_position() if pos + 2 >= carousel.get_n_pages(): if pos + 1 == carousel.get_n_pages(): @@ -158,11 +256,11 @@ def carousel_go_prev(self, btn, carousel, jump=2): else: next_page = carousel.get_nth_page(pos + 1) else: - next_page = carousel.get_nth_page(pos + jump) + next_page = carousel.get_nth_page(pos + 1) if next_page is not None: carousel.scroll_to(next_page, True) - def carousel_go_next(self, btn, carousel, jump=2): + def carousel_go_next(self, btn, carousel) -> None: pos = carousel.get_position() if pos - 2 < 0: if pos - 1 < 0: @@ -170,24 +268,82 @@ def carousel_go_next(self, btn, carousel, jump=2): else: next_page = carousel.get_nth_page(0) else: - next_page = carousel.get_nth_page(pos - jump) + next_page = carousel.get_nth_page(pos - 1) carousel.scroll_to(next_page, True) - def get_carousel(self, carousel_title): + def new_carousel_for(self, carousel_title, carousel_content, more_function=None): + """Create and append a carousel widget with content. + + Args: + carousel_title (str): The title to display for the carousel + carousel_content: List of items to display in the carousel + more_function: Optional function to call when "See More" is clicked + """ + if len(carousel_content) == 0 or all( + isinstance(item, Video) for item in carousel_content + ): + return + carousel = HTCarouselWidget(carousel_title) - self.disconnectables.append(carousel) - return carousel + carousel.set_items(carousel_content) - def on_playlist_button_clicked(self, btn, playlist): - utils.sidebar_list.select_row(None) + if more_function: + carousel.set_more_function(more_function) - from .playlist_page import HTPlaylistPage + self.append(carousel) - page = HTPlaylistPage(playlist, playlist.name).load() - utils.navigation_view.push(page) + def new_track_list_for(self, list_title, list_content, more_function=None): + """Create and append a track list widget with content. + + Args: + list_title (str): The title to display for the track list + list_content: List of Track objects to display + more_function: Optional function to call when "See More" is clicked + """ + if len(list_content) == 0: + return + + tracks_list_widget = HTTracksListWidget(list_title) + tracks_list_widget.set_tracks_list(list_content) + + if more_function: + tracks_list_widget.set_more_function(more_function) + + self.append(tracks_list_widget) + + def new_auto_load_for(self, list_title, list_content=None, more_function=None): + """Create an auto-loading widget that loads more content on scroll. + + Args: + list_title (str): The title for the auto-load widget + list_content: Optional initial list of items to display + more_function: Function to call to load more content + """ + if len(list_content) == 0: + return + + auto_load = HTAutoLoadWidget() + auto_load.set_scrolled_window(self.scrolled_window) + + if more_function: + auto_load.set_function(more_function) + + if list_content: + auto_load.set_items(list_content) def get_page_link_card(self, page_link): + """Create a button card for navigating to another page. + + Args: + page_link: An object with .title attribute and .get() method + + Returns: + Gtk.Button: A button configured to navigate to the linked page + """ + + # TODO make a separate widget for this + button = Gtk.Button( label=page_link.title, margin_start=12, @@ -204,7 +360,13 @@ def get_page_link_card(self, page_link): return button def on_page_link_clicked(self, btn, page_link): - from .generic_page import genericPage + """Handle page link button clicks by navigating to the linked page. + + Args: + btn: The button that was clicked + page_link: An object with a .get() method that returns page content + """ + from .generic_page import HTGenericPage - page = genericPage(page_link).load() + page = HTGenericPage.new_from_function(page_link.get).load() utils.navigation_view.push(page) diff --git a/src/pages/playlist_page.py b/src/pages/playlist_page.py index e5c0c72..05d1a72 100644 --- a/src/pages/playlist_page.py +++ b/src/pages/playlist_page.py @@ -18,41 +18,41 @@ # SPDX-License-Identifier: GPL-3.0-or-later from gi.repository import Gtk + +from .page import Page from ..lib import utils + import threading -from .page import Page -from ..disconnectable_iface import IDisconnectable -from tidalapi.playlist import Playlist + from gettext import gettext as _ class HTPlaylistPage(Page): - __gtype_name__ = "HTPlaylistPage" - """It is used to display a playlist with author, number of tracks and duration""" - def __init__(self, _id): - IDisconnectable.__init__(self) - super().__init__() + __gtype_name__ = "HTPlaylistPage" + + tracks = None - self.id = _id + def _load_async(self) -> None: + self.item = utils.get_playlist(self.id) - def _th_load_page(self): - self.item = Playlist(utils.session, self.id) + self.tracks = self.item.tracks(limit=50) + def _load_finish(self) -> None: self.set_title(self.item.name) builder = Gtk.Builder.new_from_resource( "/io/github/nokse22/high-tide/ui/pages_ui/tracks_list_template.ui" ) - page_content = builder.get_object("_main") + self.append(builder.get_object("_main")) auto_load = builder.get_object("_auto_load") auto_load.set_scrolled_window(self.scrolled_window) auto_load.set_function(self.item.tracks) - auto_load.th_load_items() + auto_load.set_items(self.tracks) play_btn = builder.get_object("_play_button") self.signals.append(( @@ -100,10 +100,3 @@ def _th_load_page(self): image = builder.get_object("_image") threading.Thread(target=utils.add_image, args=(image, self.item)).start() - - self.page_content.append(page_content) - self._page_loaded() - - def on_row_selected(self, list_box, row): - index = int(row.get_name()) - utils.player_object.play_this(self.item, index) diff --git a/src/pages/search_page.py b/src/pages/search_page.py index 9393990..f1c47f2 100644 --- a/src/pages/search_page.py +++ b/src/pages/search_page.py @@ -17,14 +17,11 @@ # # SPDX-License-Identifier: GPL-3.0-or-later -from gi.repository import Gtk - from tidalapi.artist import Artist from tidalapi.album import Album from tidalapi.media import Track from tidalapi.playlist import Playlist -from ..widgets.carousel_widget import HTCarouselWidget from ..widgets.top_hit_widget import HTTopHitWidget from .page import Page @@ -36,49 +33,27 @@ class HTSearchPage(Page): - __gtype_name__ = "HTSearchPage" - """It is used to display the search results""" + __gtype_name__ = "HTSearchPage" + def __init__(self, _search): IDisconnectable.__init__(self) super().__init__() self.search = _search - def _th_load_page(self): - results = utils.session.search( + self.results = None + + def _load_async(self) -> None: + self.results = utils.session.search( self.search, [Artist, Album, Playlist, Track], 10 ) - top_hit = results["top_hit"] - top_hit_widget = HTTopHitWidget(top_hit) - self.page_content.append(top_hit_widget) - - # Adds a carousel with artists, albums and playlists - - carousel = self.get_carousel(_("Artists")) - artists = results["artists"] - if len(artists) > 0: - self.page_content.append(carousel) - carousel.set_items(artists, "artist") - - carousel = self.get_carousel(_("Albums")) - albums = results["albums"] - if len(albums) > 0: - self.page_content.append(carousel) - carousel.set_items(albums, "album") - - carousel = self.get_carousel(_("Playlists")) - playlists = results["playlists"] - if len(playlists) > 0: - self.page_content.append(carousel) - carousel.set_items(playlists, "playlist") - - carousel = self.get_carousel(_("Tracks")) - tracks = results["tracks"] - if len(tracks) > 0: - self.page_content.append(carousel) - carousel.set_items(tracks, "track") + def _load_finish(self) -> None: + self.append(HTTopHitWidget(self.results["top_hit"])) - self._page_loaded() + self.new_carousel_for(_("Artists"), self.results["artists"]) + self.new_carousel_for(_("Albums"), self.results["albums"]) + self.new_carousel_for(_("Playlists"), self.results["playlists"]) + self.new_carousel_for(_("Tracks"), self.results["tracks"]) diff --git a/src/pages/track_radio_page.py b/src/pages/track_radio_page.py index 7a68290..04fc76d 100644 --- a/src/pages/track_radio_page.py +++ b/src/pages/track_radio_page.py @@ -26,27 +26,26 @@ class HTHrackRadioPage(Page): - __gtype_name__ = "HTHrackRadioPage" - """It is used to display a radio from a track""" - # FIXME Fix the favourite hearth (Probably impossible because tidalapi doesn't - # store a radio as a mix, but maybe possible with some ID) + __gtype_name__ = "HTHrackRadioPage" - def __init__(self, _id): - super().__init__() + radio_tracks = [] - self.id = _id - self.radio_tracks = [] + def _load_async(self) -> None: + self.item = utils.get_track(self.id) - def _th_load_page(self): - self.item = Track(utils.session, self.id) + if isinstance(self.item, Track): + self.radio_tracks = self.item.get_track_radio() + else: + self.radio_tracks = self.item.get_radio() + def _load_finish(self) -> None: builder = Gtk.Builder.new_from_resource( "/io/github/nokse22/high-tide/ui/pages_ui/tracks_list_template.ui" ) - page_content = builder.get_object("_main") + self.append(builder.get_object("_main")) auto_load = builder.get_object("_auto_load") auto_load.set_scrolled_window(self.scrolled_window) @@ -84,27 +83,19 @@ def _th_load_page(self): else: threading.Thread(target=utils.add_image, args=(image, self.item)).start() - if isinstance(self.item, Track): - self.radio_tracks = self.item.get_track_radio() - else: - self.radio_tracks = self.item.get_radio() - auto_load.set_items(self.radio_tracks) - self.page_content.append(page_content) - self._page_loaded() - - def on_row_selected(self, list_box, row): + def on_row_selected(self, list_box, row) -> None: index = int(row.get_name()) utils.player_object.play_this(self.radio_tracks, index) - def on_play_button_clicked(self, btn): + def on_play_button_clicked(self, btn) -> None: # overwritten to pass a list and not the Track or Artist # (that is the self.item for the radio page) utils.player_object.play_this(self.radio_tracks) - def on_shuffle_button_clicked(self, btn): + def on_shuffle_button_clicked(self, btn) -> None: # overwritten to pass a list and not the Track or Artist # (that is the self.item for the radio page) utils.player_object.shuffle_this(self.radio_tracks) diff --git a/src/widgets/auto_load_widget.py b/src/widgets/auto_load_widget.py index cf0b69d..53a308b 100644 --- a/src/widgets/auto_load_widget.py +++ b/src/widgets/auto_load_widget.py @@ -58,7 +58,7 @@ def __init__(self, **kwargs) -> None: self.handler_id = None self.scrolled_window = None - def set_function(self, function : callable) -> None: + def set_function(self, function: callable) -> None: """ Set the function to use to fetch new items, it needs to support limit and offset arguments @@ -68,7 +68,7 @@ def set_function(self, function : callable) -> None: """ self.function = function - def set_items(self, items : list) -> None: + def set_items(self, items: list) -> None: """ Call once to set the initial items to display. Subsequent calls are ignored @@ -84,12 +84,15 @@ def set_items(self, items : list) -> None: if self.type is None: self.type = utils.get_type(self.items[0]) - if self.type == "track": - GLib.idle_add(self._add_tracks, self.items) - elif self.type is not None: - GLib.idle_add(self._add_cards, self.items) + def _add(): + if self.type == "track": + self._add_tracks(self.items) + elif self.type is not None: + self._add_cards(self.items) - self.items_n = len(self.items) + self.items_n = len(self.items) + + GLib.idle_add(_add) def set_scrolled_window(self, scrolled_window) -> None: """ @@ -111,7 +114,7 @@ def th_load_items(self) -> None: self.is_loading = True self.spinner.set_visible(True) new_items = [] - new_items = self.function(limit=self.items_limit, offset=(self.items_n)) + new_items = self.function(limit=self.items_limit, offset=self.items_n) self.items.extend(new_items) if new_items == []: GObject.signal_handler_disconnect(self.scrolled_window, self.handler_id) @@ -120,12 +123,17 @@ def th_load_items(self) -> None: elif self.type is None: self.type = utils.get_type(new_items[0]) - if self.type == "track": - GLib.idle_add(self._add_tracks, new_items) - elif self.type is not None: - GLib.idle_add(self._add_cards, new_items) + def _add(): + if self.type == "track": + self._add_tracks(new_items) + elif self.type is not None: + self._add_cards(new_items) + + self.items_n += len(new_items) + self.spinner.set_visible(False) + self.is_loading = False - self.spinner.set_visible(False) + GLib.idle_add(_add) def _on_edge_reached(self, scrolled_window, pos): GObject.signal_handler_block(self.scrolled_window, self.handler_id) @@ -143,15 +151,11 @@ def _add_tracks(self, new_items): )) for index, track in enumerate(new_items): - listing = HTGenericTrackWidget(track, False) + listing = HTGenericTrackWidget(track) self.disconnectables.append(listing) - listing.set_name(str(index + self.items_n)) + listing.index = index + self.items_n self.parent.append(listing) - self.items_n += len(new_items) - self.spinner.set_visible(False) - self.is_loading = False - def _add_cards(self, new_items): if self.parent is None: self.parent = Gtk.FlowBox(selection_mode=0) @@ -159,13 +163,8 @@ def _add_cards(self, new_items): for index, item in enumerate(new_items): card = HTCardWidget(item) + self.disconnectables.append(card) self.parent.append(card) - self.items_n += len(new_items) - self.spinner.set_visible(False) - self.is_loading = False - def _on_tracks_row_selected(self, list_box, row): - index = int(row.get_name()) - - utils.player_object.play_this(self.items, index) + utils.player_object.play_this(self.items, row.index) diff --git a/src/widgets/card_widget.py b/src/widgets/card_widget.py index 4fc81e0..9052a94 100644 --- a/src/widgets/card_widget.py +++ b/src/widgets/card_widget.py @@ -21,6 +21,7 @@ from gi.repository import Gtk, GLib import threading +from typing import Union from ..lib import utils @@ -29,6 +30,7 @@ from tidalapi.album import Album from tidalapi.media import Track from tidalapi.playlist import Playlist +from tidalapi.page import PageItem from ..disconnectable_iface import IDisconnectable @@ -37,8 +39,13 @@ @Gtk.Template(resource_path="/io/github/nokse22/high-tide/ui/widgets/card_widget.ui") class HTCardWidget(Adw.BreakpointBin, IDisconnectable): - """It is card that adapts to the content it needs to display, it is used when - listing artists, albums, mixes and so on""" + """A card widget that adapts to display different types of TIDAL content. + + This widget automatically configures itself based on the type of TIDAL item + it receives (Track, Album, Artist, Playlist, Mix) and displays appropriate + information and imagery. It handles click events to navigate to detail pages + or start playback for tracks. + """ __gtype_name__ = "HTCardWidget" @@ -49,7 +56,12 @@ class HTCardWidget(Adw.BreakpointBin, IDisconnectable): track_artist_label = Gtk.Template.Child() - def __init__(self, _item): + def __init__(self, item: Union[Track, Album, Artist, Playlist, Mix, MixV2]) -> None: + """Initialize the card widget with a TIDAL item. + + Args: + item: A TIDAL object (Track, Album, Artist, Playlist, or Mix) to display + """ IDisconnectable.__init__(self) super().__init__() @@ -63,10 +75,13 @@ def __init__(self, _item): self.click_gesture.connect("released", self._on_click), )) - self.item = _item + self.item: Union[Track, Album, Artist, Playlist, Mix, MixV2] = item + + self.action: str | None = None - self.action = None + self._populate() + def _populate(self): if isinstance(self.item, MixV2) or isinstance(self.item, Mix): self._make_mix_card() self.action = "win.push-mix-page" @@ -81,9 +96,11 @@ def __init__(self, _item): self.action = "win.push-artist-page" elif isinstance(self.item, Track): self._make_track_card() - self.action = None + elif isinstance(self.item, PageItem): + self._make_page_item_card() - def _make_track_card(self): + def _make_track_card(self) -> None: + """Configure the card to display a Track item""" self.title_label.set_label(self.item.name) self.title_label.set_tooltip_text(self.item.name) self.track_artist_label.set_artists(self.item.artists) @@ -93,11 +110,11 @@ def _make_track_card(self): self.detail_label.set_visible(False) threading.Thread( - target=utils.add_image, - args=(self.image, self.item.album) + target=utils.add_image, args=(self.image, self.item.album) ).start() - def _make_mix_card(self): + def _make_mix_card(self) -> None: + """Configure the card to display a Mix item""" self.title_label.set_label(self.item.title) self.title_label.set_tooltip_text(self.item.title) self.detail_label.set_label(self.item.sub_title) @@ -105,7 +122,8 @@ def _make_mix_card(self): threading.Thread(target=utils.add_image, args=(self.image, self.item)).start() - def _make_album_card(self): + def _make_album_card(self) -> None: + """Configure the card to display an Album item""" self.title_label.set_label(self.item.name) self.title_label.set_tooltip_text(self.item.name) self.track_artist_label.set_artists(self.item.artists) @@ -113,21 +131,20 @@ def _make_album_card(self): threading.Thread(target=utils.add_image, args=(self.image, self.item)).start() - def _make_playlist_card(self): + def _make_playlist_card(self) -> None: + """Configure the card to display a Playlist item""" self.title_label.set_label(self.item.name) self.title_label.set_tooltip_text(self.item.name) self.track_artist_label.set_visible(False) - creator = self.item.creator - if creator: - creator = creator.name - else: - creator = "TIDAL" - self.detail_label.set_label(_("By {}").format(creator.title())) + self.detail_label.set_label( + _("By {}").format(self.item.creator.name if self.item.creator else "TIDAL") + ) threading.Thread(target=utils.add_image, args=(self.image, self.item)).start() - def _make_artist_card(self): + def _make_artist_card(self) -> None: + """Configure the card to display an Artist item""" self.title_label.set_label(self.item.name) self.title_label.set_tooltip_text(self.item.name) self.detail_label.set_label(_("Artist")) @@ -135,11 +152,36 @@ def _make_artist_card(self): threading.Thread(target=utils.add_image, args=(self.image, self.item)).start() - def _on_click(self, *args): + def _make_page_item_card(self) -> None: + """Configure the card to display a PageItem""" + + def _get_item(): + if self.item.type == "PLAYLIST": + self.item = utils.get_playlist(self.item.artifact_id) + elif self.item.type == "TRACK": + self.item = utils.get_track(self.item.artifact_id) + elif self.item.type == "ARTIST": + self.item = utils.get_artist(self.item.artifact_id) + elif self.item.type == "ALBUM": + self.item = utils.get_album(self.item.artifact_id) + + GLib.idle_add(self._populate) + + threading.Thread(target=_get_item).start() + + def _on_click(self, *args) -> None: + """Handle click events on the card. + + For non-track items, activates the appropriate navigation action to show + the detail page. For track items, starts playback immediately. + """ if self.action: self.activate_action(self.action, GLib.Variant("s", str(self.item.id))) elif isinstance(self.item, Track): utils.player_object.play_this(self.item) + elif isinstance(self.item, PageItem) and self.item.type == "TRACK": + + def _get(): + utils.player_object.play_this(self.item.get()) - def __repr__(self, *args): - return "" + threading.Thread(target=_get).start() diff --git a/src/widgets/carousel_widget.py b/src/widgets/carousel_widget.py index b1202a5..4b4abb7 100644 --- a/src/widgets/carousel_widget.py +++ b/src/widgets/carousel_widget.py @@ -17,6 +17,8 @@ # # SPDX-License-Identifier: GPL-3.0-or-later +from typing import Callable + from gi.repository import Gtk from ..widgets import HTCardWidget @@ -29,7 +31,13 @@ resource_path="/io/github/nokse22/high-tide/ui/widgets/carousel_widget.ui" ) class HTCarouselWidget(Gtk.Box, IDisconnectable): - """It is used to display multiple elements side by side with navigation arrows""" + """A horizontal scrolling carousel widget for displaying multiple TIDAL items. + + This widget creates a scrollable carousel with navigation arrows to display + collections of TIDAL content (albums, artists, playlists, etc.) in a + horizontal layout. It supports "See More" functionality to navigate to + detailed pages and automatic card creation for TIDAL items. + """ __gtype_name__ = "HTCarouselWidget" @@ -64,34 +72,42 @@ def __init__(self, _title=""): self.title_label.set_label(self.title) self.more_function = None - self.type = None self.items = [] - def set_more_function(self, _type, _function): + def set_more_function(self, function: Callable) -> None: + """Set the function to call when the "See More" button is clicked. + + Args: + function: A callable that returns page content + """ self.more_button.set_visible(True) - self.more_function = _function - self.type = _type + self.more_function = function - def append_card(self, card): - self.disconnectables.append(card) - self.carousel.append(card) - self.n_pages = self.carousel.get_n_pages() + def set_items(self, items_list) -> None: + """Set the list of items to display in the carousel. - if self.n_pages != 2: - self.next_button.set_sensitive(True) + Creates card widgets for each item in the list and adds them to the carousel. - def set_items(self, items_list, items_type): + Args: + items_list: List of TIDAL objects to display as cards + """ self.items = items_list - self.type = items_type for index, item in enumerate(self.items): if index >= 8: self.more_button.set_visible(True) break - self.append_card(HTCardWidget(item)) + card = HTCardWidget(item) + self.disconnectables.append(card) + self.carousel.append(card) + self.n_pages = self.carousel.get_n_pages() + + if self.n_pages != 2: + self.next_button.set_sensitive(True) def on_more_clicked(self, *args): + """Handle "See More" button clicks by navigating to a detailed page""" from ..pages import HTFromFunctionPage if self.more_function is None: @@ -104,12 +120,13 @@ def on_more_clicked(self, *args): page.load() utils.navigation_view.push(page) - def carousel_go_next(self, btn): + def carousel_go_next(self, *args): + """Navigate to the next page in the carousel""" pos = self.carousel.get_position() total_pages = self.carousel.get_n_pages() - if pos + 3 >= total_pages: - next_pos = max(0, total_pages - 2) + if pos + 2 >= total_pages: + next_pos = total_pages - 1 else: next_pos = pos + 2 @@ -117,25 +134,22 @@ def carousel_go_next(self, btn): if next_page is not None: self.carousel.scroll_to(next_page, True) - self.prev_button.set_sensitive(next_pos > 0) + self.prev_button.set_sensitive(next_pos > 1) self.next_button.set_sensitive(next_pos < total_pages - 2) - def carousel_go_prev(self, btn): + def carousel_go_prev(self, *args): + """Navigate to the previous page in the carousel""" pos = self.carousel.get_position() total_pages = self.carousel.get_n_pages() if pos - 2 < 0: - next_pos = 0 + prev_pos = 0 else: - next_pos = pos - 2 + prev_pos = pos - 2 - next_page = self.carousel.get_nth_page(next_pos) - if next_page is not None: - self.carousel.scroll_to(next_page, True) - - # Update button sensitivity - self.prev_button.set_sensitive(next_pos > 0) - self.next_button.set_sensitive(next_pos < total_pages - 2) + prev_page = self.carousel.get_nth_page(prev_pos) + if prev_page is not None: + self.carousel.scroll_to(prev_page, True) - def __repr__(self, *args): - return "" + self.prev_button.set_sensitive(prev_pos > 1) + self.next_button.set_sensitive(prev_pos < total_pages - 2) diff --git a/src/widgets/generic_track_widget.py b/src/widgets/generic_track_widget.py index 57c045a..9549524 100644 --- a/src/widgets/generic_track_widget.py +++ b/src/widgets/generic_track_widget.py @@ -18,12 +18,12 @@ # SPDX-License-Identifier: GPL-3.0-or-later -from gi.repository import Gtk +from gi.repository import Gtk, GObject from gi.repository import Gio, GLib from ..lib import utils from ..disconnectable_iface import IDisconnectable -from tidalapi import UserPlaylist, Track +from tidalapi import UserPlaylist import threading @@ -34,7 +34,12 @@ resource_path="/io/github/nokse22/high-tide/ui/widgets/generic_track_widget.ui" ) class HTGenericTrackWidget(Gtk.ListBoxRow, IDisconnectable): - """It is used to display a single track""" + """A widget for displaying a single track with playback and menu options. + + This widget shows track information including title, artist, album, duration, + and cover art. It provides context menu actions for playing, adding to queue, + adding to playlists, and other track-related operations. + """ __gtype_name__ = "HTGenericTrackWidget" @@ -52,22 +57,15 @@ class HTGenericTrackWidget(Gtk.ListBoxRow, IDisconnectable): menu_button = Gtk.Template.Child() track_menu = Gtk.Template.Child() - def __init__(self, _track=None, is_album=False): + index = GObject.Property(type=int, default=0) + + def __init__(self, track): IDisconnectable.__init__(self) super().__init__() - if not _track: - return self.menu_activated = False + self.track = track - self.set_track(_track, is_album) - - def set_track(self, track : Track, is_album=False): - """Set the track for HTGenericTrackWidget - - Args: - track: the track - is_album (bool): if this track is in an album view""" self.signals.append(( self.artist_label, self.artist_label.connect("activate-link", utils.open_uri), @@ -86,8 +84,6 @@ def set_track(self, track : Track, is_album=False): self.menu_button.connect("notify::active", self._on_menu_activate), )) - self.track = track - self.track_album_label.set_album(self.track.album) self.track_title_label.set_label( self.track.full_name @@ -179,6 +175,3 @@ def _add_to_playlist(self, action, parameter): def _copy_share_url(self, *args): utils.share_this(self.track) - - def __repr__(self, *args): - return "" diff --git a/src/widgets/link_label_widget.py b/src/widgets/link_label_widget.py index 6848aef..0b0842a 100644 --- a/src/widgets/link_label_widget.py +++ b/src/widgets/link_label_widget.py @@ -20,6 +20,7 @@ from gi.repository import Gtk from tidalapi import Artist, Album +from typing import List import html @@ -29,13 +30,13 @@ class HTLinkLabelWidget(Gtk.Label): __gtype_name__ = "HTLinkLabelWidget" - def __init__(self): + def __init__(self) -> None: super().__init__(self) self.xalign = 0.0 self.add_css_class("artist-link") - def set_artists(self, artists : list[Artist]): + def set_artists(self, artists: List[Artist]) -> None: """Set the artists for HTLinkLabelWidget Args: @@ -43,7 +44,7 @@ def set_artists(self, artists : list[Artist]): if not isinstance(artists, list) or not isinstance(artists[0], Artist): return - label = "" + label: str = "" for index, artist in enumerate(artists): if index >= 1: @@ -54,10 +55,10 @@ def set_artists(self, artists : list[Artist]): self.set_markup(label) - def set_album(self, album : Album): + def set_album(self, album: Album) -> None: """Set the album for HTLinkLabelWidget Args: album: an Album""" - label = f"""{html.escape(album.name)}""" + label: str = f"""{html.escape(album.name)}""" self.set_markup(label) diff --git a/src/widgets/lyrics_widget.py b/src/widgets/lyrics_widget.py index 1f2b726..1ad687f 100644 --- a/src/widgets/lyrics_widget.py +++ b/src/widgets/lyrics_widget.py @@ -94,7 +94,7 @@ def __init__(self, _item=None): "selection-changed", self._on_selection_changed ) - def set_lyrics(self, lyrics_text : str): + def set_lyrics(self, lyrics_text: str): """Set the lyrics Args: @@ -121,7 +121,7 @@ def clear(self): self.stack.set_visible_child_name("status_page") self.list_store.remove_all() - def set_time(self, time_seconds : float): + def set_time(self, time_seconds: float): """Updates the time of the widget to highlight the correct line Args: diff --git a/src/widgets/queue_widget.py b/src/widgets/queue_widget.py index a8e26fc..04b0d1d 100644 --- a/src/widgets/queue_widget.py +++ b/src/widgets/queue_widget.py @@ -25,7 +25,7 @@ @Gtk.Template(resource_path="/io/github/nokse22/high-tide/ui/widgets/queue_widget.ui") class HTQueueWidget(Gtk.Box): """It is used to display the track queue, including played tracks, - tracks to play and tracks added to the queue""" + tracks to play and tracks added to the queue""" __gtype_name__ = "HTQueueWidget" @@ -37,16 +37,13 @@ class HTQueueWidget(Gtk.Box): queued_songs_box = Gtk.Template.Child() next_songs_box = Gtk.Template.Child() - def __init__(self): - super().__init__() - - def update_all(self, player): + def update_all(self, player) -> None: """Updates played songs, queue and next songs""" self.update_played_songs(player) self.update_queue(player) self.update_next_songs(player) - def update_played_songs(self, player): + def update_played_songs(self, player) -> None: """Updates played songs""" child = self.played_songs_list.get_row_at_index(0) while child: @@ -57,13 +54,13 @@ def update_played_songs(self, player): if len(player.played_songs) > 0: self.played_songs_box.set_visible(True) for index, track in enumerate(player.played_songs): - listing = HTGenericTrackWidget(track, False) + listing = HTGenericTrackWidget(track) listing.set_name(str(index)) self.played_songs_list.append(listing) else: self.played_songs_box.set_visible(False) - def update_queue(self, player): + def update_queue(self, player) -> None: """Updates the queue""" child = self.queued_songs_list.get_row_at_index(0) while child: @@ -74,13 +71,13 @@ def update_queue(self, player): if len(player.queue) > 0: self.queued_songs_box.set_visible(True) for index, track in enumerate(player.queue): - listing = HTGenericTrackWidget(track, False) + listing = HTGenericTrackWidget(track) listing.set_name(str(index)) self.queued_songs_list.append(listing) else: self.queued_songs_box.set_visible(False) - def update_next_songs(self, player): + def update_next_songs(self, player) -> None: """Updates next songs""" child = self.next_songs_list.get_row_at_index(0) while child: @@ -91,7 +88,7 @@ def update_next_songs(self, player): if len(player.tracks_to_play) > 0: self.next_songs_box.set_visible(True) for index, track in enumerate(player.tracks_to_play): - listing = HTGenericTrackWidget(track, False) + listing = HTGenericTrackWidget(track) listing.set_name(str(index)) self.next_songs_list.append(listing) else: diff --git a/src/widgets/shortcuts_widget.py b/src/widgets/shortcuts_widget.py new file mode 100644 index 0000000..c40c3e5 --- /dev/null +++ b/src/widgets/shortcuts_widget.py @@ -0,0 +1,110 @@ +# shortcuts_widget.py +# +# Copyright 2025 Nokse +# +# This program is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . +# +# SPDX-License-Identifier: GPL-3.0-or-later + +from gi.repository import Gtk, GLib + +from tidalapi.mix import Mix, MixV2 +from tidalapi.artist import Artist +from tidalapi.album import Album +from tidalapi.playlist import Playlist +from typing import List, Union + +from ..lib import utils +from ..disconnectable_iface import IDisconnectable +import threading + +from gettext import gettext as _ + + +@Gtk.Template( + resource_path="/io/github/nokse22/high-tide/ui/widgets/shortcut_widget.ui" +) +class HTShorcutWidget(Gtk.FlowBoxChild, IDisconnectable): + """A widget to display a shortcut on the home page""" + + __gtype_name__ = "HTShorcutWidget" + + title_label = Gtk.Template.Child() + subtitle_label = Gtk.Template.Child() + image = Gtk.Template.Child() + click_gesture = Gtk.Template.Child() + + def __init__(self, item: Union[Mix, MixV2, Album, Artist, Playlist]) -> None: + IDisconnectable.__init__(self) + super().__init__() + + self.action: str | None = None + self.item: Union[Mix, MixV2, Album, Artist, Playlist] = item + + self.signals.append(( + self.click_gesture, + self.click_gesture.connect("released", self._on_click), + )) + + if isinstance(self.item, Mix) or isinstance(self.item, MixV2): + self.title_label.set_label(self.item.title) + self.subtitle_label.set_label(self.item.sub_title) + self.action = "win.push-mix-page" + elif isinstance(self.item, Album): + self.title_label.set_label(self.item.name) + self.subtitle_label.set_label(self.item.artist.name) + self.action = "win.push-album-page" + elif isinstance(self.item, Artist): + self.title_label.set_label(self.item.name) + self.subtitle_label.set_label(_("Artist")) + self.action = "win.push-artist-page" + elif isinstance(self.item, Playlist): + self.title_label.set_label(self.item.name) + self.subtitle_label.set_label(self.item.creator.name) + self.action = "win.push-playlist-page" + + threading.Thread(target=utils.add_image, args=(self.image, self.item)).start() + + def _on_click(self, *args) -> None: + if self.action is None: + return + + self.activate_action(self.action, GLib.Variant("s", str(self.item.id))) + + +@Gtk.Template( + resource_path="/io/github/nokse22/high-tide/ui/widgets/shortcuts_widget.ui" +) +class HTShorcutsWidget(Gtk.Box, IDisconnectable): + """A widget to display all shortcuts on the home page""" + + __gtype_name__ = "HTShorcutsWidget" + + shorcuts_flow_box = Gtk.Template.Child() + + def __init__( + self, items: List[Union[Mix, MixV2, Album, Artist, Playlist]] | None = None + ) -> None: + IDisconnectable.__init__(self) + super().__init__() + + self.set_items(items) + + def set_items( + self, items_list: List[Union[Mix, MixV2, Album, Artist, Playlist]] | None + ) -> None: + if items_list is None: + return + for item in items_list: + self.shorcuts_flow_box.append(HTShorcutWidget(item)) diff --git a/src/widgets/top_hit_widget.py b/src/widgets/top_hit_widget.py index dae69d0..da77335 100644 --- a/src/widgets/top_hit_widget.py +++ b/src/widgets/top_hit_widget.py @@ -34,8 +34,7 @@ @Gtk.Template(resource_path="/io/github/nokse22/high-tide/ui/widgets/top_hit_widget.ui") class HTTopHitWidget(Gtk.Box, IDisconnectable): - """It is used to display the top hit when searching regardless - of the type""" + """A widget to display the top hit when searching""" __gtype_name__ = "HTTopHitWidget" @@ -80,11 +79,11 @@ def __init__(self, _item): self.click_gesture.connect("released", self._on_click), )) - def _on_click(self, *args): + def _on_click(self, *args) -> None: if self.action: self.activate_action(self.action, GLib.Variant("s", str(self.item.id))) - def _make_track(self): + def _make_track(self) -> None: self.primary_label.set_label(self.item.name) self.secondary_label.set_label(_("Track")) @@ -104,7 +103,7 @@ def _make_track(self): target=utils.add_image, args=(self.image, self.item.album) ).start() - def _make_mix(self): + def _make_mix(self) -> None: self.primary_label.set_label(self.item.title) self.secondary_label.set_label(_("Mix")) @@ -127,7 +126,7 @@ def _make_mix(self): threading.Thread(target=utils.add_image, args=(self.image, self.item)).start() - def _make_album(self): + def _make_album(self) -> None: self.primary_label.set_label(self.item.name) self.secondary_label.set_label(_("Album")) @@ -150,7 +149,7 @@ def _make_album(self): threading.Thread(target=utils.add_image, args=(self.image, self.item)).start() - def _make_playlist(self): + def _make_playlist(self) -> None: self.primary_label.set_label(self.item.name) self.secondary_label.set_visible(False) @@ -177,7 +176,7 @@ def _make_playlist(self): threading.Thread(target=utils.add_image, args=(self.image, self.item)).start() - def _make_artist(self): + def _make_artist(self) -> None: self.primary_label.set_label(self.item.name) self.secondary_label.set_label(_("Artist")) @@ -185,6 +184,3 @@ def _make_artist(self): self.shuffle_button.set_visible(False) threading.Thread(target=utils.add_image, args=(self.image, self.item)).start() - - def __repr__(self, *args): - return "" diff --git a/src/widgets/tracks_list_widget.py b/src/widgets/tracks_list_widget.py index 8db7e08..33252f4 100644 --- a/src/widgets/tracks_list_widget.py +++ b/src/widgets/tracks_list_widget.py @@ -17,11 +17,14 @@ # # SPDX-License-Identifier: GPL-3.0-or-later +from typing import Callable, List from gi.repository import Gtk from . import HTGenericTrackWidget from ..lib import utils from ..disconnectable_iface import IDisconnectable +from tidalapi import Track + @Gtk.Template( resource_path="/io/github/nokse22/high-tide/ui/widgets/tracks_list_widget.ui" @@ -36,7 +39,7 @@ class HTTracksListWidget(Gtk.Box, IDisconnectable): more_button = Gtk.Template.Child() title_label = Gtk.Template.Child() - def __init__(self, _title): + def __init__(self, title): IDisconnectable.__init__(self) super().__init__() @@ -47,19 +50,19 @@ def __init__(self, _title): self.n_pages = 0 - self.title_name = _title - self.title_label.set_label(_title) + self.title_name: str = title + self.title_label.set_label(title) - self.get_function = None + self.get_function: Callable = None self.signals.append(( self.tracks_list_box, self.tracks_list_box.connect("row-activated", self._on_tracks_row_selected), )) - self.tracks = [] + self.tracks: List[Track] = [] - def set_function(self, function): + def set_more_function(self, function: Callable) -> None: """Set the function to fetch more items Args: @@ -70,19 +73,19 @@ def set_function(self, function): self._add_tracks() - def set_tracks_list(self, tracks_list): + def set_tracks_list(self, tracks_list: List[Track]) -> None: self.tracks = tracks_list self._add_tracks() def _add_tracks(self): for index, track in enumerate(self.tracks): - listing = HTGenericTrackWidget(track, False) + listing = HTGenericTrackWidget(track) self.disconnectables.append(listing) listing.set_name(str(index)) self.tracks_list_box.append(listing) - def _on_more_clicked(self, *args): + def _on_more_clicked(self, *args) -> None: from ..pages import HTFromFunctionPage page = HTFromFunctionPage(self.title_name) @@ -90,10 +93,7 @@ def _on_more_clicked(self, *args): page.load() utils.navigation_view.push(page) - def _on_tracks_row_selected(self, list_box, row): + def _on_tracks_row_selected(self, list_box, row) -> None: index = int(row.get_name()) utils.player_object.play_this(self.tracks, index) - - def __repr__(self, *args): - return "" diff --git a/src/window.py b/src/window.py index 222d79e..442695c 100644 --- a/src/window.py +++ b/src/window.py @@ -18,9 +18,10 @@ # SPDX-License-Identifier: GPL-3.0-or-later import threading -import requests import tidalapi +from typing import Callable + from gi.repository import Adw from gi.repository import Gtk from gi.repository import Gio @@ -31,12 +32,12 @@ from tidalapi.media import Quality -from .lib import PlayerObject, RepeatType, SecretStore, utils +from .lib import PlayerObject, RepeatType, SecretStore, utils, HTCache from .login import LoginDialog # from .new_playlist import NewPlaylistWindow -from .pages import HTHomePage, HTExplorePage, HTNotLoggedInPage +from .pages import HTNotLoggedInPage, HTGenericPage, HTExplorePage from .pages import HTCollectionPage from .pages import HTArtistPage, HTMixPage, HTHrackRadioPage, HTPlaylistPage from .pages import HTAlbumPage @@ -181,6 +182,7 @@ def __init__(self, **kwargs): utils.session = self.session utils.navigation_view = self.navigation_view utils.toast_overlay = self.toast_overlay + utils.cache = HTCache(self.session) self.user = self.session.user @@ -236,13 +238,12 @@ def on_app_id_closed_cb(self, dialog): # def new_login(self): - """Opens a LoginDialog""" + """Open a new login dialog for user authentication""" login_dialog = LoginDialog(self, self.session) login_dialog.present(self) def th_login(self): - """Logs the user in, if it doesn't work it calls on_login_failed()""" try: self.session.load_oauth_session( self.secret_store.token_dictionary["token-type"], @@ -258,15 +259,22 @@ def th_login(self): GLib.idle_add(self.on_logged_in) def logout(self): + """Log out the current user and return to login screen. + + Clears stored authentication tokens and navigates back to the + not logged in page. + """ self.secret_store.clear() page = HTNotLoggedInPage().load() self.navigation_view.replace([page]) def on_logged_in(self): + """Handle successful user login""" print("logged in") - page = HTHomePage().load() + page = HTGenericPage.new_from_function(utils.session.home).load() + page.set_tag("home") self.navigation_view.replace([page]) self.player_lyrics_queue.set_sensitive(True) @@ -280,6 +288,7 @@ def on_logged_in(self): utils.open_tidal_uri(self.queued_uri) def on_login_failed(self): + """Handle failed login attempts""" print("login failed") page = HTNotLoggedInPage().load() @@ -315,6 +324,11 @@ def th_set_last_playing_song(self): # def on_song_changed(self, *args): + """Handle song change events from the player. + + Updates the UI elements when the currently playing song changes, + including album art, track information, and video covers. + """ print("song changed") album = self.player_object.song_album track = self.player_object.playing_track @@ -379,6 +393,11 @@ def on_song_changed(self, *args): self.queue_widget_updated = False def save_last_playing_thing(self): + """Save the current playing context to settings for persistence. + + Stores information about the currently playing track and its source + (album, playlist, mix, etc.) so playback can resume on app restart. + """ mix_album_playlist = self.player_object.current_mix_album_playlist track = self.player_object.playing_track @@ -404,6 +423,11 @@ def stop_video_in_background(self, window, param): self.videoplayer.pause() def set_quality_label(self): + """Update the quality label with current track's audio information. + + Displays information about the current track's codec, bit depth, + sample rate, and audio quality in the UI. + """ codec = None bit_depth = None sample_rate = None @@ -448,13 +472,15 @@ def set_quality_label(self): self.quality_label.set_label(quality_text) self.quality_label.set_visible(True) - def update_controls(self, player, *args): - if player.playing: + def update_controls(self, *args): + """Update playback control button states based on player status""" + if self.player_object.playing: self.play_button.set_icon_name("media-playback-pause-symbolic") else: self.play_button.set_icon_name("media-playback-start-symbolic") def update_repeat_button(self, player, repeat_type): + """Update the repeat button icon based on current repeat mode""" if player.repeat_type == RepeatType.NONE: self.repeat_button.set_icon_name("media-playlist-consecutive-symbolic") elif player.repeat_type == RepeatType.LIST: @@ -485,13 +511,13 @@ def on_share_clicked(self, *args): @Gtk.Template.Callback("on_track_radio_button_clicked") def on_track_radio_button_clicked_func(self, widget): track = self.player_object.playing_track - page = HTHrackRadioPage(track.id).load() + page = HTHrackRadioPage.new_from_id(track.id).load() self.navigation_view.push(page) @Gtk.Template.Callback("on_album_button_clicked") def on_album_button_clicked_func(self, widget): track = self.player_object.playing_track - page = HTAlbumPage(track.album.id).load() + page = HTAlbumPage.new_from_id(track.album.id).load() self.navigation_view.push(page) @Gtk.Template.Callback("on_skip_forward_button_clicked") @@ -613,8 +639,8 @@ def on_shuffle_changed(self, *args): def update_slider(self, *args): """Update the progress bar and playback information. - Called periodically to update the progress bar, song duration, - current position and volume level. + Called periodically to update the progress bar, song duration, current position + and volume level. """ self.duration = self.player_object.query_duration() end_value = self.duration / Gst.SECOND @@ -649,21 +675,6 @@ def th_add_lyrics_to_page(self): except Exception: self.lyrics_widget.clear() - def th_download_song(self): - """Added to check the streamed song quality, triggered with ctrl+d""" - - song = self.player_object.playing_track - song_url = song.get_url() - try: - response = requests.get(song_url) - except Exception: - return - if response.status_code == 200: - image_data = response.content - file_path = f"{song.id}.flac" - with open(file_path, "wb") as file: - file.write(image_data) - def select_quality(self, pos): match pos: case 0: @@ -734,31 +745,39 @@ def change_discord_rpc_enabled(self, state): # def on_push_artist_page(self, action, parameter): - page = HTArtistPage(parameter.get_string()).load() + page = HTArtistPage.new_from_id(parameter.get_string()).load() self.navigation_view.push(page) def on_push_album_page(self, action, parameter): - page = HTAlbumPage(parameter.get_string()).load() + page = HTAlbumPage.new_from_id(parameter.get_string()).load() self.navigation_view.push(page) def on_push_playlist_page(self, action, parameter): - page = HTPlaylistPage(parameter.get_string()).load() + page = HTPlaylistPage.new_from_id(parameter.get_string()).load() self.navigation_view.push(page) def on_push_mix_page(self, action, parameter): - page = HTMixPage(parameter.get_string()).load() + page = HTMixPage.new_from_id(parameter.get_string()).load() self.navigation_view.push(page) def on_push_track_radio_page(self, action, parameter): - page = HTHrackRadioPage(parameter.get_string()).load() + page = HTHrackRadioPage.new_from_id(parameter.get_string()).load() self.navigation_view.push(page) # # # - def create_action_with_target(self, name, target_type, callback): - """Used to create a new action with a target""" + def create_action_with_target( + self, name: str, target_type: GLib.VariantType, callback: Callable + ): + """Create a new GAction with a target parameter. + + Args: + name (str): The action name + target_type: The GVariant type for the target parameter + callback: The callback function to execute when action is triggered + """ action = Gio.SimpleAction.new(name, target_type) action.connect("activate", callback)