Up next working!
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
import os
|
||||
import random
|
||||
|
||||
import mpv
|
||||
|
||||
@@ -125,20 +126,19 @@ class LibremsonicApp(Gtk.Application):
|
||||
if (self.state.config.current_server is None
|
||||
or self.state.config.current_server < 0):
|
||||
self.show_configure_servers_dialog()
|
||||
else:
|
||||
self.on_connected_server_changed(
|
||||
None,
|
||||
self.state.config.current_server,
|
||||
)
|
||||
|
||||
# ########## ACTION HANDLERS ########## #
|
||||
def on_configure_servers(self, action, param):
|
||||
self.show_configure_servers_dialog()
|
||||
|
||||
def on_play_pause(self, action, param):
|
||||
self.player.cycle('pause')
|
||||
self.state.playing = not self.state.playing
|
||||
if self.player.time_pos is None:
|
||||
# This is from a restart, start playing the file.
|
||||
self.play_song(self.state.current_song.id)
|
||||
else:
|
||||
self.player.cycle('pause')
|
||||
|
||||
self.state.playing = not self.state.playing
|
||||
self.update_window()
|
||||
|
||||
def on_next_track(self, action, params):
|
||||
@@ -177,6 +177,18 @@ class LibremsonicApp(Gtk.Application):
|
||||
self.update_window()
|
||||
|
||||
def on_shuffle_press(self, action, params):
|
||||
if self.state.shuffle_on:
|
||||
# Revert to the old play queue.
|
||||
self.state.play_queue = self.state.old_play_queue
|
||||
else:
|
||||
self.state.old_play_queue = self.state.play_queue.copy()
|
||||
|
||||
# Remove the current song, then shuffle and put the song back.
|
||||
song_id = self.state.current_song.id
|
||||
self.state.play_queue.remove(song_id)
|
||||
random.shuffle(self.state.play_queue)
|
||||
self.state.play_queue = [song_id] + self.state.play_queue
|
||||
|
||||
self.state.shuffle_on = not self.state.shuffle_on
|
||||
self.update_window()
|
||||
|
||||
@@ -247,8 +259,6 @@ class LibremsonicApp(Gtk.Application):
|
||||
lambda *a, **k: CacheManager.get_song_details(*a, **k),
|
||||
)
|
||||
def play_song(self, song: Child):
|
||||
self.play_song(song)
|
||||
|
||||
# Do this the old fashioned way so that we can have access to ``song``
|
||||
# in the callback.
|
||||
song_filename_future = CacheManager.get_song_filename(
|
||||
|
@@ -86,18 +86,19 @@ class CacheManager(metaclass=Singleton):
|
||||
return
|
||||
|
||||
self.playlists = [
|
||||
Playlist.from_json(p) for p in meta_json.get('playlists', [])
|
||||
Playlist.from_json(p)
|
||||
for p in meta_json.get('playlists') or []
|
||||
]
|
||||
self.playlist_details = {
|
||||
id: PlaylistWithSongs.from_json(v)
|
||||
for id, v in meta_json.get('playlist_details', {}).items()
|
||||
for id, v in (meta_json.get('playlist_details') or {}).items()
|
||||
}
|
||||
self.song_details = {
|
||||
id: Child.from_json(v)
|
||||
for id, v in meta_json.get('song_details', {}).items()
|
||||
for id, v in (meta_json.get('song_details') or {}).items()
|
||||
}
|
||||
self.permanently_cached_paths = set(
|
||||
meta_json.get('permanently_cached_paths', []))
|
||||
meta_json.get('permanently_cached_paths') or [])
|
||||
|
||||
def save_cache_info(self):
|
||||
os.makedirs(self.app_config.cache_location, exist_ok=True)
|
||||
@@ -184,7 +185,7 @@ class CacheManager(metaclass=Singleton):
|
||||
self.playlist_details[playlist_id] = playlist
|
||||
|
||||
# Playlists also have song details, so save that as well.
|
||||
for song in playlist.entry:
|
||||
for song in (playlist.entry or []):
|
||||
self.song_details[song.id] = song
|
||||
|
||||
self.save_cache_info()
|
||||
|
@@ -38,7 +38,7 @@ class AppConfiguration:
|
||||
servers: List[ServerConfiguration]
|
||||
current_server: int = -1
|
||||
_cache_location: str = ''
|
||||
max_cache_size_mb: int # -1 means unlimited
|
||||
max_cache_size_mb: int # -1 means unlimited
|
||||
|
||||
def to_json(self):
|
||||
return {
|
||||
@@ -59,8 +59,9 @@ class AppConfiguration:
|
||||
@property
|
||||
def cache_location(self):
|
||||
if (hasattr(self, '_cache_location')
|
||||
and self._cache_location is not None):
|
||||
return self.cache_location
|
||||
and self._cache_location is not None
|
||||
and self._cache_location != ''):
|
||||
return self._cache_location
|
||||
else:
|
||||
default_cache_location = (os.environ.get('XDG_DATA_HOME')
|
||||
or os.path.expanduser('~/.local/share'))
|
||||
|
@@ -5,6 +5,7 @@ from typing import List
|
||||
|
||||
from libremsonic.from_json import from_json
|
||||
from .config import AppConfiguration
|
||||
from .cache_manager import CacheManager
|
||||
from .server.api_objects import Child
|
||||
|
||||
|
||||
@@ -42,6 +43,7 @@ class ApplicationState:
|
||||
config_file: str
|
||||
playing: bool = False
|
||||
play_queue: List[str]
|
||||
old_play_queue: List[str]
|
||||
volume: int = 100
|
||||
old_volume: int = 100
|
||||
repeat_type: RepeatType = RepeatType.NO_REPEAT
|
||||
@@ -49,8 +51,11 @@ class ApplicationState:
|
||||
|
||||
def to_json(self):
|
||||
return {
|
||||
# 'current_song': getattr(self, 'current_song', None),
|
||||
'current_song': (self.current_song.id if
|
||||
(hasattr(self, 'current_song')
|
||||
and self.current_song is not None) else None),
|
||||
'play_queue': getattr(self, 'play_queue', None),
|
||||
'old_play_queue': getattr(self, 'old_play_queue', None),
|
||||
'volume': getattr(self, 'volume', None),
|
||||
'repeat_type': getattr(self, 'repeat_type',
|
||||
RepeatType.NO_REPEAT).value,
|
||||
@@ -58,8 +63,14 @@ class ApplicationState:
|
||||
}
|
||||
|
||||
def load_from_json(self, json_object):
|
||||
# self.current_song = json_object.get('current_song') or None
|
||||
current_song_id = json_object.get('current_song') or None
|
||||
if current_song_id:
|
||||
self.current_song = CacheManager.song_details.get(current_song_id)
|
||||
else:
|
||||
self.current_song = None
|
||||
|
||||
self.play_queue = json_object.get('play_queue') or []
|
||||
self.old_play_queue = json_object.get('old_play_queue') or []
|
||||
self.volume = json_object.get('volume') or 100
|
||||
self.repeat_type = (RepeatType(json_object.get('repeat_type'))
|
||||
or RepeatType.NO_REPEAT)
|
||||
@@ -68,6 +79,14 @@ class ApplicationState:
|
||||
def load(self):
|
||||
self.config = self.get_config(self.config_file)
|
||||
|
||||
if self.config.current_server >= 0:
|
||||
# Reset the CacheManager.
|
||||
CacheManager.reset(
|
||||
self.config,
|
||||
self.config.servers[self.config.current_server]
|
||||
if self.config.current_server >= 0 else None,
|
||||
)
|
||||
|
||||
if os.path.exists(self.state_filename):
|
||||
with open(self.state_filename, 'r') as f:
|
||||
try:
|
||||
|
@@ -10,13 +10,15 @@
|
||||
}
|
||||
|
||||
#playlist-list-new-playlist-entry {
|
||||
margin: 10px;
|
||||
margin-right: 0;
|
||||
margin: 10px 10px 5px 10px;
|
||||
}
|
||||
|
||||
#playlist-list-new-playlist-cancel {
|
||||
margin: 5px 0 10px 0;
|
||||
}
|
||||
|
||||
#playlist-list-new-playlist-confirm {
|
||||
margin: 10px;
|
||||
margin-left: 0;
|
||||
margin: 5px 10px 10px 0;
|
||||
}
|
||||
|
||||
#playlist-artwork-spinner {
|
||||
@@ -44,13 +46,6 @@
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
#new-playlist-button {
|
||||
border-radius: 0;
|
||||
border-left: none;
|
||||
border-right: none;
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
/* ********** Playback Controls ********** */
|
||||
#player-controls-album-artwork #player-controls-album-artwork {
|
||||
min-height: 70px;
|
||||
@@ -89,4 +84,9 @@
|
||||
|
||||
#player-controls-bar #album-name {
|
||||
margin-bottom: 3px;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
#up-next-popover #label {
|
||||
margin: 10px;
|
||||
}
|
||||
|
@@ -1,9 +1,10 @@
|
||||
import math
|
||||
import concurrent
|
||||
|
||||
import gi
|
||||
|
||||
gi.require_version('Gtk', '3.0')
|
||||
from gi.repository import Gtk, Pango, GObject, Gio
|
||||
from gi.repository import Gtk, Pango, GObject, Gio, GLib
|
||||
|
||||
from libremsonic.state_manager import ApplicationState, RepeatType
|
||||
from libremsonic.cache_manager import CacheManager
|
||||
@@ -94,18 +95,57 @@ class PlayerControls(Gtk.ActionBar):
|
||||
|
||||
self.volume_slider.set_value(state.volume)
|
||||
|
||||
if not has_current_song:
|
||||
# TODO should probably clear out the cover art display?
|
||||
return
|
||||
# Update the current song information.
|
||||
if has_current_song:
|
||||
# TODO should probably clear out the cover art display if no song??
|
||||
self.update_cover_art(state.current_song.coverArt, size='70')
|
||||
|
||||
self.update_cover_art(state.current_song.coverArt, size='70')
|
||||
self.song_title.set_text(util.esc(state.current_song.title))
|
||||
self.album_name.set_text(util.esc(state.current_song.album))
|
||||
self.artist_name.set_text(util.esc(state.current_song.artist))
|
||||
|
||||
def esc(string):
|
||||
return string.replace('&', '&')
|
||||
# Set the Up Next button popup.
|
||||
if hasattr(state, 'play_queue'):
|
||||
play_queue_len = len(state.play_queue)
|
||||
if play_queue_len == 0:
|
||||
self.popover_label.set_markup('<b>Up Next</b>')
|
||||
else:
|
||||
song_label = str(play_queue_len) + ' ' + util.pluralize(
|
||||
'song', play_queue_len)
|
||||
self.popover_label.set_markup(f'<b>Up Next:</b> {song_label}')
|
||||
|
||||
self.song_title.set_text(esc(state.current_song.title))
|
||||
self.album_name.set_text(esc(state.current_song.album))
|
||||
self.artist_name.set_text(esc(state.current_song.artist))
|
||||
# Remove everything from the play queue.
|
||||
for c in self.popover_list.get_children():
|
||||
self.popover_list.remove(c)
|
||||
|
||||
for s in state.play_queue:
|
||||
self.popover_list.add(
|
||||
Gtk.Label(
|
||||
halign=Gtk.Align.START,
|
||||
use_markup=True,
|
||||
margin=5,
|
||||
))
|
||||
|
||||
self.popover_list.show_all()
|
||||
|
||||
# These are normally already have been retrieved, so should be no
|
||||
# cost for doing the ``get_song_details`` call.
|
||||
for i, song_id in enumerate(state.play_queue):
|
||||
# Create a function to capture the value of i for the inner
|
||||
# function. This outer function creates the actual callback
|
||||
# function.
|
||||
def update_fn_generator(i):
|
||||
def do_update_label(result):
|
||||
title = util.esc(result.title)
|
||||
album = util.esc(result.album)
|
||||
row = self.popover_list.get_row_at_index(i)
|
||||
row.get_child().set_markup(f'<b>{title}</b>\n{album}')
|
||||
row.show_all()
|
||||
|
||||
return lambda f: GLib.idle_add(do_update_label, f.result())
|
||||
|
||||
future = CacheManager.get_song_details(song_id, lambda: None)
|
||||
future.add_done_callback(update_fn_generator(i))
|
||||
|
||||
@util.async_callback(
|
||||
lambda *k, **v: CacheManager.get_cover_art_filename(*k, **v),
|
||||
@@ -129,6 +169,11 @@ class PlayerControls(Gtk.ActionBar):
|
||||
if not self.editing:
|
||||
self.emit('song-scrub', self.song_scrubber.get_value())
|
||||
|
||||
def on_up_next_click(self, button):
|
||||
self.up_next_popover.set_relative_to(button)
|
||||
self.up_next_popover.show_all()
|
||||
self.up_next_popover.popup()
|
||||
|
||||
def create_song_display(self):
|
||||
box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
|
||||
|
||||
@@ -231,11 +276,32 @@ class PlayerControls(Gtk.ActionBar):
|
||||
box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
|
||||
|
||||
# Up Next button
|
||||
# TODO connect it to something.
|
||||
up_next_button = util.button_with_icon(
|
||||
'view-list-symbolic', icon_size=Gtk.IconSize.LARGE_TOOLBAR)
|
||||
up_next_button.connect('clicked', self.on_up_next_click)
|
||||
box.pack_start(up_next_button, False, True, 5)
|
||||
|
||||
self.up_next_popover = Gtk.PopoverMenu(name='up-next-popover')
|
||||
|
||||
popover_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
|
||||
self.popover_label = Gtk.Label(
|
||||
'<b>Up Next</b>',
|
||||
name='label',
|
||||
use_markup=True,
|
||||
halign=Gtk.Align.START,
|
||||
)
|
||||
popover_box.add(self.popover_label)
|
||||
|
||||
popover_scroll_box = Gtk.ScrolledWindow(
|
||||
min_content_height=600,
|
||||
min_content_width=400,
|
||||
)
|
||||
self.popover_list = Gtk.ListBox()
|
||||
popover_scroll_box.add(self.popover_list)
|
||||
popover_box.pack_end(popover_scroll_box, True, True, 0)
|
||||
|
||||
self.up_next_popover.add(popover_box)
|
||||
|
||||
# Volume mute toggle
|
||||
self.volume_mute_toggle = util.button_with_icon('audio-volume-high')
|
||||
self.volume_mute_toggle.set_action_name('app.mute-toggle')
|
||||
|
@@ -59,16 +59,16 @@ class PlaylistsPanel(Gtk.Paned):
|
||||
active=True)
|
||||
self.playlist_list.add(self.playlist_list_loading)
|
||||
|
||||
self.new_playlist_box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
|
||||
self.new_playlist_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL,
|
||||
visible=False)
|
||||
|
||||
self.playlist_list_new_entry = Gtk.Entry(
|
||||
name='playlist-list-new-playlist-entry')
|
||||
self.playlist_list_new_entry.connect(
|
||||
'activate', self.on_playlist_list_new_entry_activate)
|
||||
self.playlist_list_new_entry.connect(
|
||||
'focus-out-event', self.on_playlist_list_new_entry_focus_loose)
|
||||
self.new_playlist_box.pack_start(self.playlist_list_new_entry, True,
|
||||
True, 0)
|
||||
self.new_playlist_box.add(self.playlist_list_new_entry)
|
||||
|
||||
new_playlist_actions = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
|
||||
|
||||
self.playlist_list_new_confirm_button = Gtk.Button.new_from_icon_name(
|
||||
'object-select-symbolic', Gtk.IconSize.BUTTON)
|
||||
@@ -76,8 +76,19 @@ class PlaylistsPanel(Gtk.Paned):
|
||||
'playlist-list-new-playlist-confirm')
|
||||
self.playlist_list_new_confirm_button.connect(
|
||||
'clicked', self.on_playlist_list_new_confirm_button_clicked)
|
||||
self.new_playlist_box.pack_end(self.playlist_list_new_confirm_button,
|
||||
False, True, 0)
|
||||
new_playlist_actions.pack_end(self.playlist_list_new_confirm_button,
|
||||
False, True, 0)
|
||||
|
||||
self.playlist_list_new_cancel_button = Gtk.Button.new_from_icon_name(
|
||||
'process-stop-symbolic', Gtk.IconSize.BUTTON)
|
||||
self.playlist_list_new_cancel_button.set_name(
|
||||
'playlist-list-new-playlist-cancel')
|
||||
self.playlist_list_new_cancel_button.connect(
|
||||
'clicked', self.on_playlist_list_new_cancel_button_clicked)
|
||||
new_playlist_actions.pack_end(self.playlist_list_new_cancel_button,
|
||||
False, True, 0)
|
||||
|
||||
self.new_playlist_box.add(new_playlist_actions)
|
||||
|
||||
self.playlist_list.add(self.new_playlist_box)
|
||||
|
||||
@@ -238,21 +249,13 @@ class PlaylistsPanel(Gtk.Paned):
|
||||
[m[-1] for m in self.playlist_song_model])
|
||||
|
||||
def on_playlist_list_new_entry_activate(self, entry):
|
||||
try:
|
||||
CacheManager.create_playlist(name=entry.get_text())
|
||||
except ConnectionError:
|
||||
# TODO show message box
|
||||
return
|
||||
self.update_playlist_list(force=True)
|
||||
self.create_playlist(entry.get_text())
|
||||
|
||||
def on_playlist_list_new_entry_focus_loose(self, entry, event):
|
||||
# TODO don't do this, have an X button
|
||||
print(entry, event)
|
||||
print('ohea')
|
||||
def on_playlist_list_new_cancel_button_clicked(self, button):
|
||||
self.new_playlist_box.hide()
|
||||
|
||||
def on_playlist_list_new_confirm_button_clicked(self, button):
|
||||
print('check')
|
||||
# TODO figure out hwo to make the button styled nicer so that it looks integrated
|
||||
self.create_playlist(self.playlist_list_new_entry.get_text())
|
||||
|
||||
# Helper Methods
|
||||
# =========================================================================
|
||||
@@ -287,6 +290,15 @@ class PlaylistsPanel(Gtk.Paned):
|
||||
else:
|
||||
self.artwork_spinner.hide()
|
||||
|
||||
def create_playlist(self, playlist_name):
|
||||
try:
|
||||
CacheManager.create_playlist(name=playlist_name)
|
||||
except ConnectionError:
|
||||
# TODO show a message box
|
||||
return
|
||||
|
||||
self.update_playlist_list(force=True)
|
||||
|
||||
@util.async_callback(
|
||||
lambda *a, **k: CacheManager.get_playlists(*a, **k),
|
||||
before_download=lambda self: self.set_playlist_list_loading(True),
|
||||
@@ -371,17 +383,12 @@ class PlaylistsPanel(Gtk.Paned):
|
||||
use_markup=True,
|
||||
margin=12)
|
||||
|
||||
def pluralize(self, string, number, pluralized_form=None):
|
||||
if number != 1:
|
||||
return pluralized_form or f'{string}s'
|
||||
return string
|
||||
|
||||
def format_stats(self, playlist):
|
||||
created_date = playlist.created.strftime('%B %d, %Y')
|
||||
return ' • '.join([
|
||||
f'Created by {playlist.owner} on {created_date}',
|
||||
'{} {}'.format(playlist.songCount,
|
||||
self.pluralize("song", playlist.songCount)),
|
||||
util.pluralize("song", playlist.songCount)),
|
||||
self.format_playlist_duration(playlist.duration)
|
||||
])
|
||||
|
||||
@@ -393,18 +400,18 @@ class PlaylistsPanel(Gtk.Paned):
|
||||
format_components = []
|
||||
if duration_hrs > 0:
|
||||
hrs = '{} {}'.format(duration_hrs,
|
||||
self.pluralize('hour', duration_hrs))
|
||||
util.pluralize('hour', duration_hrs))
|
||||
format_components.append(hrs)
|
||||
|
||||
if duration_mins > 0:
|
||||
mins = '{} {}'.format(duration_mins,
|
||||
self.pluralize('minute', duration_mins))
|
||||
util.pluralize('minute', duration_mins))
|
||||
format_components.append(mins)
|
||||
|
||||
# Show seconds if there are no hours.
|
||||
if duration_hrs == 0:
|
||||
secs = '{} {}'.format(duration_secs,
|
||||
self.pluralize('second', duration_secs))
|
||||
util.pluralize('second', duration_secs))
|
||||
format_components.append(secs)
|
||||
|
||||
return ', '.join(format_components)
|
||||
|
@@ -1,8 +1,8 @@
|
||||
import gi
|
||||
import functools
|
||||
|
||||
from concurrent.futures import Future
|
||||
|
||||
import gi
|
||||
gi.require_version('Gtk', '3.0')
|
||||
from gi.repository import Gio, Gtk, GObject, GLib
|
||||
|
||||
@@ -27,6 +27,16 @@ def format_song_duration(duration_secs) -> str:
|
||||
return f'{duration_secs // 60}:{duration_secs % 60:02}'
|
||||
|
||||
|
||||
def pluralize(string: str, number: int, pluralized_form=None):
|
||||
if number != 1:
|
||||
return pluralized_form or f'{string}s'
|
||||
return string
|
||||
|
||||
|
||||
def esc(string):
|
||||
return string.replace('&', '&')
|
||||
|
||||
|
||||
def async_callback(future_fn, before_download=None, on_failure=None):
|
||||
"""
|
||||
Defines the ``async_callback`` decorator.
|
||||
|
Reference in New Issue
Block a user