Refactored to use a TreeView and allowed for reordering of the queue

This commit is contained in:
Sumner Evans
2019-12-24 20:49:21 -07:00
parent 460bfa4090
commit 52a0b3e604
6 changed files with 277 additions and 134 deletions

View File

@@ -46,6 +46,7 @@ setup(
package_data={ package_data={
'sublime': [ 'sublime': [
'ui/app_styles.css', 'ui/app_styles.css',
'ui/images/play-queue-play.png',
'ui/mpris_specs/org.mpris.MediaPlayer2.xml', 'ui/mpris_specs/org.mpris.MediaPlayer2.xml',
'ui/mpris_specs/org.mpris.MediaPlayer2.Player.xml', 'ui/mpris_specs/org.mpris.MediaPlayer2.Player.xml',
'ui/mpris_specs/org.mpris.MediaPlayer2.Playlists.xml', 'ui/mpris_specs/org.mpris.MediaPlayer2.Playlists.xml',

View File

@@ -9,6 +9,8 @@ gi.require_version('Gtk', '3.0')
gi.require_version('Notify', '0.7') gi.require_version('Notify', '0.7')
from gi.repository import Gdk, Gio, GLib, Gtk, Notify, GdkPixbuf from gi.repository import Gdk, Gio, GLib, Gtk, Notify, GdkPixbuf
import Levenshtein
from .ui.main import MainWindow from .ui.main import MainWindow
from .ui.configure_servers import ConfigureServersDialog from .ui.configure_servers import ConfigureServersDialog
from .ui.settings import SettingsDialog from .ui.settings import SettingsDialog
@@ -90,8 +92,6 @@ class SublimeMusicApp(Gtk.Application):
add_action('prev-track', self.on_prev_track) add_action('prev-track', self.on_prev_track)
add_action('repeat-press', self.on_repeat_press) add_action('repeat-press', self.on_repeat_press)
add_action('shuffle-press', self.on_shuffle_press) add_action('shuffle-press', self.on_shuffle_press)
add_action(
'play-queue-click', self.on_play_queue_click, parameter_type='i')
# Navigation actions. # Navigation actions.
add_action('play-next', self.on_play_next, parameter_type='as') add_action('play-next', self.on_play_next, parameter_type='as')
@@ -139,6 +139,8 @@ class SublimeMusicApp(Gtk.Application):
'device-update', self.on_device_update) 'device-update', self.on_device_update)
self.window.player_controls.connect( self.window.player_controls.connect(
'volume-change', self.on_volume_change) 'volume-change', self.on_volume_change)
self.window.player_controls.connect(
'play-queue-reorder', self.on_play_queue_reorder)
self.window.connect('key-press-event', self.on_window_key_press) self.window.connect('key-press-event', self.on_window_key_press)
self.window.show_all() self.window.show_all()
@@ -236,6 +238,11 @@ class SublimeMusicApp(Gtk.Application):
# Send out to the bus that we exist. # Send out to the bus that we exist.
self.dbus_manager.property_diff() self.dbus_manager.property_diff()
def on_play_queue_reorder(self, _, new_queue):
self.state.play_queue = new_queue
self.save_play_queue()
self.update_window()
# ########## DBUS MANAGMENT ########## # # ########## DBUS MANAGMENT ########## #
def do_dbus_register(self, connection, path): def do_dbus_register(self, connection, path):
def get_state_and_player(): def get_state_and_player():
@@ -516,9 +523,6 @@ class SublimeMusicApp(Gtk.Application):
self.state.shuffle_on = not self.state.shuffle_on self.state.shuffle_on = not self.state.shuffle_on
self.update_window() self.update_window()
def on_play_queue_click(self, action, song_index):
self.play_song(song_index.get_int32(), reset=True)
@dbus_propagate() @dbus_propagate()
def on_play_next(self, action, song_ids): def on_play_next(self, action, song_ids):
if self.state.current_song is None: if self.state.current_song is None:
@@ -593,7 +597,7 @@ class SublimeMusicApp(Gtk.Application):
self.state.active_playlist_id = None self.state.active_playlist_id = None
# If shuffle is enabled, then shuffle the playlist. # If shuffle is enabled, then shuffle the playlist.
if self.state.shuffle_on: if self.state.shuffle_on and not metadata.get('no_reshuffle'):
song_id = song_queue[song_index] song_id = song_queue[song_index]
del song_queue[song_index] del song_queue[song_index]

Binary file not shown.

After

Width:  |  Height:  |  Size: 903 B

View File

@@ -0,0 +1,97 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<!-- Created with Inkscape (http://www.inkscape.org/) -->
<svg
xmlns:dc="http://purl.org/dc/elements/1.1/"
xmlns:cc="http://creativecommons.org/ns#"
xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#"
xmlns:svg="http://www.w3.org/2000/svg"
xmlns="http://www.w3.org/2000/svg"
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
width="50"
height="50"
viewBox="0 0 13.229166 13.229167"
version="1.1"
id="svg8"
inkscape:version="0.92.4 5da689c313, 2019-01-14"
sodipodi:docname="play-queue-play.svg"
inkscape:export-filename="/home/sumner/projects/sublime-music/sublime/ui/images/play-queue-play.png"
inkscape:export-xdpi="96"
inkscape:export-ydpi="96">
<defs
id="defs2" />
<sodipodi:namedview
id="base"
pagecolor="#ffffff"
bordercolor="#666666"
borderopacity="1.0"
inkscape:pageopacity="0.0"
inkscape:pageshadow="2"
inkscape:zoom="5.6"
inkscape:cx="15.843479"
inkscape:cy="55.759456"
inkscape:document-units="mm"
inkscape:current-layer="layer1"
showgrid="false"
units="px"
inkscape:window-width="1271"
inkscape:window-height="1404"
inkscape:window-x="1283"
inkscape:window-y="30"
inkscape:window-maximized="0" />
<metadata
id="metadata5">
<rdf:RDF>
<cc:Work
rdf:about="">
<dc:format>image/svg+xml</dc:format>
<dc:type
rdf:resource="http://purl.org/dc/dcmitype/StillImage" />
<dc:title />
</cc:Work>
</rdf:RDF>
</metadata>
<g
inkscape:label="Layer 1"
inkscape:groupmode="layer"
id="layer1"
transform="translate(0,-283.77082)">
<path
sodipodi:type="star"
style="fill:#000000;fill-opacity:1;stroke-width:0.39981332"
id="path4520"
sodipodi:sides="3"
sodipodi:cx="6.6130843"
sodipodi:cy="291.79547"
sodipodi:r1="5.6454182"
sodipodi:r2="1.3420769"
sodipodi:arg1="0.52306766"
sodipodi:arg2="1.6763933"
inkscape:flatsided="true"
inkscape:rounded="0"
inkscape:randomized="0"
d="m 11.503658,294.61558 -9.7781494,0.005 4.8845773,-8.47073 z"
inkscape:transform-center-x="-1.6314957"
inkscape:transform-center-y="0.0017367273"
transform="matrix(0,1.1570367,-1.1570367,0,342.71928,282.73209)" />
<path
sodipodi:type="star"
style="fill:#ffffff;fill-opacity:1;stroke-width:0.39981332"
id="path4520-9"
sodipodi:sides="3"
sodipodi:cx="6.6130843"
sodipodi:cy="291.79547"
sodipodi:r1="5.6454182"
sodipodi:r2="1.3420769"
sodipodi:arg1="0.52306766"
sodipodi:arg2="1.6763933"
inkscape:flatsided="true"
inkscape:rounded="0"
inkscape:randomized="0"
d="m 11.503658,294.61558 -9.7781494,0.005 4.8845773,-8.47073 z"
inkscape:transform-center-x="-1.1634043"
inkscape:transform-center-y="0.0012332389"
transform="matrix(0,0.8250756,-0.8250756,0,245.84428,284.92787)" />
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.1 KiB

View File

@@ -1,11 +1,12 @@
import math import math
from datetime import datetime from datetime import datetime
from pathlib import Path
from typing import List from typing import List
import gi import gi
gi.require_version('Gtk', '3.0') gi.require_version('Gtk', '3.0')
from gi.repository import Gtk, Gdk, Pango, GObject, Gio, GLib from gi.repository import Gtk, GdkPixbuf, Pango, GObject, Gio, GLib
from sublime.cache_manager import CacheManager from sublime.cache_manager import CacheManager
from sublime.state_manager import ApplicationState, RepeatType from sublime.state_manager import ApplicationState, RepeatType
@@ -49,23 +50,19 @@ class PlayerControls(Gtk.ActionBar):
GObject.TYPE_NONE, GObject.TYPE_NONE,
(object, bool), (object, bool),
), ),
'play-queue-reorder': (
GObject.SignalFlags.RUN_FIRST,
GObject.TYPE_NONE,
(object, ),
),
} }
editing: bool = False editing: bool = False
editing_play_queue_song_list: bool = False
reordering_play_queue_song_list: bool = False
current_song = None current_song = None
current_device = None current_device = None
chromecasts: List[ChromecastPlayer] = [] chromecasts: List[ChromecastPlayer] = []
class PlayQueueSong(GObject.GObject):
song_index = GObject.Property(type=int)
song_id = GObject.Property(type=str)
playing = GObject.Property(type=str)
def __init__(self, song_index: int, song_id: str, playing: bool):
GObject.GObject.__init__(self)
self.song_index = song_index
self.song_id = song_id
self.playing = str(playing)
def __init__(self): def __init__(self):
Gtk.ActionBar.__init__(self) Gtk.ActionBar.__init__(self)
self.set_name('player-controls-bar') self.set_name('player-controls-bar')
@@ -164,12 +161,52 @@ class PlayerControls(Gtk.ActionBar):
self.popover_label.set_markup( self.popover_label.set_markup(
f'<b>Play Queue:</b> {play_queue_len} {song_label}') f'<b>Play Queue:</b> {play_queue_len} {song_label}')
new_model = [ self.editing_play_queue_song_list = True
PlayerControls.PlayQueueSong(
i, s, has_current_song and i == state.current_song_index) new_store = []
for i, s in enumerate(state.play_queue)
] # Use this so that we can have a closure around the index
util.diff_model_store(self.play_queue_store, new_model) # variables.
def make_play_queue_updater(idx):
def on_cover_art_future_done(cover_art_filename):
self.play_queue_store[idx][0] = cover_art_filename
def on_song_details_future_done(song_details):
title = util.esc(song_details.title)
album = util.esc(song_details.album)
artist = util.esc(song_details.artist)
label = f'<b>{title}</b>\n{util.dot_join(album, artist)}'
self.play_queue_store[idx][1] = label
# Cover Art
cover_art_future = CacheManager.get_cover_art_filename(
song_details.coverArt,
size=50,
)
cover_art_future.add_done_callback(
lambda f: GLib.idle_add(
on_cover_art_future_done, f.result()))
return lambda f: GLib.idle_add(
on_song_details_future_done, f.result())
for i, song_id in enumerate(state.play_queue):
new_store.append(
[
'',
'\n',
i == state.current_song_index,
song_id,
])
# Get the song details.
song_details_future = CacheManager.get_song_details(song_id)
song_details_future.add_done_callback(
make_play_queue_updater(i))
util.diff_song_store(self.play_queue_store, new_store)
self.editing_play_queue_song_list = False
@util.async_callback( @util.async_callback(
lambda *k, **v: CacheManager.get_cover_art_filename(*k, **v), lambda *k, **v: CacheManager.get_cover_art_filename(*k, **v),
@@ -212,6 +249,15 @@ class PlayerControls(Gtk.ActionBar):
self.play_queue_popover.popup() self.play_queue_popover.popup()
self.play_queue_popover.show_all() self.play_queue_popover.show_all()
def on_song_activated(self, treeview, idx, column):
# The song ID is in the last column of the model.
self.emit(
'song-clicked',
idx.get_indices()[0],
[m[-1] for m in self.play_queue_store],
{'no_reshuffle': True},
)
def update_device_list(self, force=False): def update_device_list(self, force=False):
self.device_list_loading.show() self.device_list_loading.show()
@@ -263,46 +309,65 @@ class PlayerControls(Gtk.ActionBar):
def on_device_refresh_click(self, button): def on_device_refresh_click(self, button):
self.update_device_list(force=True) self.update_device_list(force=True)
def on_play_queue_button_press(self, listbox, event): def on_play_queue_button_press(self, tree, event):
if event.button == 3: # Right click if event.button == 3: # Right click
clicked_row = listbox.get_row_at_y(event.y) clicked_path = tree.get_path_at_pos(event.x, event.y)
clicked_row_index = clicked_row.get_index()
selected_indexes = [
r.get_index() for r in listbox.get_selected_rows()
]
if clicked_row_index not in selected_indexes: store, paths = tree.get_selection().get_selected_rows()
listbox.unselect_all() allow_deselect = False
listbox.select_row(clicked_row)
selected_indexes = [clicked_row_index]
def on_download_state_change(song_id=None): def on_download_state_change(song_id=None):
# Refresh the entire window (no force) because the song could # Refresh the entire window (no force) because the song could
# be in a list anywhere in the window. # be in a list anywhere in the window.
self.emit('refresh-window', {}, False) self.emit('refresh-window', {}, False)
song_ids = [ # Use the new selection instead of the old one for calculating what
self.play_queue_store[idx].song_id for idx in selected_indexes # to do the right click on.
] if clicked_path[0] not in paths:
paths = [clicked_path[0]]
allow_deselect = True
song_ids = [self.play_queue_store[p][-1] for p in paths]
remove_text = ( remove_text = (
'Remove ' + util.pluralize('song', len(song_ids)) 'Remove ' + util.pluralize('song', len(song_ids))
+ ' from queue') + ' from queue')
def on_remove_songs_click(_): def on_remove_songs_click(_):
self.emit('songs-removed', selected_indexes) self.emit('songs-removed', [p.get_indices()[0] for p in paths])
util.show_song_popover( util.show_song_popover(
song_ids, song_ids,
event.x, event.x,
event.y, event.y,
listbox, tree,
on_download_state_change=on_download_state_change, on_download_state_change=on_download_state_change,
extra_menu_items=[ extra_menu_items=[
(Gtk.ModelButton(text=remove_text), on_remove_songs_click), (Gtk.ModelButton(text=remove_text), on_remove_songs_click),
], ],
) )
# If the click was on a selected row, don't deselect anything.
if not allow_deselect:
return True
def on_play_queue_model_row_move(self, *args):
# If we are programatically editing the song list, don't do anything.
if self.editing_play_queue_song_list:
return
# We get both a delete and insert event, I think it's deterministic
# which one comes first, but just in case, we have this
# reordering_play_queue_song_list flag.
if self.reordering_play_queue_song_list:
self.emit(
'play-queue-reorder',
[s[-1] for s in self.play_queue_store],
)
self.reordering_play_queue_song_list = False
else:
self.reordering_play_queue_song_list = True
def create_song_display(self): def create_song_display(self):
box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL) box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL)
@@ -490,18 +555,65 @@ class PlayerControls(Gtk.ActionBar):
min_content_width=400, min_content_width=400,
) )
self.play_queue_store = Gio.ListStore() self.play_queue_store = Gtk.ListStore(
self.play_queue_list = Gtk.ListBox(activate_on_single_click=False) str, # image filename
str, # title, album, artist
bool, # playing
str, # song ID
)
self.play_queue_list = Gtk.TreeView(
model=self.play_queue_store,
reorderable=True,
headers_visible=False,
)
self.play_queue_list.get_selection().set_mode(
Gtk.SelectionMode.MULTIPLE)
# Album Art column.
def filename_to_pixbuf(column, cell, model, iter, flags):
filename = model.get_value(iter, 0)
if not filename:
cell.set_property('icon_name', '')
return
pixbuf = GdkPixbuf.Pixbuf.new_from_file(filename)
# If this is the playing song, then overlay the play icon.
if model.get_value(iter, 2):
play_overlay_pixbuf = GdkPixbuf.Pixbuf.new_from_file(
str(
Path(__file__).parent.joinpath(
'images/play-queue-play.png')))
play_overlay_pixbuf.composite(
pixbuf, 0, 0, 50, 50, 0, 0, 1, 1,
GdkPixbuf.InterpType.NEAREST, 255)
cell.set_property('pixbuf', pixbuf)
renderer = Gtk.CellRendererPixbuf()
renderer.set_fixed_size(55, 60)
column = Gtk.TreeViewColumn('', renderer)
column.set_cell_data_func(renderer, filename_to_pixbuf)
column.set_resizable(True)
self.play_queue_list.append_column(column)
renderer = Gtk.CellRendererText(
markup=True,
ellipsize=Pango.EllipsizeMode.END,
)
column = Gtk.TreeViewColumn('', renderer, markup=1)
self.play_queue_list.append_column(column)
self.play_queue_list.connect('row-activated', self.on_song_activated)
self.play_queue_list.connect( self.play_queue_list.connect(
'button-press-event', self.on_play_queue_button_press) 'button-press-event', self.on_play_queue_button_press)
self.play_queue_list.set_selection_mode(Gtk.SelectionMode.MULTIPLE)
self.play_queue_list.bind_model( # Set up drag-and-drop on the song list for editing the order of the
self.play_queue_store, # playlist.
self.create_play_queue_row, self.play_queue_store.connect(
) 'row-inserted', self.on_play_queue_model_row_move)
self.play_queue_list.drag_dest_set( self.play_queue_store.connect(
Gtk.DestDefaults.ALL, None, Gdk.DragAction.MOVE) 'row-deleted', self.on_play_queue_model_row_move)
self.play_queue_list.connect('drag-data-received', lambda *a: print(a))
play_queue_scrollbox.add(self.play_queue_list) play_queue_scrollbox.add(self.play_queue_list)
play_queue_popover_box.pack_end(play_queue_scrollbox, True, True, 0) play_queue_popover_box.pack_end(play_queue_scrollbox, True, True, 0)
@@ -524,74 +636,3 @@ class PlayerControls(Gtk.ActionBar):
vbox.pack_start(box, False, True, 0) vbox.pack_start(box, False, True, 0)
vbox.pack_start(Gtk.Box(), True, True, 0) vbox.pack_start(Gtk.Box(), True, True, 0)
return vbox return vbox
def create_play_queue_row(self, model: PlayQueueSong):
draggable_container = Gtk.EventBox()
row = Gtk.ListBoxRow(
action_name='app.play-queue-click',
action_target=GLib.Variant('i', model.song_index),
)
box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, margin=3)
overlay = Gtk.Overlay()
image = SpinnerImage(image_name='play-queue-row-image')
overlay.add(image)
box.add(overlay)
# Add a play icon overlay if this is the currently playing song.
if model.playing == 'True':
# TODO style this with a a border so that it is visible when the
# cover art looks similar.
play_icon = Gtk.Image(
name='play-queue-playing-icon',
halign=Gtk.Align.CENTER,
valign=Gtk.Align.CENTER,
)
play_icon.set_from_icon_name(
'media-playback-start-symbolic',
Gtk.IconSize.DIALOG,
)
overlay.add_overlay(play_icon)
label = Gtk.Label(
label='\n',
use_markup=True,
margin=10,
halign=Gtk.Align.START,
ellipsize=Pango.EllipsizeMode.END,
max_width_chars=35,
)
box.add(label)
row.add(box)
def update_image(image_filename):
image.set_from_file(image_filename)
image.set_loading(False)
row.show_all()
def update_row(song_details):
title = util.esc(song_details.title)
album = util.esc(song_details.album)
artist = util.esc(song_details.artist)
label.set_markup(f'<b>{title}</b>\n{util.dot_join(album, artist)}')
row.show_all()
cover_art_future = CacheManager.get_cover_art_filename(
song_details.coverArt, size=50)
cover_art_future.add_done_callback(
lambda f: GLib.idle_add(update_image, f.result()))
song_details_future = CacheManager.get_song_details(model.song_id)
song_details_future.add_done_callback(
lambda f: GLib.idle_add(update_row, f.result()))
draggable_container.add(row)
def on_drag_data_get(widget, drag_context, data, info, time):
data.set_text(str(model.song_index))
draggable_container.drag_source_set(
Gdk.ModifierType.BUTTON1_MASK, None, Gdk.DragAction.MOVE)
draggable_container.connect('drag-data-get', on_drag_data_get)
return draggable_container

