caching indicators, scrubbing, next, prev

This commit is contained in:
Sumner Evans
2019-06-23 18:37:06 -06:00
parent 207dd02249
commit 2d6116ab88
7 changed files with 213 additions and 104 deletions

View File

@@ -12,6 +12,7 @@ from .ui.configure_servers import ConfigureServersDialog
from .state_manager import ApplicationState
from .cache_manager import CacheManager
from .server.api_objects import Child
class LibremsonicApp(Gtk.Application):
@@ -38,6 +39,18 @@ class LibremsonicApp(Gtk.Application):
self.window.player_controls.update_scrubber(
value, self.state.current_song.duration)
@self.player.property_observer('eof-reached')
def file_end(_, value):
print('eof', value)
return
if value is None:
# TODO handle repeat
current_idx = self.state.play_queue.index(
self.state.current_song)
has_next_song = current_idx < len(self.state.play_queue) - 1
if has_next_song:
self.on_next_track(None, None)
# Handle command line option parsing.
def do_command_line(self, command_line):
options = command_line.get_options_dict()
@@ -99,6 +112,7 @@ class LibremsonicApp(Gtk.Application):
self.window.stack.connect('notify::visible-child',
self.on_stack_change)
self.window.connect('song-clicked', self.on_song_clicked)
self.window.player_controls.connect('song-scrub', self.on_song_scrub)
# Display the window.
self.window.show_all()
@@ -122,16 +136,18 @@ class LibremsonicApp(Gtk.Application):
self.show_configure_servers_dialog()
def on_play_pause(self, action, param):
self.player.command('cycle', 'pause')
self.player.cycle('pause')
self.state.playing = not self.state.playing
self.update_window()
def on_next_track(self, action, params):
self.player.playlist_next()
current_idx = self.state.play_queue.index(self.state.current_song)
self.play_song(self.state.play_queue[current_idx + 1])
def on_prev_track(self, action, params):
self.player.playlist_prev()
current_idx = self.state.play_queue.index(self.state.current_song)
self.play_song(self.state.play_queue[current_idx - 1])
def on_repeat_press(self, action, params):
print('repeat press')
@@ -163,12 +179,16 @@ class LibremsonicApp(Gtk.Application):
def on_song_clicked(self, win, song_id, song_queue):
CacheManager.save_play_queue(id=song_queue, current=song_id)
song = CacheManager.get_song(song_id)
self.state.current_song = song
self.state.play_queue = [CacheManager.get_song(s) for s in song_queue]
self.state.playing = True
# print(CacheManager.get_play_queue())
song_file = CacheManager.get_song_filename(song)
self.player.loadfile(song_file)
self.update_window()
self.play_song(song)
def on_song_scrub(self, _, scrub_value):
if not hasattr(self.state, 'current_song'):
return
new_time = self.state.current_song.duration * (scrub_value / 100)
self.player.command('seek', str(new_time), 'absolute')
# ########## HELPER METHODS ########## #
def show_configure_servers_dialog(self):
@@ -182,3 +202,10 @@ class LibremsonicApp(Gtk.Application):
def update_window(self):
self.window.update(self.state)
def play_song(self, song: Child):
self.state.current_song = song
song_file = CacheManager.get_song_filename(song)
self.player.loadfile(song_file, 'replace')
self.player.pause = False
self.update_window()

View File

