Up next working!

This commit is contained in:
Sumner Evans
2019-06-29 00:02:31 -06:00
parent 54e17f1ecf
commit 3da3d33b5f
8 changed files with 184 additions and 70 deletions

View File

@@ -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(

View File

@@ -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()

View File

@@ -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'))

View File

@@ -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:

View File

@@ -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;
}

View File

@@ -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('&', '&amp;')
# 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')

View File

@@ -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)

View File

@@ -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('&', '&amp;')
def async_callback(future_fn, before_download=None, on_failure=None):
"""
Defines the ``async_callback`` decorator.