Merge branch 'master' of gitlab.com:robozman/libremsonic
This commit is contained in:
@@ -67,9 +67,11 @@ class LibremsonicApp(Gtk.Application):
|
||||
def do_startup(self):
|
||||
Gtk.Application.do_startup(self)
|
||||
|
||||
def add_action(name: str, fn):
|
||||
def add_action(name: str, fn, parameter_type=None):
|
||||
"""Registers an action with the application."""
|
||||
action = Gio.SimpleAction.new(name, None)
|
||||
if type(parameter_type) == str:
|
||||
parameter_type = GLib.VariantType(parameter_type)
|
||||
action = Gio.SimpleAction.new(name, parameter_type)
|
||||
action.connect('activate', fn)
|
||||
self.add_action(action)
|
||||
|
||||
@@ -84,6 +86,12 @@ class LibremsonicApp(Gtk.Application):
|
||||
add_action('repeat-press', self.on_repeat_press)
|
||||
add_action('shuffle-press', self.on_shuffle_press)
|
||||
|
||||
# Navigation actions.
|
||||
add_action('play-next', self.on_play_next, parameter_type='as')
|
||||
add_action('add-to-queue', self.on_add_to_queue, parameter_type='as')
|
||||
add_action('go-to-album', self.on_go_to_album, parameter_type='s')
|
||||
add_action('go-to-artist', self.on_go_to_artist, parameter_type='s')
|
||||
|
||||
add_action('mute-toggle', self.on_mute_toggle)
|
||||
add_action(
|
||||
'update-play-queue-from-server',
|
||||
@@ -135,6 +143,7 @@ class LibremsonicApp(Gtk.Application):
|
||||
|
||||
self.update_window()
|
||||
|
||||
# Configure the players
|
||||
self.last_play_queue_update = 0
|
||||
|
||||
def time_observer(value):
|
||||
@@ -207,6 +216,9 @@ class LibremsonicApp(Gtk.Application):
|
||||
dialog.destroy()
|
||||
|
||||
def on_play_pause(self, *args):
|
||||
if self.state.current_song is None:
|
||||
return
|
||||
|
||||
if self.player.song_loaded:
|
||||
self.player.toggle_play()
|
||||
self.save_play_queue()
|
||||
@@ -269,6 +281,34 @@ class LibremsonicApp(Gtk.Application):
|
||||
self.state.shuffle_on = not self.state.shuffle_on
|
||||
self.update_window()
|
||||
|
||||
def on_play_next(self, action, song_ids):
|
||||
if self.state.current_song is None:
|
||||
insert_at = 0
|
||||
else:
|
||||
insert_at = (
|
||||
self.state.play_queue.index(self.state.current_song.id) + 1)
|
||||
|
||||
self.state.play_queue = (self.state.play_queue[:insert_at]
|
||||
+ list(song_ids)
|
||||
+ self.state.play_queue[insert_at:])
|
||||
self.state.old_play_queue.extend(song_ids)
|
||||
self.update_window()
|
||||
|
||||
def on_add_to_queue(self, action, song_ids):
|
||||
self.state.play_queue.extend(song_ids)
|
||||
self.state.old_play_queue.extend(song_ids)
|
||||
self.update_window()
|
||||
|
||||
def on_go_to_album(self, action, album_id):
|
||||
# TODO
|
||||
self.state.current_tab = 'albums'
|
||||
self.update_window()
|
||||
|
||||
def on_go_to_artist(self, action, artist_id):
|
||||
self.state.current_tab = 'artists'
|
||||
self.state.selected_artist_id = artist_id.get_string()
|
||||
self.update_window()
|
||||
|
||||
def on_server_list_changed(self, action, servers):
|
||||
self.state.config.servers = servers
|
||||
self.state.save()
|
||||
@@ -278,11 +318,12 @@ class LibremsonicApp(Gtk.Application):
|
||||
self.state.save()
|
||||
|
||||
self.reset_cache_manager()
|
||||
self.update_window()
|
||||
|
||||
def reset_cache_manager(self):
|
||||
CacheManager.reset(
|
||||
self.state.config,
|
||||
self.state.config.servers[self.state.config.current_server]
|
||||
self.current_server
|
||||
if self.state.config.current_server >= 0 else None,
|
||||
)
|
||||
|
||||
@@ -290,13 +331,17 @@ class LibremsonicApp(Gtk.Application):
|
||||
self.update_window()
|
||||
|
||||
def on_stack_change(self, stack, child):
|
||||
self.state.current_tab = stack.get_visible_child_name()
|
||||
self.update_window()
|
||||
|
||||
def on_song_clicked(self, win, song_id, song_queue):
|
||||
def on_song_clicked(self, win, song_id, song_queue, metadata):
|
||||
# Reset the play queue so that we don't ever revert back to the
|
||||
# previous one.
|
||||
old_play_queue = song_queue.copy()
|
||||
|
||||
if metadata.get('force_shuffle_state') is not None:
|
||||
self.state.shuffle_on = metadata['force_shuffle_state']
|
||||
|
||||
# If shuffle is enabled, then shuffle the playlist.
|
||||
if self.state.shuffle_on:
|
||||
song_queue.remove(song_id)
|
||||
@@ -383,6 +428,11 @@ class LibremsonicApp(Gtk.Application):
|
||||
self.save_play_queue()
|
||||
CacheManager.shutdown()
|
||||
|
||||
# ########## PROPERTIES ########## #
|
||||
@property
|
||||
def current_server(self):
|
||||
return self.state.config.servers[self.state.config.current_server]
|
||||
|
||||
# ########## HELPER METHODS ########## #
|
||||
def show_configure_servers_dialog(self):
|
||||
"""Show the Connect to Server dialog."""
|
||||
@@ -435,7 +485,6 @@ class LibremsonicApp(Gtk.Application):
|
||||
uri, stream = CacheManager.get_song_filename_or_stream(
|
||||
song,
|
||||
force_stream=self.state.config.always_stream,
|
||||
format='mp3',
|
||||
)
|
||||
|
||||
self.state.current_song = song
|
||||
@@ -448,7 +497,10 @@ class LibremsonicApp(Gtk.Application):
|
||||
self.player.reset()
|
||||
self.state.song_progress = 0
|
||||
|
||||
def on_song_download_complete(_):
|
||||
def on_song_download_complete(song_id):
|
||||
if self.state.current_song != song.id:
|
||||
return
|
||||
|
||||
# Switch to the local media if the player can hotswap (MPV can,
|
||||
# Chromecast cannot hotswap without lag).
|
||||
if self.player.can_hotswap_source:
|
||||
@@ -494,18 +546,21 @@ class LibremsonicApp(Gtk.Application):
|
||||
self.update_window),
|
||||
)
|
||||
|
||||
if self.current_server.sync_enabled:
|
||||
CacheManager.scrobble(song.id)
|
||||
|
||||
song_details_future = CacheManager.get_song_details(song)
|
||||
song_details_future.add_done_callback(
|
||||
lambda f: GLib.idle_add(do_play_song, f.result()), )
|
||||
|
||||
def save_play_queue(self):
|
||||
if len(self.state.play_queue) == 0:
|
||||
return
|
||||
|
||||
position = self.state.song_progress
|
||||
self.last_play_queue_update = position
|
||||
|
||||
current_server = self.state.config.current_server
|
||||
current_server = self.state.config.servers[current_server]
|
||||
|
||||
if current_server.sync_enabled:
|
||||
if self.current_server.sync_enabled:
|
||||
CacheManager.executor.submit(
|
||||
CacheManager.save_play_queue,
|
||||
id=self.state.play_queue,
|
||||
|
@@ -570,6 +570,12 @@ class CacheManager(metaclass=Singleton):
|
||||
def get_play_queue(self) -> Future:
|
||||
return CacheManager.executor.submit(self.server.get_play_queue)
|
||||
|
||||
def scrobble(self, song_id: int) -> Future:
|
||||
def do_scrobble():
|
||||
self.server.scrobble(song_id)
|
||||
|
||||
return CacheManager.executor.submit(do_scrobble)
|
||||
|
||||
def get_song_filename_or_stream(
|
||||
self,
|
||||
song: Child,
|
||||
|
@@ -39,17 +39,19 @@ class ApplicationState:
|
||||
loads.
|
||||
"""
|
||||
config: AppConfiguration = AppConfiguration()
|
||||
current_song: Child
|
||||
config_file: str
|
||||
current_song: Child = None
|
||||
config_file: str = None
|
||||
playing: bool = False
|
||||
play_queue: List[str]
|
||||
old_play_queue: List[str]
|
||||
play_queue: List[str] = []
|
||||
old_play_queue: List[str] = []
|
||||
volume: int = 100
|
||||
old_volume: int = 100
|
||||
repeat_type: RepeatType = RepeatType.NO_REPEAT
|
||||
shuffle_on: bool = False
|
||||
song_progress: float = 0
|
||||
current_device: str = 'this device'
|
||||
current_tab: str = 'albums'
|
||||
selected_artist_id: str = None
|
||||
|
||||
def to_json(self):
|
||||
current_song = (self.current_song.id if
|
||||
@@ -65,6 +67,8 @@ class ApplicationState:
|
||||
'shuffle_on': getattr(self, 'shuffle_on', None),
|
||||
'song_progress': getattr(self, 'song_progress', None),
|
||||
'current_device': getattr(self, 'current_device', 'this device'),
|
||||
'current_tab': getattr(self, 'current_tab', 'albums'),
|
||||
'selected_artist_id': getattr(self, 'selected_artist_id', None),
|
||||
}
|
||||
|
||||
def load_from_json(self, json_object):
|
||||
@@ -83,6 +87,8 @@ class ApplicationState:
|
||||
self.shuffle_on = json_object.get('shuffle_on', False)
|
||||
self.song_progress = json_object.get('song_progress', 0.0)
|
||||
self.current_device = json_object.get('current_device', 'this device')
|
||||
self.current_tab = json_object.get('current_tab', 'albums')
|
||||
self.selected_artist_id = json_object.get('selected_artist_id', None)
|
||||
|
||||
def load(self):
|
||||
self.config = self.get_config(self.config_file)
|
||||
|
@@ -1,5 +1,4 @@
|
||||
import gi
|
||||
import threading
|
||||
from typing import Optional, Union
|
||||
|
||||
gi.require_version('Gtk', '3.0')
|
||||
@@ -8,7 +7,7 @@ from gi.repository import Gtk, GObject, GLib
|
||||
from libremsonic.state_manager import ApplicationState
|
||||
from libremsonic.cache_manager import CacheManager
|
||||
from libremsonic.ui import util
|
||||
from libremsonic.ui.common import AlbumWithSongs, CoverArtGrid
|
||||
from libremsonic.ui.common import AlbumWithSongs, IconButton, CoverArtGrid
|
||||
|
||||
from libremsonic.server.api_objects import Child, AlbumWithSongsID3
|
||||
|
||||
@@ -20,7 +19,7 @@ class AlbumsPanel(Gtk.Box):
|
||||
'song-clicked': (
|
||||
GObject.SignalFlags.RUN_FIRST,
|
||||
GObject.TYPE_NONE,
|
||||
(str, object),
|
||||
(str, object, object),
|
||||
),
|
||||
}
|
||||
|
||||
@@ -84,7 +83,7 @@ class AlbumsPanel(Gtk.Box):
|
||||
self.to_year_entry.connect('changed', self.on_year_changed)
|
||||
actionbar.pack_start(self.to_year_entry)
|
||||
|
||||
refresh = util.button_with_icon('view-refresh')
|
||||
refresh = IconButton('view-refresh')
|
||||
refresh.connect('clicked', lambda *a: self.update(force=True))
|
||||
actionbar.pack_end(refresh)
|
||||
|
||||
@@ -94,7 +93,8 @@ class AlbumsPanel(Gtk.Box):
|
||||
self.grid = AlbumsGrid()
|
||||
self.grid.connect(
|
||||
'song-clicked',
|
||||
lambda _, song, queue: self.emit('song-clicked', song, queue),
|
||||
lambda _, song, queue, metadata: self.emit('song-clicked', song,
|
||||
queue, metadata),
|
||||
)
|
||||
scrolled_window.add(self.grid)
|
||||
self.add(scrolled_window)
|
||||
@@ -124,6 +124,7 @@ class AlbumsPanel(Gtk.Box):
|
||||
model.append((genre.value, genre.value))
|
||||
|
||||
self.populating_genre_combo = False
|
||||
# TODO Get this from state
|
||||
self.genre_combo.set_active_id(self.currently_active_genre)
|
||||
|
||||
genres_future = CacheManager.get_genres()
|
||||
|
@@ -4,6 +4,15 @@
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
#icon-button-box image {
|
||||
margin: 5px 2px;
|
||||
}
|
||||
|
||||
#icon-button-box label {
|
||||
margin-left: 5px;
|
||||
margin-right: 3px;
|
||||
}
|
||||
|
||||
/* ********** Playlist ********** */
|
||||
#playlist-list-listbox row {
|
||||
margin: 0;
|
||||
@@ -58,6 +67,10 @@
|
||||
margin: -10px 0 0 10px;
|
||||
}
|
||||
|
||||
#playlist-play-shuffle-buttons {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
/* ********** Playback Controls ********** */
|
||||
#player-controls-album-artwork {
|
||||
min-height: 70px;
|
||||
@@ -73,8 +86,10 @@
|
||||
|
||||
/* Make the play icon look centered. */
|
||||
#player-controls-bar #play-button image {
|
||||
margin-left: 1px;
|
||||
margin-left: 5px;
|
||||
margin-right: 5px;
|
||||
margin-top: 1px;
|
||||
margin-bottom: 1px;
|
||||
}
|
||||
|
||||
#player-controls-bar #song-scrubber {
|
||||
|
@@ -3,12 +3,12 @@ from typing import List, Union, Optional
|
||||
import gi
|
||||
|
||||
gi.require_version('Gtk', '3.0')
|
||||
from gi.repository import Gtk, GObject, Pango
|
||||
from gi.repository import Gtk, GObject, Pango, GLib
|
||||
|
||||
from libremsonic.state_manager import ApplicationState
|
||||
from libremsonic.cache_manager import CacheManager
|
||||
from libremsonic.ui import util
|
||||
from libremsonic.ui.common import AlbumWithSongs, SpinnerImage
|
||||
from libremsonic.ui.common import AlbumWithSongs, IconButton, SpinnerImage
|
||||
|
||||
from libremsonic.server.api_objects import (
|
||||
AlbumID3,
|
||||
@@ -25,7 +25,7 @@ class ArtistsPanel(Gtk.Paned):
|
||||
'song-clicked': (
|
||||
GObject.SignalFlags.RUN_FIRST,
|
||||
GObject.TYPE_NONE,
|
||||
(str, object),
|
||||
(str, object, object),
|
||||
),
|
||||
}
|
||||
artist_id: Optional[str] = None
|
||||
@@ -33,49 +33,30 @@ class ArtistsPanel(Gtk.Paned):
|
||||
def __init__(self, *args, **kwargs):
|
||||
Gtk.Paned.__init__(self, orientation=Gtk.Orientation.HORIZONTAL)
|
||||
|
||||
self.selected_artist = None
|
||||
|
||||
self.artist_list = ArtistList()
|
||||
self.artist_list.connect(
|
||||
'selection-changed',
|
||||
self.on_list_selection_changed,
|
||||
)
|
||||
self.pack1(self.artist_list, False, False)
|
||||
|
||||
self.artist_detail_panel = ArtistDetailPanel()
|
||||
self.artist_detail_panel.connect(
|
||||
'song-clicked',
|
||||
lambda _, song, queue: self.emit('song-clicked', song, queue),
|
||||
lambda _, song, queue, metadata: self.emit('song-clicked', song,
|
||||
queue, metadata),
|
||||
)
|
||||
self.pack2(self.artist_detail_panel, True, False)
|
||||
|
||||
def update(self, state: ApplicationState):
|
||||
self.artist_list.update(state)
|
||||
if self.artist_id:
|
||||
self.artist_detail_panel.update(self.artist_id)
|
||||
|
||||
def on_list_selection_changed(self, artist_list, artist):
|
||||
self.artist_id = artist.id
|
||||
self.artist_detail_panel.update(self.artist_id)
|
||||
if state.selected_artist_id:
|
||||
self.artist_detail_panel.update(state.selected_artist_id)
|
||||
|
||||
|
||||
class ArtistList(Gtk.Box):
|
||||
__gsignals__ = {
|
||||
'selection-changed': (
|
||||
GObject.SignalFlags.RUN_FIRST,
|
||||
GObject.TYPE_NONE,
|
||||
(object, ),
|
||||
),
|
||||
}
|
||||
|
||||
def __init__(self):
|
||||
Gtk.Box.__init__(self, orientation=Gtk.Orientation.VERTICAL)
|
||||
|
||||
self.artist_map = {}
|
||||
|
||||
list_actions = Gtk.ActionBar()
|
||||
|
||||
refresh = util.button_with_icon('view-refresh')
|
||||
refresh = IconButton('view-refresh')
|
||||
refresh.connect('clicked', lambda *a: self.update(force=True))
|
||||
list_actions.pack_end(refresh)
|
||||
|
||||
@@ -92,24 +73,19 @@ class ArtistList(Gtk.Box):
|
||||
self.loading_indicator.add(loading_spinner)
|
||||
self.list.add(self.loading_indicator)
|
||||
|
||||
self.list.connect('row-activated', self.on_row_activated)
|
||||
list_scroll_window.add(self.list)
|
||||
self.pack_start(list_scroll_window, True, True, 0)
|
||||
|
||||
def update(self, state=None, force=False):
|
||||
self.update_list(force=force)
|
||||
self.update_list(force=force, state=state)
|
||||
|
||||
@util.async_callback(
|
||||
lambda *a, **k: CacheManager.get_artists(*a, **k),
|
||||
before_download=lambda self: self.loading_indicator.show(),
|
||||
on_failure=lambda self, e: self.loading_indicator.hide(),
|
||||
)
|
||||
def update_list(self, artists):
|
||||
selected_row = self.list.get_selected_row()
|
||||
selected_artist = None
|
||||
if selected_row:
|
||||
selected_artist = self.artist_map.get(selected_row.get_index())
|
||||
|
||||
def update_list(self, artists, state: ApplicationState):
|
||||
# TODO use a diff here
|
||||
# Remove everything
|
||||
for row in self.list.get_children()[1:]:
|
||||
self.list.remove(row)
|
||||
@@ -118,9 +94,9 @@ class ArtistList(Gtk.Box):
|
||||
|
||||
for i, artist in enumerate(artists):
|
||||
# Use i + 1 because of the loading indicator in index 0.
|
||||
if selected_artist and artist.id == selected_artist.id:
|
||||
if (state.selected_artist_id
|
||||
and artist.id == (state.selected_artist_id or -1)):
|
||||
selected_idx = i + 1
|
||||
self.artist_map[i + 1] = artist
|
||||
|
||||
label_text = [f'<b>{util.esc(artist.name)}</b>']
|
||||
|
||||
@@ -129,7 +105,11 @@ class ArtistList(Gtk.Box):
|
||||
label_text.append('{} {}'.format(
|
||||
album_count, util.pluralize('album', album_count)))
|
||||
|
||||
self.list.add(
|
||||
row = Gtk.ListBoxRow(
|
||||
action_name='app.go-to-artist',
|
||||
action_target=GLib.Variant('s', artist.id),
|
||||
)
|
||||
row.add(
|
||||
Gtk.Label(
|
||||
label='\n'.join(label_text),
|
||||
use_markup=True,
|
||||
@@ -138,16 +118,16 @@ class ArtistList(Gtk.Box):
|
||||
ellipsize=Pango.EllipsizeMode.END,
|
||||
max_width_chars=30,
|
||||
))
|
||||
self.list.add(row)
|
||||
|
||||
if selected_idx:
|
||||
row = self.list.get_row_at_index(selected_idx)
|
||||
# TODO scroll to the row
|
||||
self.list.select_row(row)
|
||||
|
||||
self.list.show_all()
|
||||
self.loading_indicator.hide()
|
||||
|
||||
def on_row_activated(self, listbox, row):
|
||||
self.emit('selection-changed', self.artist_map[row.get_index()])
|
||||
|
||||
|
||||
class ArtistDetailPanel(Gtk.Box):
|
||||
"""Defines the artists list."""
|
||||
@@ -156,7 +136,7 @@ class ArtistDetailPanel(Gtk.Box):
|
||||
'song-clicked': (
|
||||
GObject.SignalFlags.RUN_FIRST,
|
||||
GObject.TYPE_NONE,
|
||||
(str, object),
|
||||
(str, object, object),
|
||||
),
|
||||
}
|
||||
|
||||
@@ -190,12 +170,12 @@ class ArtistDetailPanel(Gtk.Box):
|
||||
self.artist_action_buttons = Gtk.Box(
|
||||
orientation=Gtk.Orientation.HORIZONTAL)
|
||||
|
||||
view_refresh_button = util.button_with_icon('view-refresh-symbolic')
|
||||
view_refresh_button = IconButton('view-refresh-symbolic')
|
||||
view_refresh_button.connect('clicked', self.on_view_refresh_click)
|
||||
self.artist_action_buttons.pack_end(view_refresh_button, False, False,
|
||||
5)
|
||||
|
||||
download_all_btn = util.button_with_icon('folder-download-symbolic')
|
||||
download_all_btn = IconButton('folder-download-symbolic')
|
||||
download_all_btn.connect('clicked', self.on_download_all_click)
|
||||
self.artist_action_buttons.pack_end(download_all_btn, False, False, 5)
|
||||
|
||||
@@ -237,14 +217,15 @@ class ArtistDetailPanel(Gtk.Box):
|
||||
self.albums_list = AlbumsListWithSongs()
|
||||
self.albums_list.connect(
|
||||
'song-clicked',
|
||||
lambda _, song, queue: self.emit('song-clicked', song, queue),
|
||||
lambda _, song, queue, metadata: self.emit('song-clicked', song,
|
||||
queue, metadata),
|
||||
)
|
||||
artist_info_box.pack_start(self.albums_list, True, True, 0)
|
||||
|
||||
self.add(artist_info_box)
|
||||
|
||||
def update(self, album_id):
|
||||
self.update_artist_view(album_id)
|
||||
def update(self, artist_id):
|
||||
self.update_artist_view(artist_id)
|
||||
|
||||
def get_model_list_future(self, before_download):
|
||||
def do_get_model_list() -> List[Child]:
|
||||
@@ -259,7 +240,11 @@ class ArtistDetailPanel(Gtk.Box):
|
||||
before_download=lambda self: self.artist_artwork.set_loading(True),
|
||||
on_failure=lambda self, e: print('fail a', e),
|
||||
)
|
||||
def update_artist_view(self, artist: ArtistWithAlbumsID3):
|
||||
def update_artist_view(
|
||||
self,
|
||||
artist: ArtistWithAlbumsID3,
|
||||
state: ApplicationState,
|
||||
):
|
||||
self.artist_id = artist.id
|
||||
self.artist_indicator.set_text('ARTIST')
|
||||
self.artist_name.set_markup(util.esc(f'<b>{artist.name}</b>'))
|
||||
@@ -274,7 +259,11 @@ class ArtistDetailPanel(Gtk.Box):
|
||||
@util.async_callback(
|
||||
lambda *a, **k: CacheManager.get_artist_info(*a, **k),
|
||||
)
|
||||
def update_artist_info(self, artist_info: ArtistInfo2):
|
||||
def update_artist_info(
|
||||
self,
|
||||
artist_info: ArtistInfo2,
|
||||
state: ApplicationState,
|
||||
):
|
||||
self.artist_bio.set_markup(util.esc(''.join(artist_info.biography)))
|
||||
|
||||
if len(artist_info.similarArtist or []) > 0:
|
||||
@@ -285,9 +274,10 @@ class ArtistDetailPanel(Gtk.Box):
|
||||
for artist in artist_info.similarArtist[:5]:
|
||||
self.similar_artists_button_box.add(
|
||||
Gtk.LinkButton(
|
||||
uri=f'artist://{artist.id}',
|
||||
label=artist.name,
|
||||
name='similar-artist-button',
|
||||
action_name='app.go-to-artist',
|
||||
action_target=GLib.Variant('s', artist.id),
|
||||
))
|
||||
self.similar_artists_box.show_all()
|
||||
else:
|
||||
@@ -298,7 +288,11 @@ class ArtistDetailPanel(Gtk.Box):
|
||||
before_download=lambda self: self.artist_artwork.set_loading(True),
|
||||
on_failure=lambda self, e: self.artist_artwork.set_loading(False),
|
||||
)
|
||||
def update_artist_artwork(self, cover_art_filename):
|
||||
def update_artist_artwork(
|
||||
self,
|
||||
cover_art_filename,
|
||||
state: ApplicationState,
|
||||
):
|
||||
self.artist_artwork.set_from_file(cover_art_filename)
|
||||
self.artist_artwork.set_loading(False)
|
||||
|
||||
@@ -308,10 +302,20 @@ class ArtistDetailPanel(Gtk.Box):
|
||||
self.update_artist_view(self.artist_id, force=True)
|
||||
|
||||
def on_download_all_click(self, btn):
|
||||
print('download all')
|
||||
songs_for_download = []
|
||||
artist = CacheManager.get_artist(self.artist_id).result()
|
||||
for album in artist.album:
|
||||
print(album)
|
||||
for album in (artist.get('album', artist.get('child', []))):
|
||||
album_songs = CacheManager.get_album(album.id).result()
|
||||
album_songs = album_songs.get('child', album_songs.get('song', []))
|
||||
for song in album_songs:
|
||||
songs_for_download.append(song.id)
|
||||
|
||||
CacheManager.batch_download_songs(
|
||||
songs_for_download,
|
||||
before_download=lambda: self.update_artist_view(self.artist_id),
|
||||
on_song_download_complete=lambda i: self.update_artist_view(
|
||||
self.artist_id),
|
||||
)
|
||||
|
||||
# Helper Methods
|
||||
# =========================================================================
|
||||
@@ -351,7 +355,7 @@ class AlbumsListWithSongs(Gtk.Overlay):
|
||||
'song-clicked': (
|
||||
GObject.SignalFlags.RUN_FIRST,
|
||||
GObject.TYPE_NONE,
|
||||
(str, object),
|
||||
(str, object, object),
|
||||
),
|
||||
}
|
||||
|
||||
@@ -381,7 +385,8 @@ class AlbumsListWithSongs(Gtk.Overlay):
|
||||
album_with_songs = AlbumWithSongs(album, show_artist_name=False)
|
||||
album_with_songs.connect(
|
||||
'song-clicked',
|
||||
lambda _, song, queue: self.emit('song-clicked', song, queue),
|
||||
lambda _, song, queue, metadata: self.emit(
|
||||
'song-clicked', song, queue, metadata),
|
||||
)
|
||||
album_with_songs.connect('song-selected', self.on_song_selected)
|
||||
album_with_songs.show_all()
|
||||
|
@@ -1,6 +1,13 @@
|
||||
from .album_with_songs import AlbumWithSongs
|
||||
from .cover_art_grid import CoverArtGrid
|
||||
from .edit_form_dialog import EditFormDialog
|
||||
from .icon_button import IconButton
|
||||
from .spinner_image import SpinnerImage
|
||||
|
||||
__all__ = ('AlbumWithSongs', 'CoverArtGrid', 'EditFormDialog', 'SpinnerImage')
|
||||
__all__ = (
|
||||
'AlbumWithSongs',
|
||||
'CoverArtGrid',
|
||||
'EditFormDialog',
|
||||
'IconButton',
|
||||
'SpinnerImage',
|
||||
)
|
||||
|
@@ -1,12 +1,15 @@
|
||||
from typing import Union
|
||||
from random import randint
|
||||
|
||||
import gi
|
||||
|
||||
gi.require_version('Gtk', '3.0')
|
||||
from gi.repository import Gtk, GObject, Pango, GLib
|
||||
|
||||
from libremsonic.state_manager import ApplicationState
|
||||
from libremsonic.cache_manager import CacheManager
|
||||
from libremsonic.ui import util
|
||||
from .icon_button import IconButton
|
||||
from .spinner_image import SpinnerImage
|
||||
|
||||
from libremsonic.server.api_objects import (
|
||||
@@ -26,7 +29,7 @@ class AlbumWithSongs(Gtk.Box):
|
||||
'song-clicked': (
|
||||
GObject.SignalFlags.RUN_FIRST,
|
||||
GObject.TYPE_NONE,
|
||||
(str, object),
|
||||
(str, object, object),
|
||||
),
|
||||
}
|
||||
|
||||
@@ -60,13 +63,44 @@ class AlbumWithSongs(Gtk.Box):
|
||||
lambda f: GLib.idle_add(cover_art_future_done, f))
|
||||
|
||||
album_details = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
|
||||
album_details.add(
|
||||
album_title_and_buttons = Gtk.Box(
|
||||
orientation=Gtk.Orientation.HORIZONTAL)
|
||||
|
||||
# TODO: deal with super long-ass titles
|
||||
album_title_and_buttons.add(
|
||||
Gtk.Label(
|
||||
label=album.get('name', album.get('title')),
|
||||
name='artist-album-list-album-name',
|
||||
halign=Gtk.Align.START,
|
||||
))
|
||||
|
||||
self.play_btn = IconButton('media-playback-start-symbolic',
|
||||
sensitive=False)
|
||||
self.play_btn.connect('clicked', self.play_btn_clicked)
|
||||
album_title_and_buttons.pack_start(self.play_btn, False, False, 5)
|
||||
|
||||
self.shuffle_btn = IconButton('media-playlist-shuffle-symbolic',
|
||||
sensitive=False)
|
||||
self.shuffle_btn.connect('clicked', self.shuffle_btn_clicked)
|
||||
album_title_and_buttons.pack_start(self.shuffle_btn, False, False, 5)
|
||||
|
||||
self.play_next_btn = IconButton('go-top-symbolic',
|
||||
action_name='app.play-next')
|
||||
album_title_and_buttons.pack_start(self.play_next_btn, False, False, 5)
|
||||
|
||||
self.add_to_queue_btn = IconButton('go-jump-symbolic',
|
||||
action_name='app.add-to-queue')
|
||||
album_title_and_buttons.pack_start(self.add_to_queue_btn, False, False,
|
||||
5)
|
||||
|
||||
self.download_all_btn = IconButton('folder-download-symbolic',
|
||||
sensitive=False)
|
||||
self.download_all_btn.connect('clicked', self.on_download_all_click)
|
||||
album_title_and_buttons.pack_end(self.download_all_btn, False, False,
|
||||
5)
|
||||
|
||||
album_details.add(album_title_and_buttons)
|
||||
|
||||
stats = [
|
||||
album.artist if show_artist_name else None,
|
||||
album.year,
|
||||
@@ -144,6 +178,8 @@ class AlbumWithSongs(Gtk.Box):
|
||||
|
||||
self.update_album_songs(album.id)
|
||||
|
||||
# Event Handlers
|
||||
# =========================================================================
|
||||
def on_song_selection_change(self, event):
|
||||
if not self.album_songs.has_focus():
|
||||
self.emit('song-selected')
|
||||
@@ -152,7 +188,7 @@ class AlbumWithSongs(Gtk.Box):
|
||||
# The song ID is in the last column of the model.
|
||||
song_id = self.album_song_store[idx][-1]
|
||||
self.emit('song-clicked', song_id,
|
||||
[m[-1] for m in self.album_song_store])
|
||||
[m[-1] for m in self.album_song_store], {})
|
||||
|
||||
def on_song_button_press(self, tree, event):
|
||||
if event.button == 3: # Right click
|
||||
@@ -192,6 +228,34 @@ class AlbumWithSongs(Gtk.Box):
|
||||
if not allow_deselect:
|
||||
return True
|
||||
|
||||
def on_download_all_click(self, btn):
|
||||
CacheManager.batch_download_songs(
|
||||
[x[-1] for x in self.album_song_store],
|
||||
before_download=self.update,
|
||||
on_song_download_complete=lambda x: self.update(),
|
||||
)
|
||||
|
||||
def play_btn_clicked(self, btn):
|
||||
song_ids = [x[-1] for x in self.album_song_store]
|
||||
self.emit(
|
||||
'song-clicked',
|
||||
song_ids[0],
|
||||
song_ids,
|
||||
{'force_shuffle_state': False},
|
||||
)
|
||||
|
||||
def shuffle_btn_clicked(self, btn):
|
||||
rand_idx = randint(0, len(self.album_song_store) - 1)
|
||||
song_ids = [x[-1] for x in self.album_song_store]
|
||||
self.emit(
|
||||
'song-clicked',
|
||||
song_ids[rand_idx],
|
||||
song_ids,
|
||||
{'force_shuffle_state': True},
|
||||
)
|
||||
|
||||
# Helper Methods
|
||||
# =========================================================================
|
||||
def deselect_all(self):
|
||||
self.album_songs.get_selection().unselect_all()
|
||||
|
||||
@@ -206,13 +270,24 @@ class AlbumWithSongs(Gtk.Box):
|
||||
def update_album_songs(
|
||||
self,
|
||||
album: Union[AlbumWithSongsID3, Child, Directory],
|
||||
state: ApplicationState,
|
||||
):
|
||||
new_store = [[
|
||||
util.get_cached_status_icon(CacheManager.get_cached_status(song)),
|
||||
util.esc(song.title),
|
||||
util.format_song_duration(song.duration),
|
||||
song.id,
|
||||
] for song in album.get('child', album.get('song', []))]
|
||||
] for song in (album.get('child') or album.get('song') or [])]
|
||||
|
||||
song_ids = [song[-1] for song in new_store]
|
||||
|
||||
self.play_btn.set_sensitive(True)
|
||||
self.shuffle_btn.set_sensitive(True)
|
||||
self.play_next_btn.set_action_target_value(GLib.Variant(
|
||||
'as', song_ids))
|
||||
self.add_to_queue_btn.set_action_target_value(
|
||||
GLib.Variant('as', song_ids))
|
||||
self.download_all_btn.set_sensitive(True)
|
||||
|
||||
util.diff_store(self.album_song_store, new_store)
|
||||
self.loading_indicator.hide()
|
||||
|
@@ -15,7 +15,7 @@ class CoverArtGrid(Gtk.ScrolledWindow):
|
||||
'song-clicked': (
|
||||
GObject.SignalFlags.RUN_FIRST,
|
||||
GObject.TYPE_NONE,
|
||||
(str, object),
|
||||
(str, object, object),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -131,7 +131,6 @@ class CoverArtGrid(Gtk.ScrolledWindow):
|
||||
force_reload_from_master=(old_len != new_len or force))
|
||||
stop_loading()
|
||||
|
||||
print('update grid')
|
||||
future = self.get_model_list_future(
|
||||
before_download=start_loading,
|
||||
force=force,
|
||||
@@ -284,7 +283,8 @@ class CoverArtGrid(Gtk.ScrolledWindow):
|
||||
detail_element = self.create_detail_element_from_model(model)
|
||||
detail_element.connect(
|
||||
'song-clicked',
|
||||
lambda _, song, queue: self.emit('song-clicked', song, queue),
|
||||
lambda _, song, queue, metadata: self.emit(
|
||||
'song-clicked', song, queue, metadata),
|
||||
)
|
||||
detail_element.connect('song-selected', lambda *a: None)
|
||||
|
||||
|
33
libremsonic/ui/common/icon_button.py
Normal file
33
libremsonic/ui/common/icon_button.py
Normal file
@@ -0,0 +1,33 @@
|
||||
import gi
|
||||
gi.require_version('Gtk', '3.0')
|
||||
from gi.repository import Gio, Gtk
|
||||
|
||||
|
||||
class IconButton(Gtk.Button):
|
||||
def __init__(
|
||||
self,
|
||||
icon_name,
|
||||
relief=False,
|
||||
icon_size=Gtk.IconSize.BUTTON,
|
||||
label=None,
|
||||
**kwargs
|
||||
):
|
||||
Gtk.Button.__init__(self, **kwargs)
|
||||
self.icon_size = icon_size
|
||||
box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL,
|
||||
name='icon-button-box')
|
||||
|
||||
self.image = Gtk.Image()
|
||||
self.image.set_from_icon_name(icon_name, self.icon_size)
|
||||
box.add(self.image)
|
||||
|
||||
if label is not None:
|
||||
box.add(Gtk.Label(label=label))
|
||||
|
||||
if not relief:
|
||||
self.props.relief = Gtk.ReliefStyle.NONE
|
||||
|
||||
self.add(box)
|
||||
|
||||
def set_icon(self, icon_name):
|
||||
self.image.set_from_icon_name(icon_name, self.icon_size)
|
@@ -253,6 +253,8 @@ class ChromecastPlayer(Player):
|
||||
# Set host_ip
|
||||
# TODO should have a mechanism to update this. Maybe it should be
|
||||
# determined every time we try and play a song.
|
||||
# TODO does not work properyfly when on VPNs when the DNS is piped over
|
||||
# the VPN tunnel.
|
||||
try:
|
||||
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
s.connect(('8.8.8.8', 80))
|
||||
@@ -337,6 +339,7 @@ class ChromecastPlayer(Player):
|
||||
cover_art_url = CacheManager.get_cover_art_url(song.id, 1000)
|
||||
self.chromecast.media_controller.play_media(
|
||||
file_or_url,
|
||||
# Just pretend that whatever we send it is mp3, even if it isn't.
|
||||
'audio/mp3',
|
||||
current_time=progress,
|
||||
title=song.title,
|
||||
|
@@ -6,7 +6,7 @@ from gi.repository import Gtk, GObject
|
||||
|
||||
from libremsonic.server import Server
|
||||
from libremsonic.config import ServerConfiguration
|
||||
from libremsonic.ui.common import EditFormDialog
|
||||
from libremsonic.ui.common import EditFormDialog, IconButton
|
||||
|
||||
|
||||
class EditServerDialog(EditFormDialog):
|
||||
@@ -66,7 +66,7 @@ class EditServerDialog(EditFormDialog):
|
||||
)
|
||||
dialog.format_secondary_markup(
|
||||
f'Connection to {server_address} resulted in the following '
|
||||
'error:\n\n{err}')
|
||||
f'error:\n\n{err}')
|
||||
|
||||
dialog.run()
|
||||
dialog.destroy()
|
||||
@@ -94,7 +94,7 @@ class ConfigureServersDialog(Gtk.Dialog):
|
||||
|
||||
self.server_configs = config.servers
|
||||
self.selected_server_index = config.current_server
|
||||
self.set_default_size(450, 300)
|
||||
self.set_default_size(500, 300)
|
||||
|
||||
# Flow box to hold the server list and the buttons.
|
||||
flowbox = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
|
||||
@@ -110,15 +110,18 @@ class ConfigureServersDialog(Gtk.Dialog):
|
||||
# Add all of the buttons to the button box.
|
||||
self.buttons = [
|
||||
# TODO get good icons for these
|
||||
(Gtk.Button(label='Edit...'),
|
||||
lambda e: self.on_edit_clicked(e, False), 'start', True),
|
||||
(Gtk.Button(label='Add...'),
|
||||
(IconButton('document-edit-symbolic', label='Edit...',
|
||||
relief=True), lambda e: self.on_edit_clicked(e, False),
|
||||
'start', True),
|
||||
(IconButton('list-add', label='Add...', relief=True),
|
||||
lambda e: self.on_edit_clicked(e, True), 'start', False),
|
||||
(Gtk.Button(label='Remove'), self.on_remove_clicked, 'start',
|
||||
True),
|
||||
(Gtk.Button(label='Close'), lambda _: self.close(), 'end', False),
|
||||
(Gtk.Button(label='Connect'), self.on_connect_clicked, 'end',
|
||||
True),
|
||||
(IconButton('list-remove', label='Remove',
|
||||
relief=True), self.on_remove_clicked, 'start', True),
|
||||
(IconButton('window-close', label='Close',
|
||||
relief=True), lambda _: self.close(), 'end', False),
|
||||
(IconButton('network-transmit-receive',
|
||||
label='Connect',
|
||||
relief=True), self.on_connect_clicked, 'end', True),
|
||||
]
|
||||
for button_cfg in self.buttons:
|
||||
btn, action, pack_end, requires_selection = button_cfg
|
||||
|
@@ -12,8 +12,8 @@ from libremsonic.state_manager import ApplicationState
|
||||
class MainWindow(Gtk.ApplicationWindow):
|
||||
"""Defines the main window for LibremSonic."""
|
||||
__gsignals__ = {
|
||||
'song-clicked':
|
||||
(GObject.SignalFlags.RUN_FIRST, GObject.TYPE_NONE, (str, object)),
|
||||
'song-clicked': (GObject.SignalFlags.RUN_FIRST, GObject.TYPE_NONE,
|
||||
(str, object, object)),
|
||||
}
|
||||
|
||||
def __init__(self, *args, **kwargs):
|
||||
@@ -50,14 +50,16 @@ class MainWindow(Gtk.ApplicationWindow):
|
||||
self.connected_to_label.set_markup(
|
||||
f'<span style="italic">Not Connected to a Server</span>')
|
||||
|
||||
self.stack.set_visible_child_name(state.current_tab)
|
||||
|
||||
active_panel = self.stack.get_visible_child()
|
||||
if hasattr(active_panel, 'update'):
|
||||
active_panel.update(state)
|
||||
|
||||
self.player_controls.update(state)
|
||||
|
||||
def on_song_clicked(self, panel, song, queue):
|
||||
self.emit('song-clicked', song, queue)
|
||||
def on_song_clicked(self, panel, song, queue, metadata):
|
||||
self.emit('song-clicked', song, queue, metadata)
|
||||
|
||||
def create_stack(self, **kwargs):
|
||||
stack = Gtk.Stack()
|
||||
|
@@ -7,7 +7,7 @@ from gi.repository import Gtk, Pango, GObject, Gio, GLib
|
||||
from libremsonic.cache_manager import CacheManager
|
||||
from libremsonic.state_manager import ApplicationState, RepeatType
|
||||
from libremsonic.ui import util
|
||||
from libremsonic.ui.common import SpinnerImage
|
||||
from libremsonic.ui.common import IconButton, SpinnerImage
|
||||
from libremsonic.ui.common.players import ChromecastPlayer
|
||||
|
||||
|
||||
@@ -41,8 +41,7 @@ class PlayerControls(Gtk.ActionBar):
|
||||
state.current_song.duration)
|
||||
|
||||
icon = 'pause' if state.playing else 'start'
|
||||
self.play_button.get_child().set_from_icon_name(
|
||||
f"media-playback-{icon}-symbolic", Gtk.IconSize.LARGE_TOOLBAR)
|
||||
self.play_button.set_icon(f"media-playback-{icon}-symbolic")
|
||||
|
||||
has_current_song = (hasattr(state, 'current_song')
|
||||
and state.current_song is not None)
|
||||
@@ -58,22 +57,14 @@ class PlayerControls(Gtk.ActionBar):
|
||||
# TODO: it's not correct to use symboloc vs. not symbolic icons for
|
||||
# lighter/darker versions of the icon. Fix this by using FG color I
|
||||
# think? But then we have to deal with styling, which sucks.
|
||||
icon = Gio.ThemedIcon(name=state.repeat_type.icon)
|
||||
image = Gtk.Image.new_from_gicon(icon, Gtk.IconSize.BUTTON)
|
||||
self.repeat_button.remove(self.repeat_button.get_child())
|
||||
self.repeat_button.add(image)
|
||||
self.repeat_button.show_all()
|
||||
self.repeat_button.set_icon(state.repeat_type.icon)
|
||||
|
||||
# Shuffle button state
|
||||
# TODO: it's not correct to use symboloc vs. not symbolic icons for
|
||||
# lighter/darker versions of the icon. Fix this by using FG color I
|
||||
# think? But then we have to deal with styling, which sucks.
|
||||
icon = Gio.ThemedIcon(name='media-playlist-shuffle'
|
||||
+ ('-symbolic' if state.shuffle_on else ''))
|
||||
image = Gtk.Image.new_from_gicon(icon, Gtk.IconSize.BUTTON)
|
||||
self.shuffle_button.remove(self.shuffle_button.get_child())
|
||||
self.shuffle_button.add(image)
|
||||
self.shuffle_button.show_all()
|
||||
self.shuffle_button.set_icon('media-playlist-shuffle' +
|
||||
('-symbolic' if state.shuffle_on else ''))
|
||||
|
||||
self.song_scrubber.set_sensitive(has_current_song)
|
||||
self.prev_button.set_sensitive(has_current_song)
|
||||
@@ -118,7 +109,8 @@ class PlayerControls(Gtk.ActionBar):
|
||||
else:
|
||||
song_label = str(play_queue_len) + ' ' + util.pluralize(
|
||||
'song', play_queue_len)
|
||||
self.popover_label.set_markup(f'<b>Play Queue:</b> {song_label}')
|
||||
self.popover_label.set_markup(
|
||||
f'<b>Play Queue:</b> {song_label}')
|
||||
|
||||
# Remove everything from the play queue.
|
||||
for c in self.play_queue_list.get_children():
|
||||
@@ -159,7 +151,7 @@ class PlayerControls(Gtk.ActionBar):
|
||||
before_download=lambda self: self.album_art.set_loading(True),
|
||||
on_failure=lambda self, e: self.album_art.set_loading(False),
|
||||
)
|
||||
def update_cover_art(self, cover_art_filename):
|
||||
def update_cover_art(self, cover_art_filename, state):
|
||||
self.album_art.set_from_file(cover_art_filename)
|
||||
self.album_art.set_loading(False)
|
||||
|
||||
@@ -285,35 +277,32 @@ class PlayerControls(Gtk.ActionBar):
|
||||
buttons.pack_start(Gtk.Box(), True, True, 0)
|
||||
|
||||
# Repeat button
|
||||
self.repeat_button = util.button_with_icon('media-playlist-repeat')
|
||||
self.repeat_button = IconButton('media-playlist-repeat')
|
||||
self.repeat_button.set_action_name('app.repeat-press')
|
||||
buttons.pack_start(self.repeat_button, False, False, 5)
|
||||
|
||||
# Previous button
|
||||
self.prev_button = util.button_with_icon(
|
||||
'media-skip-backward-symbolic',
|
||||
icon_size=Gtk.IconSize.LARGE_TOOLBAR)
|
||||
self.prev_button = IconButton('media-skip-backward-symbolic',
|
||||
icon_size=Gtk.IconSize.LARGE_TOOLBAR)
|
||||
self.prev_button.set_action_name('app.prev-track')
|
||||
buttons.pack_start(self.prev_button, False, False, 5)
|
||||
|
||||
# Play button
|
||||
self.play_button = util.button_with_icon(
|
||||
'media-playback-start-symbolic',
|
||||
relief=True,
|
||||
icon_size=Gtk.IconSize.LARGE_TOOLBAR)
|
||||
self.play_button = IconButton('media-playback-start-symbolic',
|
||||
relief=True,
|
||||
icon_size=Gtk.IconSize.LARGE_TOOLBAR)
|
||||
self.play_button.set_name('play-button')
|
||||
self.play_button.set_action_name('app.play-pause')
|
||||
buttons.pack_start(self.play_button, False, False, 0)
|
||||
|
||||
# Next button
|
||||
self.next_button = util.button_with_icon(
|
||||
'media-skip-forward-symbolic',
|
||||
icon_size=Gtk.IconSize.LARGE_TOOLBAR)
|
||||
self.next_button = IconButton('media-skip-forward-symbolic',
|
||||
icon_size=Gtk.IconSize.LARGE_TOOLBAR)
|
||||
self.next_button.set_action_name('app.next-track')
|
||||
buttons.pack_start(self.next_button, False, False, 5)
|
||||
|
||||
# Shuffle button
|
||||
self.shuffle_button = util.button_with_icon('media-playlist-shuffle')
|
||||
self.shuffle_button = IconButton('media-playlist-shuffle')
|
||||
self.shuffle_button.set_action_name('app.shuffle-press')
|
||||
buttons.pack_start(self.shuffle_button, False, False, 5)
|
||||
|
||||
@@ -329,8 +318,8 @@ class PlayerControls(Gtk.ActionBar):
|
||||
|
||||
# Device button (for chromecast)
|
||||
# TODO need icon
|
||||
device_button = util.button_with_icon(
|
||||
'view-list-symbolic', icon_size=Gtk.IconSize.LARGE_TOOLBAR)
|
||||
device_button = IconButton('view-list-symbolic',
|
||||
icon_size=Gtk.IconSize.LARGE_TOOLBAR)
|
||||
device_button.connect('clicked', self.on_device_click)
|
||||
box.pack_start(device_button, False, True, 5)
|
||||
|
||||
@@ -347,7 +336,7 @@ class PlayerControls(Gtk.ActionBar):
|
||||
)
|
||||
device_popover_header.add(self.popover_label)
|
||||
|
||||
refresh_devices = util.button_with_icon('view-refresh')
|
||||
refresh_devices = IconButton('view-refresh')
|
||||
refresh_devices.connect('clicked', self.on_device_refresh_click)
|
||||
device_popover_header.pack_end(refresh_devices, False, False, 0)
|
||||
|
||||
@@ -376,8 +365,8 @@ class PlayerControls(Gtk.ActionBar):
|
||||
self.device_popover.add(device_popover_box)
|
||||
|
||||
# Play Queue button
|
||||
play_queue_button = util.button_with_icon(
|
||||
'view-list-symbolic', icon_size=Gtk.IconSize.LARGE_TOOLBAR)
|
||||
play_queue_button = IconButton('view-list-symbolic',
|
||||
icon_size=Gtk.IconSize.LARGE_TOOLBAR)
|
||||
play_queue_button.connect('clicked', self.on_play_queue_click)
|
||||
box.pack_start(play_queue_button, False, True, 5)
|
||||
|
||||
@@ -412,7 +401,7 @@ class PlayerControls(Gtk.ActionBar):
|
||||
self.play_queue_popover.add(play_queue_popover_box)
|
||||
|
||||
# Volume mute toggle
|
||||
self.volume_mute_toggle = util.button_with_icon('audio-volume-high')
|
||||
self.volume_mute_toggle = IconButton('audio-volume-high')
|
||||
self.volume_mute_toggle.set_action_name('app.mute-toggle')
|
||||
box.pack_start(self.volume_mute_toggle, False, True, 0)
|
||||
|
||||
|
@@ -1,4 +1,5 @@
|
||||
from functools import lru_cache
|
||||
from random import randint
|
||||
from typing import List, OrderedDict
|
||||
|
||||
from fuzzywuzzy import process
|
||||
@@ -11,7 +12,7 @@ from libremsonic.server.api_objects import PlaylistWithSongs
|
||||
from libremsonic.state_manager import ApplicationState
|
||||
from libremsonic.cache_manager import CacheManager
|
||||
from libremsonic.ui import util
|
||||
from libremsonic.ui.common import EditFormDialog, SpinnerImage
|
||||
from libremsonic.ui.common import EditFormDialog, IconButton, SpinnerImage
|
||||
|
||||
|
||||
class EditPlaylistDialog(EditFormDialog):
|
||||
@@ -38,8 +39,8 @@ class EditPlaylistDialog(EditFormDialog):
|
||||
class PlaylistsPanel(Gtk.Paned):
|
||||
"""Defines the playlists panel."""
|
||||
__gsignals__ = {
|
||||
'song-clicked':
|
||||
(GObject.SignalFlags.RUN_FIRST, GObject.TYPE_NONE, (str, object)),
|
||||
'song-clicked': (GObject.SignalFlags.RUN_FIRST, GObject.TYPE_NONE,
|
||||
(str, object, object)),
|
||||
}
|
||||
|
||||
playlist_map: OrderedDict[int, PlaylistWithSongs] = {}
|
||||
@@ -57,17 +58,14 @@ class PlaylistsPanel(Gtk.Paned):
|
||||
|
||||
playlist_list_actions = Gtk.ActionBar()
|
||||
|
||||
self.new_playlist = Gtk.Button(relief=Gtk.ReliefStyle.NONE)
|
||||
box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
|
||||
add_icon = Gio.ThemedIcon(name='list-add')
|
||||
image = Gtk.Image.new_from_gicon(add_icon, Gtk.IconSize.LARGE_TOOLBAR)
|
||||
box.add(image)
|
||||
box.add(Gtk.Label(label='New Playlist', margin=5))
|
||||
self.new_playlist.add(box)
|
||||
self.new_playlist = IconButton(
|
||||
icon_name='list-add',
|
||||
label='New Playlist',
|
||||
)
|
||||
self.new_playlist.connect('clicked', self.on_new_playlist_clicked)
|
||||
playlist_list_actions.pack_start(self.new_playlist)
|
||||
|
||||
list_refresh_button = util.button_with_icon('view-refresh')
|
||||
list_refresh_button = IconButton('view-refresh')
|
||||
list_refresh_button.connect('clicked', self.on_list_refresh_click)
|
||||
playlist_list_actions.pack_end(list_refresh_button)
|
||||
|
||||
@@ -150,18 +148,18 @@ class PlaylistsPanel(Gtk.Paned):
|
||||
self.playlist_action_buttons = Gtk.Box(
|
||||
orientation=Gtk.Orientation.HORIZONTAL)
|
||||
|
||||
view_refresh_button = util.button_with_icon('view-refresh-symbolic')
|
||||
view_refresh_button = IconButton('view-refresh-symbolic')
|
||||
view_refresh_button.connect('clicked', self.on_view_refresh_click)
|
||||
self.playlist_action_buttons.pack_end(view_refresh_button, False,
|
||||
False, 5)
|
||||
|
||||
playlist_edit_button = util.button_with_icon('document-edit-symbolic')
|
||||
playlist_edit_button = IconButton('document-edit-symbolic')
|
||||
playlist_edit_button.connect('clicked',
|
||||
self.on_playlist_edit_button_click)
|
||||
self.playlist_action_buttons.pack_end(playlist_edit_button, False,
|
||||
False, 5)
|
||||
|
||||
download_all_button = util.button_with_icon('folder-download-symbolic')
|
||||
download_all_button = IconButton('folder-download-symbolic')
|
||||
download_all_button.connect(
|
||||
'clicked', self.on_playlist_list_download_all_button_click)
|
||||
self.playlist_action_buttons.pack_end(download_all_button, False,
|
||||
@@ -184,6 +182,29 @@ class PlaylistsPanel(Gtk.Paned):
|
||||
self.playlist_stats = self.make_label(name='playlist-stats')
|
||||
playlist_details_box.add(self.playlist_stats)
|
||||
|
||||
self.play_shuffle_buttons = Gtk.Box(
|
||||
orientation=Gtk.Orientation.HORIZONTAL,
|
||||
name='playlist-play-shuffle-buttons',
|
||||
)
|
||||
|
||||
play_button = IconButton(
|
||||
'media-playback-start-symbolic',
|
||||
label='Play All',
|
||||
relief=True,
|
||||
)
|
||||
play_button.connect('clicked', self.on_play_all_clicked)
|
||||
self.play_shuffle_buttons.pack_start(play_button, False, False, 0)
|
||||
|
||||
shuffle_button = IconButton(
|
||||
'media-playlist-shuffle-symbolic',
|
||||
label='Shuffle All',
|
||||
relief=True,
|
||||
)
|
||||
shuffle_button.connect('clicked', self.on_shuffle_all_button)
|
||||
self.play_shuffle_buttons.pack_start(shuffle_button, False, False, 5)
|
||||
|
||||
playlist_details_box.add(self.play_shuffle_buttons)
|
||||
|
||||
self.big_info_panel.pack_start(playlist_details_box, True, True, 10)
|
||||
|
||||
playlist_box.pack_start(self.big_info_panel, False, True, 0)
|
||||
@@ -351,11 +372,29 @@ class PlaylistsPanel(Gtk.Paned):
|
||||
self.playlist_list.get_selected_row().get_index()]
|
||||
self.update_playlist_view(playlist.id, force=True)
|
||||
|
||||
def on_play_all_clicked(self, btn):
|
||||
song_id = self.playlist_song_store[0][-1]
|
||||
self.emit('song-clicked', song_id,
|
||||
[m[-1] for m in self.playlist_song_store],
|
||||
{'force_shuffle_state': False})
|
||||
|
||||
def on_shuffle_all_button(self, btn):
|
||||
rand_idx = randint(0, len(self.playlist_song_store) - 1)
|
||||
self.emit(
|
||||
'song-clicked',
|
||||
self.playlist_song_store[rand_idx][-1],
|
||||
[m[-1] for m in self.playlist_song_store],
|
||||
{'force_shuffle_state': True},
|
||||
)
|
||||
|
||||
def on_song_activated(self, treeview, idx, column):
|
||||
# The song ID is in the last column of the model.
|
||||
song_id = self.playlist_song_store[idx][-1]
|
||||
self.emit('song-clicked', song_id,
|
||||
[m[-1] for m in self.playlist_song_store])
|
||||
self.emit(
|
||||
'song-clicked',
|
||||
self.playlist_song_store[idx][-1],
|
||||
[m[-1] for m in self.playlist_song_store],
|
||||
{},
|
||||
)
|
||||
|
||||
def on_song_button_press(self, tree, event):
|
||||
if event.button == 3: # Right click
|
||||
@@ -456,8 +495,10 @@ class PlaylistsPanel(Gtk.Paned):
|
||||
playlist_id = self.playlist_map[selected.get_index()].id
|
||||
self.update_playlist_view(playlist_id)
|
||||
self.playlist_action_buttons.show()
|
||||
self.play_shuffle_buttons.show()
|
||||
else:
|
||||
self.playlist_action_buttons.hide()
|
||||
self.play_shuffle_buttons.hide()
|
||||
|
||||
self.playlist_songs.set_headers_visible(state.config.show_headers)
|
||||
|
||||
@@ -489,7 +530,11 @@ class PlaylistsPanel(Gtk.Paned):
|
||||
before_download=lambda self: self.set_playlist_list_loading(True),
|
||||
on_failure=lambda self, e: self.set_playlist_list_loading(False),
|
||||
)
|
||||
def update_playlist_list(self, playlists: List[PlaylistWithSongs]):
|
||||
def update_playlist_list(
|
||||
self,
|
||||
playlists: List[PlaylistWithSongs],
|
||||
state: ApplicationState,
|
||||
):
|
||||
selected_row = self.playlist_list.get_selected_row()
|
||||
selected_playlist = None
|
||||
if selected_row:
|
||||
@@ -522,7 +567,7 @@ class PlaylistsPanel(Gtk.Paned):
|
||||
on_failure=lambda self, e: (self.set_playlist_view_loading(False) or
|
||||
self.playlist_artwork.set_loading(False)),
|
||||
)
|
||||
def update_playlist_view(self, playlist):
|
||||
def update_playlist_view(self, playlist, state: ApplicationState):
|
||||
# Update the Playlist Info panel
|
||||
self.update_playlist_artwork(playlist.coverArt)
|
||||
self.playlist_indicator.set_markup('PLAYLIST')
|
||||
@@ -536,11 +581,12 @@ class PlaylistsPanel(Gtk.Paned):
|
||||
|
||||
self.update_playlist_song_list(playlist.id)
|
||||
self.playlist_action_buttons.show()
|
||||
self.play_shuffle_buttons.show()
|
||||
|
||||
@util.async_callback(
|
||||
lambda *a, **k: CacheManager.get_playlist(*a, **k),
|
||||
)
|
||||
def update_playlist_song_list(self, playlist):
|
||||
def update_playlist_song_list(self, playlist, state: ApplicationState):
|
||||
# Update the song list model. This requires some fancy diffing to
|
||||
# update the list.
|
||||
self.editing_playlist_song_list = True
|
||||
@@ -564,7 +610,11 @@ class PlaylistsPanel(Gtk.Paned):
|
||||
before_download=lambda self: self.playlist_artwork.set_loading(True),
|
||||
on_failure=lambda self, e: self.playlist_artwork.set_loading(False),
|
||||
)
|
||||
def update_playlist_artwork(self, cover_art_filename):
|
||||
def update_playlist_artwork(
|
||||
self,
|
||||
cover_art_filename,
|
||||
state: ApplicationState,
|
||||
):
|
||||
self.playlist_artwork.set_from_file(cover_art_filename)
|
||||
self.playlist_artwork.set_loading(False)
|
||||
|
||||
@@ -572,7 +622,7 @@ class PlaylistsPanel(Gtk.Paned):
|
||||
lambda *a, **k: CacheManager.get_playlist(*a, **k),
|
||||
# TODO make loading here
|
||||
)
|
||||
def update_playlist_order(self, playlist):
|
||||
def update_playlist_order(self, playlist, state: ApplicationState):
|
||||
CacheManager.update_playlist(
|
||||
playlist_id=playlist.id,
|
||||
song_index_to_remove=list(range(playlist.songCount)),
|
||||
|
@@ -8,27 +8,11 @@ from deepdiff import DeepDiff
|
||||
|
||||
import gi
|
||||
gi.require_version('Gtk', '3.0')
|
||||
from gi.repository import Gio, Gtk, GLib, Gdk
|
||||
from gi.repository import Gtk, GLib, Gdk
|
||||
|
||||
from libremsonic.cache_manager import CacheManager, SongCacheStatus
|
||||
|
||||
|
||||
def button_with_icon(
|
||||
icon_name,
|
||||
relief=False,
|
||||
icon_size=Gtk.IconSize.BUTTON,
|
||||
) -> Gtk.Button:
|
||||
button = Gtk.Button()
|
||||
icon = Gio.ThemedIcon(name=icon_name)
|
||||
image = Gtk.Image.new_from_gicon(icon, icon_size)
|
||||
button.add(image)
|
||||
|
||||
if not relief:
|
||||
button.props.relief = Gtk.ReliefStyle.NONE
|
||||
|
||||
return button
|
||||
|
||||
|
||||
def format_song_duration(duration_secs) -> str:
|
||||
return f'{duration_secs // 60}:{duration_secs % 60:02}'
|
||||
|
||||
@@ -119,18 +103,6 @@ def show_song_popover(
|
||||
show_remove_from_playlist_button: bool = False,
|
||||
extra_menu_items: List[Tuple[Gtk.ModelButton, Any]] = [],
|
||||
):
|
||||
def on_play_next_click(button):
|
||||
print('play next click')
|
||||
|
||||
def on_add_to_queue_click(button):
|
||||
print('add to queue click')
|
||||
|
||||
def on_go_to_album_click(button):
|
||||
print('go to album click')
|
||||
|
||||
def on_go_to_artist_click(button):
|
||||
print('go to artist click')
|
||||
|
||||
def on_download_songs_click(button):
|
||||
CacheManager.batch_download_songs(
|
||||
song_ids,
|
||||
@@ -159,9 +131,13 @@ def show_song_popover(
|
||||
|
||||
# Determine if we should enable the download button.
|
||||
download_sensitive, remove_download_sensitive = False, False
|
||||
albums, artists = set(), set()
|
||||
for song_id in song_ids:
|
||||
details = CacheManager.get_song_details(song_id).result()
|
||||
status = CacheManager.get_cached_status(details)
|
||||
albums.add(details.albumId)
|
||||
artists.add(details.artistId)
|
||||
|
||||
if download_sensitive or status == SongCacheStatus.NOT_CACHED:
|
||||
download_sensitive = True
|
||||
|
||||
@@ -171,18 +147,32 @@ def show_song_popover(
|
||||
remove_download_sensitive = True
|
||||
|
||||
menu_items = [
|
||||
(Gtk.ModelButton(text='Play next'), on_play_next_click),
|
||||
(Gtk.ModelButton(text='Add to queue'), on_add_to_queue_click),
|
||||
(Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL), None),
|
||||
(
|
||||
Gtk.ModelButton(text='Go to album', sensitive=len(song_ids) == 1),
|
||||
on_go_to_album_click,
|
||||
Gtk.ModelButton(
|
||||
text='Play next',
|
||||
action_name='app.play-next',
|
||||
action_target=GLib.Variant('as', song_ids),
|
||||
),
|
||||
(
|
||||
Gtk.ModelButton(text='Go to artist', sensitive=len(song_ids) == 1),
|
||||
on_go_to_artist_click,
|
||||
Gtk.ModelButton(
|
||||
text='Add to queue',
|
||||
action_name='app.add-to-queue',
|
||||
action_target=GLib.Variant('as', song_ids),
|
||||
),
|
||||
(Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL), None),
|
||||
Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL),
|
||||
Gtk.ModelButton(
|
||||
text='Go to album',
|
||||
action_name='app.go-to-album',
|
||||
action_target=GLib.Variant('s',
|
||||
list(albums)[0]),
|
||||
sensitive=len(albums) == 1,
|
||||
),
|
||||
Gtk.ModelButton(
|
||||
text='Go to artist',
|
||||
action_name='app.go-to-artist',
|
||||
action_target=GLib.Variant('s',
|
||||
list(artists)[0]),
|
||||
sensitive=len(artists) == 1,
|
||||
),
|
||||
Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL),
|
||||
(
|
||||
Gtk.ModelButton(
|
||||
text=f"Download {pluralize('song', song_count)}",
|
||||
@@ -197,23 +187,23 @@ def show_song_popover(
|
||||
),
|
||||
on_remove_downloads_click,
|
||||
),
|
||||
(Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL), None),
|
||||
(
|
||||
Gtk.ModelButton(
|
||||
text=f"Add {pluralize('song', song_count)} to playlist",
|
||||
menu_name='add-to-playlist',
|
||||
),
|
||||
None,
|
||||
Gtk.Separator(orientation=Gtk.Orientation.HORIZONTAL),
|
||||
Gtk.ModelButton(
|
||||
text=f"Add {pluralize('song', song_count)} to playlist",
|
||||
menu_name='add-to-playlist',
|
||||
),
|
||||
*extra_menu_items,
|
||||
]
|
||||
|
||||
for item, action in menu_items:
|
||||
if action:
|
||||
item.connect('clicked', action)
|
||||
if type(item) == Gtk.ModelButton:
|
||||
for item in menu_items:
|
||||
if type(item) == tuple:
|
||||
el, fn = item
|
||||
el.connect('clicked', fn)
|
||||
el.get_style_context().add_class('menu-button')
|
||||
vbox.pack_start(item[0], False, True, 0)
|
||||
else:
|
||||
item.get_style_context().add_class('menu-button')
|
||||
vbox.pack_start(item, False, True, 0)
|
||||
vbox.pack_start(item, False, True, 0)
|
||||
|
||||
popover.add(vbox)
|
||||
|
||||
@@ -249,7 +239,11 @@ def show_song_popover(
|
||||
popover.show_all()
|
||||
|
||||
|
||||
def async_callback(future_fn, before_download=None, on_failure=None):
|
||||
def async_callback(
|
||||
future_fn,
|
||||
before_download=None,
|
||||
on_failure=None,
|
||||
):
|
||||
"""
|
||||
Defines the ``async_callback`` decorator.
|
||||
|
||||
@@ -263,7 +257,7 @@ def async_callback(future_fn, before_download=None, on_failure=None):
|
||||
"""
|
||||
def decorator(callback_fn):
|
||||
@functools.wraps(callback_fn)
|
||||
def wrapper(self, *args, **kwargs):
|
||||
def wrapper(self, *args, state=None, **kwargs):
|
||||
if before_download:
|
||||
on_before_download = (
|
||||
lambda: GLib.idle_add(before_download, self))
|
||||
@@ -278,7 +272,7 @@ def async_callback(future_fn, before_download=None, on_failure=None):
|
||||
on_failure(self, e)
|
||||
return
|
||||
|
||||
return GLib.idle_add(callback_fn, self, result)
|
||||
return GLib.idle_add(callback_fn, self, result, state)
|
||||
|
||||
future: Future = future_fn(
|
||||
*args,
|
||||
|
Reference in New Issue
Block a user