@@ -1,11 +1,11 @@
import os
import json
from enum import EnumMeta
from enum import EnumMeta, Enum
from datetime import datetime
from pathlib import Path
from collections import defaultdict
from typing import DefaultDict, Dict, List, Optional, Tuple
from typing import Dict, List, Optional, Union, Callable, Set
from libremsonic.config import AppConfiguration, ServerConfiguration
from libremsonic.server import Server
@@ -27,6 +27,13 @@ class Singleton(type):
return None
class SongCacheStatus(Enum):
NOT_CACHED = 0
CACHED = 1
PERMANENTLY_CACHED = 2
DOWNLOADING = 3
class CacheManager(metaclass=Singleton):
class CacheEncoder(json.JSONEncoder):
def default(self, obj):
@@ -43,16 +50,9 @@ class CacheManager(metaclass=Singleton):
server: Server
playlists: Optional[List[Playlist]] = None
playlist_details: Dict[int, PlaylistWithSongs] = {}
# {id -> {size -> file_location}}
cover_art: DefaultDict[str, Dict[str, str]] = defaultdict(dict)
# {id -> Child}
permanently_cached_paths: Set[str] = set()
song_details: Dict[int, Child] = {}
# { (artist, album, title) -> file_location }
song_cache: Dict[str, str] = {}
def __init__(
self,
app_config: AppConfiguration,
@@ -69,8 +69,7 @@ class CacheManager(metaclass=Singleton):
self.load_cache_info()
def load_cache_info(self):
cache_location = Path(self.app_config.cache_location)
cache_meta_file = cache_location.joinpath('.cache_meta')
cache_meta_file = self.calculate_abs_path('.cache_meta')
if not cache_meta_file.exists():
return
@@ -88,42 +87,51 @@ class CacheManager(metaclass=Singleton):
id: PlaylistWithSongs.from_json(v)
for id, v in meta_json.get('playlist_details', {}).items()
}
self.cover_art = defaultdict(dict,
**meta_json.get('cover_art', {}))
self.song_details = {
id: Child.from_json(v)
for id, v in meta_json.get('song_details', {}).items()
}
self.song_cache = dict(**meta_json.get('song_cache', {}))
self.permanently_cached_paths = set(
meta_json.get('permanently_cached_paths', []))
def save_cache_info(self):
cache_location = Path(self.app_config.cache_location)
os.makedirs(cache_location, exist_ok=True)
os.makedirs(self.app_config.cache_location, exist_ok=True)
cache_meta_file = cache_location.joinpath('.cache_meta')
cache_meta_file = self.calculate_abs_path('.cache_meta')
with open(cache_meta_file, 'w+') as f:
cache_info = dict(
playlists=self.playlists,
playlist_details=self.playlist_details,
song_details=self.song_details,
permanently_cached_paths=list(
self.permanently_cached_paths),
)
f.write(
json.dumps(
dict(
playlists=self.playlists,
playlist_details=self.playlist_details,
cover_art=self.cover_art,
song_details=self.song_details,
song_cache=self.song_cache,
),
indent=2,
cls=CacheManager.CacheEncoder,
))
def save_file(self, relative_path: str, data: bytes) -> Path:
cache_location = Path(self.app_config.cache_location)
absolute_path = cache_location.joinpath(relative_path)
json.dumps(cache_info,
indent=2,
cls=CacheManager.CacheEncoder))
def save_file(self, absolute_path: Path, data: bytes):
# Make the necessary directories and write to file.
os.makedirs(absolute_path.parent, exist_ok=True)
with open(absolute_path, 'wb+') as f:
f.write(data)
return absolute_path
def calculate_abs_path(self, relative_path):
return Path(self.app_config.cache_location).joinpath(relative_path)
def return_cache_or_download(
self,
relative_path: Union[Path, str],
download_fn: Callable[[], bytes],
force: bool = False,
):
abs_path = self.calculate_abs_path(relative_path)
if not abs_path.exists() or force:
print(abs_path, 'not found. Downloading...')
self.save_file(abs_path, download_fn())
return str(abs_path)
def get_playlists(self, force: bool = False) -> List[Playlist]:
if not self.playlists or force:
@@ -147,35 +155,38 @@ class CacheManager(metaclass=Singleton):
def get_cover_art_filename(
self,
id: str,
size: str = '200',
size: Union[str, int] = 200,
force: bool = False,
) -> str:
if not self.cover_art[id].get(size):
raw_cover = self.server.get_cover_art(id, size)
abs_path = self.save_file(f'cover_art/{id}_{size}', raw_cover)
self.cover_art[id][size] = str(abs_path)
self.save_cache_info()
print('cover art cache not hit')
return self.return_cache_or_download(
f'cover_art/{id}_{size}',
lambda: self.server.get_cover_art(id, str(size)),
force=force,
)
return self.cover_art[id][size]
def get_song(self, song_id: int, force: bool = False):
def get_song(self, song_id: int, force: bool = False) -> Child:
if not self.song_details.get(song_id) or force:
self.song_details[song_id] = self.server.get_song(song_id)
self.save_cache_info()
print('song info cache not hit')
return self.song_details[song_id]
def get_song_filename(self, song: Child, force: bool = False):
if not self.song_cache.get(song.id) or force:
raw_song = self.server.download(song.id)
abs_path = self.save_file(song.path, raw_song)
self.song_cache[song.id] = str(abs_path)
self.save_cache_info()
print('song file cache not hit')
def get_song_filename(self, song: Child, force: bool = False) -> str:
return self.return_cache_or_download(
song.path,
lambda: self.server.download(song.id),
force=force,
)
return self.song_cache[song.id]
def get_cached_status(self, song: Child) -> SongCacheStatus:
path = self.calculate_abs_path(song.path)
if path.exists():
if path in self.permanently_cached_paths:
return SongCacheStatus.PERMANENTLY_CACHED
else:
return SongCacheStatus.CACHED
return SongCacheStatus.NOT_CACHED
_instance: Optional[__CacheManagerInternal] = None