View File

@@ -409,9 +409,9 @@ class PlaylistDetailPanel(Gtk.Overlay):
# Set up drag-and-drop on the song list for editing the order of the # Set up drag-and-drop on the song list for editing the order of the
# playlist. # playlist.
self.playlist_song_store.connect( self.playlist_song_store.connect(
'row-inserted', self.playlist_model_row_move) 'row-inserted', self.on_playlist_model_row_move)
self.playlist_song_store.connect( self.playlist_song_store.connect(
'row-deleted', self.playlist_model_row_move) 'row-deleted', self.on_playlist_model_row_move)
playlist_view_scroll_window.add(self.playlist_songs) playlist_view_scroll_window.add(self.playlist_songs)
@@ -547,8 +547,7 @@ class PlaylistDetailPanel(Gtk.Overlay):
'refresh-window', 'refresh-window',
{ {
'selected_playlist_id': 'selected_playlist_id':
None if result == Gtk.ResponseType.NO else None if result == Gtk.ResponseType.NO else self.playlist_id
self.playlist_id
}, },
True, True,
) )
@@ -580,7 +579,8 @@ class PlaylistDetailPanel(Gtk.Overlay):
def on_shuffle_all_button(self, btn): def on_shuffle_all_button(self, btn):
self.emit( self.emit(
'song-clicked', 'song-clicked',
randint(0, len(self.playlist_song_store) - 1), randint(0,
len(self.playlist_song_store) - 1),
[m[-1] for m in self.playlist_song_store], [m[-1] for m in self.playlist_song_store],
{ {
'force_shuffle_state': True, 'force_shuffle_state': True,
@@ -650,15 +650,7 @@ class PlaylistDetailPanel(Gtk.Overlay):
if not allow_deselect: if not allow_deselect:
return True return True
def make_label(self, text=None, name=None, **params): def on_playlist_model_row_move(self, *args):
return Gtk.Label(
label=text,
name=name,
halign=Gtk.Align.START,
**params,
)
def playlist_model_row_move(self, *args):
# If we are programatically editing the song list, don't do anything. # If we are programatically editing the song list, don't do anything.
if self.editing_playlist_song_list: if self.editing_playlist_song_list:
return return
@@ -672,6 +664,14 @@ class PlaylistDetailPanel(Gtk.Overlay):
else: else:
self.reordering_playlist_song_list = True self.reordering_playlist_song_list = True
def make_label(self, text=None, name=None, **params):
return Gtk.Label(
label=text,
name=name,
halign=Gtk.Align.START,
**params,
)
@util.async_callback(lambda *a, **k: CacheManager.get_playlist(*a, **k)) @util.async_callback(lambda *a, **k: CacheManager.get_playlist(*a, **k))
def update_playlist_order(self, playlist, state: ApplicationState): def update_playlist_order(self, playlist, state: ApplicationState):
self.playlist_view_loading_box.show_all() self.playlist_view_loading_box.show_all()