View File

@@ -7,11 +7,11 @@ from .server.api_objects import Child
class ApplicationState:
config: AppConfiguration = AppConfiguration.get_default_configuration()
current_song: Child # TODO fix
current_song: Child
config_file: str
playing: bool = False
song_progress: float = 0.0
up_next: List[Any] # TODO should be song
play_queue: List[Child]
volume: int = 100
def load_config(self):

View File

@@ -1,8 +1,7 @@
import gi
import sys
gi.require_version('Gtk', '3.0')
from gi.repository import Gio, Gtk, GObject
from gi.repository import Gtk, GObject
from libremsonic.state_manager import ApplicationState

View File

@@ -21,7 +21,7 @@ class MainWindow(Gtk.ApplicationWindow):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.set_default_size(1024, 768)
self.set_default_size(1100, 768)
self.panels = {
'Albums': albums.AlbumsPanel(),
@@ -45,7 +45,6 @@ class MainWindow(Gtk.ApplicationWindow):
flowbox.pack_start(self.player_controls, False, True, 0)
self.add(flowbox)
# TODO the song should eventually be an API object...
def update(self, state: ApplicationState):
# Update the Connected to label on the popup menu.
if state.config.current_server >= 0:

View File

@@ -3,7 +3,7 @@ import math
import gi
gi.require_version('Gtk', '3.0')
from gi.repository import Gio, Gtk, Gtk
from gi.repository import Gtk, Pango, GObject
from libremsonic.state_manager import ApplicationState
from libremsonic.cache_manager import CacheManager
@@ -14,6 +14,14 @@ class PlayerControls(Gtk.ActionBar):
"""
Defines the player controls panel that appears at the bottom of the window.
"""
__gsignals__ = {
'song-scrub': (
GObject.SIGNAL_RUN_FIRST,
GObject.TYPE_NONE,
(float, ),
),
}
editing: bool = False
def __init__(self):
Gtk.ActionBar.__init__(self)
@@ -32,13 +40,28 @@ class PlayerControls(Gtk.ActionBar):
self.play_button.get_child().set_from_icon_name(
f"media-playback-{icon}-symbolic", Gtk.IconSize.LARGE_TOOLBAR)
if not hasattr(state, 'current_song'):
has_current_song = hasattr(state, 'current_song')
has_prev_song, has_next_song = False, False
if has_current_song:
# TODO will need to change when repeat is implemented
has_prev_song = state.play_queue.index(state.current_song) > 0
has_next_song = state.play_queue.index(state.current_song) < len(
state.play_queue) - 1
self.song_scrubber.set_sensitive(has_current_song)
self.repeat_button.set_sensitive(has_current_song)
self.prev_button.set_sensitive(has_current_song and has_prev_song)
self.play_button.set_sensitive(has_current_song)
self.next_button.set_sensitive(has_current_song and has_next_song)
self.shuffle_button.set_sensitive(has_current_song)
if not has_current_song:
return
self.album_art.set_from_file(
CacheManager.get_cover_art_filename(
state.current_song.coverArt,
size=70,
size='70',
))
def esc(string):
@@ -48,6 +71,25 @@ class PlayerControls(Gtk.ActionBar):
self.album_name.set_markup(f'<i>{esc(state.current_song.album)}</i>')
self.artist_name.set_markup(f'{esc(state.current_song.artist)}')
def update_scrubber(self, current, duration):
current = current or 0
percent_complete = current / duration * 100
if not self.editing:
self.song_scrubber.set_value(percent_complete)
self.song_duration_label.set_text(util.format_song_duration(duration))
self.song_progress_label.set_text(
util.format_song_duration(math.floor(current)))
def on_scrub_state_change(self, scrubber_container, eventbutton):
self.editing = not self.editing
if not self.editing:
self.emit('song-scrub', self.song_scrubber.get_value())
def on_scrub_edit(self, box, event):
print(event.x)
def create_song_display(self):
box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
@@ -57,8 +99,13 @@ class PlayerControls(Gtk.ActionBar):
details_box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL)
details_box.pack_start(Gtk.Box(), True, True, 0)
def make_label(n):
return Gtk.Label(name=n, halign=Gtk.Align.START, use_markup=True)
def make_label(name):
return Gtk.Label(
name=name,
halign=Gtk.Align.START,
use_markup=True,
ellipsize=Pango.EllipsizeMode.END,
)
self.song_title = make_label('song-title')
details_box.add(self.song_title)
@@ -70,7 +117,7 @@ class PlayerControls(Gtk.ActionBar):
details_box.add(self.artist_name)
details_box.pack_start(Gtk.Box(), True, True, 0)
box.pack_start(details_box, True, True, 5)
box.pack_start(details_box, False, False, 5)
return box
@@ -87,6 +134,10 @@ class PlayerControls(Gtk.ActionBar):
orientation=Gtk.Orientation.HORIZONTAL, min=0, max=100, step=5)
self.song_scrubber.set_name('song-scrubber')
self.song_scrubber.set_draw_value(False)
self.song_scrubber.connect('button-press-event',
self.on_scrub_state_change)
self.song_scrubber.connect('button-release-event',
self.on_scrub_state_change)
scrubber_box.pack_start(self.song_scrubber, True, True, 0)
self.song_duration_label = Gtk.Label('-:--')
@@ -104,11 +155,11 @@ class PlayerControls(Gtk.ActionBar):
buttons.pack_start(self.repeat_button, False, False, 5)
# Previous button
previous_button = util.button_with_icon(
self.prev_button = util.button_with_icon(
'media-skip-backward-symbolic',
icon_size=Gtk.IconSize.LARGE_TOOLBAR)
previous_button.set_action_name('app.prev-track')
buttons.pack_start(previous_button, False, False, 5)
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(
@@ -120,11 +171,11 @@ class PlayerControls(Gtk.ActionBar):
buttons.pack_start(self.play_button, False, False, 0)
# Next button
next_button = util.button_with_icon(
self.next_button = util.button_with_icon(
'media-skip-forward-symbolic',
icon_size=Gtk.IconSize.LARGE_TOOLBAR)
next_button.set_action_name('app.next-track')
buttons.pack_start(next_button, False, False, 5)
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(
@@ -165,11 +216,3 @@ class PlayerControls(Gtk.ActionBar):
vbox.pack_start(box, False, True, 0)
vbox.pack_start(Gtk.Box(), True, True, 0)
return vbox
def update_scrubber(self, current, duration):
current = current or 0
percent_complete = current / duration * 100
self.song_scrubber.set_value(percent_complete)
self.song_duration_label.set_text(util.format_song_duration(duration))
self.song_progress_label.set_text(
util.format_song_duration(math.floor(current)))

View File

@@ -1,6 +1,5 @@
import gi
import sys
from datetime import datetime
from pathlib import Path
from typing import List
gi.require_version('Gtk', '3.0')
@@ -8,7 +7,7 @@ from gi.repository import Gio, Gtk, Pango, GObject
from libremsonic.server.api_objects import Child, PlaylistWithSongs
from libremsonic.state_manager import ApplicationState
from libremsonic.cache_manager import CacheManager
from libremsonic.cache_manager import CacheManager, SongCacheStatus
from libremsonic.ui import util
@@ -111,15 +110,31 @@ class PlaylistsPanel(Gtk.Paned):
column.set_expand(not width)
return column
self.playlist_song_model = Gtk.ListStore(str, str, str, str, str)
self.playlist_song_model = Gtk.ListStore(
str, # cache status
str, # title
str, # album
str, # artist
str, # duration
str, # song ID
)
self.playlist_songs = Gtk.TreeView(model=self.playlist_song_model,
margin_top=15)
self.playlist_songs.append_column(create_column('TITLE', 0, bold=True))
self.playlist_songs.append_column(create_column('ALBUM', 1))
self.playlist_songs.append_column(create_column('ARTIST', 2))
# Song status column.
renderer = Gtk.CellRendererPixbuf()
renderer.set_fixed_size(30, 35)
column = Gtk.TreeViewColumn('', renderer, icon_name=0)
column.set_resizable(True)
self.playlist_songs.append_column(column)
self.playlist_songs.append_column(create_column('TITLE', 1, bold=True))
self.playlist_songs.append_column(create_column('ALBUM', 2))
self.playlist_songs.append_column(create_column('ARTIST', 3))
self.playlist_songs.append_column(
create_column('DURATION', 3, align=1, width=40))
create_column('DURATION', 4, align=1, width=40))
self.playlist_songs.connect('row-activated', self.on_song_double_click)
@@ -139,13 +154,24 @@ class PlaylistsPanel(Gtk.Paned):
CacheManager.get_cover_art_filename(playlist.coverArt))
self.playlist_indicator.set_markup('PLAYLIST')
self.playlist_name.set_markup(f'<b>{playlist.name}</b>')
self.playlist_comment.set_text(playlist.comment or '')
if playlist.comment:
self.playlist_comment.set_text(playlist.comment)
self.playlist_comment.show()
else:
self.playlist_comment.hide()
self.playlist_stats.set_markup(self.format_stats(playlist))
# Update the song list model
self.playlist_song_model.clear()
for song in (playlist.entry or []):
cache_icon = {
SongCacheStatus.NOT_CACHED: '',
SongCacheStatus.CACHED: 'folder-download-symbolic',
SongCacheStatus.PERMANENTLY_CACHED: 'view-pin-symbolic',
SongCacheStatus.DOWNLOADING: 'folder-download-symbolic',
}
self.playlist_song_model.append([
cache_icon[CacheManager.get_cached_status(song)],
song.title,
song.album,
song.artist,
@@ -159,9 +185,10 @@ class PlaylistsPanel(Gtk.Paned):
self.update_playlist_list(force=True)
def on_song_double_click(self, treeview, idx, column):
song_id = self.playlist_song_model[idx][4]
# The song ID is in the last column of the model.
song_id = self.playlist_song_model[idx][-1]
self.emit('song-clicked', song_id,
[m[4] for m in self.playlist_song_model])
[m[-1] for m in self.playlist_song_model])
# Helper Methods
# =========================================================================
@@ -172,13 +199,16 @@ class PlaylistsPanel(Gtk.Paned):
self.update_playlist_list()
def update_playlist_list(self, force=False):
self.playlist_ids = []
for c in self.playlist_list.get_children():
self.playlist_list.remove(c)
not_seen = set(self.playlist_ids)
for playlist in CacheManager.get_playlists(force=force):
self.playlist_ids.append(playlist.id)
self.playlist_list.add(self.create_playlist_label(playlist))
if playlist.id not in self.playlist_ids:
self.playlist_ids.append(playlist.id)
self.playlist_list.add(self.create_playlist_label(playlist))
else:
not_seen.remove(playlist.id)
for playlist_id in not_seen:
print(playlist_id)
self.playlist_list.show_all()