GitLab's annual major release is around the corner. Along with a lot of new and exciting features, there will be a few breaking changes. Learn more here.

app.py 34 KB
Newer Older
Sumner Evans's avatar
Sumner Evans committed
1
import os
Sumner Evans's avatar
Sumner Evans committed
2
import logging
3
import math
Sumner Evans's avatar
Sumner Evans committed
4
import random
Sumner Evans's avatar
Sumner Evans committed
5

6 7
import gi
gi.require_version('Gtk', '3.0')
8
gi.require_version('Notify', '0.7')
9
from gi.repository import Gdk, Gio, GLib, Gtk, Notify, GdkPixbuf
10

Sumner Evans's avatar
Sumner Evans committed
11 12
from .ui.main import MainWindow
from .ui.configure_servers import ConfigureServersDialog
Sumner Evans's avatar
Sumner Evans committed
13
from .ui.settings import SettingsDialog
Sumner Evans's avatar
Sumner Evans committed
14

Sumner Evans's avatar
Cleanup  
Sumner Evans committed
15
from .dbus_manager import DBusManager, dbus_propagate
Sumner Evans's avatar
Sumner Evans committed
16
from .state_manager import ApplicationState, RepeatType
Sumner Evans's avatar
Sumner Evans committed
17
from .cache_manager import CacheManager
Sumner Evans's avatar
Sumner Evans committed
18
from .server.api_objects import Child, Directory
Sumner Evans's avatar
Sumner Evans committed
19
from .players import PlayerEvent, MPVPlayer, ChromecastPlayer
20 21


22
class SublimeMusicApp(Gtk.Application):
23 24
    def __init__(self, config_file):
        super().__init__(application_id="com.sumnerevans.sublimemusic")
25
        Notify.init('Sublime Music')
26

27
        self.window = None
Sumner Evans's avatar
Sumner Evans committed
28 29
        self.state = ApplicationState()
        self.state.config_file = config_file
Sumner Evans's avatar
Sumner Evans committed
30

31
        self.connect('shutdown', self.on_app_shutdown)
Sumner Evans's avatar
Sumner Evans committed
32

33 34 35
    def do_startup(self):
        Gtk.Application.do_startup(self)

Sumner Evans's avatar
Sumner Evans committed
36
        def add_action(name: str, fn, parameter_type=None):
Sumner Evans's avatar
Sumner Evans committed
37
            """Registers an action with the application."""
Sumner Evans's avatar
Sumner Evans committed
38 39 40
            if type(parameter_type) == str:
                parameter_type = GLib.VariantType(parameter_type)
            action = Gio.SimpleAction.new(name, parameter_type)
Sumner Evans's avatar
Sumner Evans committed
41 42
            action.connect('activate', fn)
            self.add_action(action)
43

Sumner Evans's avatar
Sumner Evans committed
44 45
        # Add action for menu items.
        add_action('configure-servers', self.on_configure_servers)
Sumner Evans's avatar
Sumner Evans committed
46
        add_action('settings', self.on_settings)
Sumner Evans's avatar
Sumner Evans committed
47 48 49 50 51 52 53

        # Add actions for player controls
        add_action('play-pause', self.on_play_pause)
        add_action('next-track', self.on_next_track)
        add_action('prev-track', self.on_prev_track)
        add_action('repeat-press', self.on_repeat_press)
        add_action('shuffle-press', self.on_shuffle_press)
Sumner Evans's avatar
Sumner Evans committed
54

Sumner Evans's avatar
Sumner Evans committed
55 56 57 58 59
        # 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')
60
        add_action('browse-to', self.browse_to, parameter_type='s')
61 62
        add_action(
            'go-to-playlist', self.on_go_to_playlist, parameter_type='s')
63

64
        add_action('mute-toggle', self.on_mute_toggle)
65 66 67 68
        add_action(
            'update-play-queue-from-server',
            lambda a, p: self.update_play_state_from_server(),
        )
69

70
    def do_activate(self):
71 72
        # We only allow a single window and raise any existing ones
        if self.window:
73
            self.window.present()
74 75 76 77
            return

        # Windows are associated with the application when the last one is
        # closed the application shuts down.
78
        self.window = MainWindow(application=self, title="Sublime Music")
79 80 81 82 83 84 85 86

        # Configure the CSS provider so that we can style elements on the
        # window.
        css_provider = Gtk.CssProvider()
        css_provider.load_from_path(
            os.path.join(os.path.dirname(__file__), 'ui/app_styles.css'))
        context = Gtk.StyleContext()
        screen = Gdk.Screen.get_default()
87 88
        context.add_provider_for_screen(
            screen, css_provider, Gtk.STYLE_PROVIDER_PRIORITY_USER)
89

90 91 92 93
        self.window.stack.connect(
            'notify::visible-child',
            self.on_stack_change,
        )
Sumner Evans's avatar
Sumner Evans committed
94
        self.window.connect('song-clicked', self.on_song_clicked)
95
        self.window.connect('songs-removed', self.on_songs_removed)
96
        self.window.connect('refresh-window', self.on_refresh_window)
97
        self.window.connect('go-to', self.on_window_go_to)
Sumner Evans's avatar
Sumner Evans committed
98
        self.window.connect('key-press-event', self.on_window_key_press)
99
        self.window.player_controls.connect('song-scrub', self.on_song_scrub)
100 101 102 103
        self.window.player_controls.connect(
            'device-update', self.on_device_update)
        self.window.player_controls.connect(
            'volume-change', self.on_volume_change)
Sumner Evans's avatar
Sumner Evans committed
104

105 106
        self.window.show_all()
        self.window.present()
Sumner Evans's avatar
Sumner Evans committed
107

Sumner Evans's avatar
Sumner Evans committed
108
        # Load the configuration and update the UI with the curent server, if
Sumner Evans's avatar
Sumner Evans committed
109
        # it exists.
Sumner Evans's avatar
Sumner Evans committed
110
        self.state.load()
Sumner Evans's avatar
Sumner Evans committed
111

Sumner Evans's avatar
Sumner Evans committed
112
        # If there is no current server, show the dialog to select a server.
113
        if self.state.config.server is None:
114
            self.show_configure_servers_dialog()
115 116 117

            # If they didn't add one with the dialog, close the window.
            if self.state.config.server is None:
118
                self.window.close()
119
                return
Sumner Evans's avatar
Sumner Evans committed
120

Sumner Evans's avatar
Sumner Evans committed
121 122
        self.update_window()

Sumner Evans's avatar
Sumner Evans committed
123
        # Configure the players
124
        self.last_play_queue_update = 0
125
        self.loading_state = False
126
        self.should_scrobble_song = False
127 128

        def time_observer(value):
129 130
            if self.loading_state:
                return
131

132 133 134 135
            if value is None:
                self.last_play_queue_update = 0
                return

136 137 138 139 140 141
            self.state.song_progress = value
            GLib.idle_add(
                self.window.player_controls.update_scrubber,
                self.state.song_progress,
                self.state.current_song.duration,
            )
142 143

            if self.last_play_queue_update + 15 <= value:
144 145
                self.save_play_queue()

146
            if value > 5 and self.should_scrobble_song:
147 148 149
                CacheManager.scrobble(self.state.current_song.id)
                self.should_scrobble_song = False

150
        def on_track_end():
151
            if (self.state.current_song_index == len(self.state.play_queue) - 1
152 153
                    and self.state.repeat_type == RepeatType.NO_REPEAT):
                self.state.playing = False
154
                self.state.current_song_index = -1
155 156 157
                GLib.idle_add(self.update_window)
                return

158 159
            GLib.idle_add(self.on_next_track)

160
        @dbus_propagate(self)
161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191
        def on_player_event(event: PlayerEvent):
            if event.name == 'play_state_change':
                self.state.playing = event.value
            elif event.name == 'volume_change':
                self.state.old_volume = self.state.volume
                self.state.volume = event.value

            GLib.idle_add(self.update_window)

        self.mpv_player = MPVPlayer(
            time_observer,
            on_track_end,
            on_player_event,
            self.state.config,
        )
        self.chromecast_player = ChromecastPlayer(
            time_observer,
            on_track_end,
            on_player_event,
            self.state.config,
        )
        self.player = self.mpv_player

        if self.state.current_device != 'this device':
            # TODO figure out how to activate the chromecast if possible
            # without blocking the main thread. Also, need to make it obvious
            # that we are trying to connect.
            pass

        self.state.current_device = 'this device'

192 193 194
        # Need to do this after we set the current device.
        self.player.volume = self.state.volume

195
        # Prompt to load the play queue from the server.
196
        # TODO should this be behind sync enabled?
197
        if self.state.config.server.sync_enabled:
198 199
            self.update_play_state_from_server(prompt_confirm=True)

Sumner Evans's avatar
Sumner Evans committed
200 201 202
        # Send out to the bus that we exist.
        self.dbus_manager.property_diff()

Sumner Evans's avatar
Sumner Evans committed
203 204
    # ########## DBUS MANAGMENT ########## #
    def do_dbus_register(self, connection, path):
205 206 207
        def get_state_and_player():
            return (self.state, getattr(self, 'player', None))

208
        self.dbus_manager = DBusManager(
Sumner Evans's avatar
Sumner Evans committed
209
            connection,
210 211
            self.on_dbus_method_call,
            self.on_dbus_set_property,
212
            get_state_and_player,
Sumner Evans's avatar
Sumner Evans committed
213 214 215 216
        )
        return True

    def on_dbus_method_call(
217 218 219 220 221 222 223 224
        self,
        connection,
        sender,
        path,
        interface,
        method,
        params,
        invocation,
Sumner Evans's avatar
Sumner Evans committed
225 226 227 228 229 230 231 232 233
    ):
        second_microsecond_conversion = 1000000

        def seek_fn(offset):
            offset_seconds = offset / second_microsecond_conversion
            new_seconds = self.state.song_progress + offset_seconds
            self.on_song_scrub(
                None, new_seconds / self.state.current_song.duration * 100)

234
        def set_pos_fn(track_id, position=0):
235 236 237 238
            if self.state.playing:
                self.on_play_pause()
            pos_seconds = position / second_microsecond_conversion
            self.state.song_progress = pos_seconds
239
            track_id, occurrence = track_id.split('/')[-2:]
240 241 242 243

            # Find the (N-1)th time that the track id shows up in the list. (N
            # is the -*** suffix on the track id.)
            song_index = [
244 245
                i for i, x in enumerate(self.state.play_queue) if x == track_id
            ][int(occurrence) or 0]
246 247

            self.play_song(song_index)
248

249 250 251 252 253 254
        def get_tracks_metadata(track_ids):
            # Have to calculate all of the metadatas so that we can deal with
            # repeat song IDs.
            metadatas = [
                self.dbus_manager.get_mpris_metadata(i, self.state.play_queue)
                for i in range(len(self.state.play_queue))
255
            ]
256 257 258 259 260 261 262 263 264 265
            metadatas = filter(
                lambda m: m['mpris:trackid'] in track_ids, metadatas)
            metadatas = sorted(
                metadatas, key=lambda m: track_ids.index(m['mpris:trackid']))

            metadatas = map(
                lambda m: {k: DBusManager.to_variant(v)
                           for k, v in m.items()},
                metadatas,
            )
266

267
            return GLib.Variant('(aa{sv})', (list(metadatas), ))
268

269
        def activate_playlist(playlist_id):
270
            playlist_id = playlist_id.split('/')[-1]
271 272 273
            playlist = CacheManager.get_playlist(playlist_id).result()

            # Calculate the song id to play.
274
            song_idx = 0
275
            if self.state.shuffle_on:
276
                song_idx = random.randint(0, len(playlist.entry) - 1)
277 278 279

            self.on_song_clicked(
                None,
280
                song_idx,
281 282 283 284 285
                [s.id for s in playlist.entry],
                {'active_playlist_id': playlist_id},
            )

        def get_playlists(index, max_count, order, reverse_order):
286 287 288 289 290 291 292 293
            playlists_result = CacheManager.get_playlists()
            if playlists_result.is_future:
                # We don't want to wait for the response in this case, so just
                # return an empty array.
                return GLib.Variant('(a(oss))', ([], ))

            playlists = playlists_result.result()

294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318
            sorters = {
                'Alphabetical': lambda p: p.name,
                'Created': lambda p: p.created,
                'Modified': lambda p: p.changed,
            }
            playlists.sort(
                key=sorters.get(order, lambda p: p),
                reverse=reverse_order,
            )

            return GLib.Variant(
                '(a(oss))', (
                    [
                        (
                            '/playlist/' + p.id,
                            p.name,
                            CacheManager.get_cover_art_filename(
                                p.coverArt,
                                allow_download=False,
                            ).result() or '',
                        )
                        for p in playlists[index:(index + max_count)]
                        if p.songCount > 0
                    ], ))

Sumner Evans's avatar
Sumner Evans committed
319
        method_call_map = {
320
            'org.mpris.MediaPlayer2': {
321
                'Raise': self.window.present,
322 323
                'Quit': self.window.destroy,
            },
Sumner Evans's avatar
Sumner Evans committed
324 325 326 327 328 329 330 331
            'org.mpris.MediaPlayer2.Player': {
                'Next': self.on_next_track,
                'Previous': self.on_prev_track,
                'Pause': self.state.playing and self.on_play_pause,
                'PlayPause': self.on_play_pause,
                'Stop': self.state.playing and self.on_play_pause,
                'Play': not self.state.playing and self.on_play_pause,
                'Seek': seek_fn,
332
                'SetPosition': set_pos_fn,
333 334 335
            },
            'org.mpris.MediaPlayer2.TrackList': {
                'GoTo': set_pos_fn,
336
                'GetTracksMetadata': get_tracks_metadata,
Sumner Evans's avatar
Sumner Evans committed
337
            },
338 339 340 341
            'org.mpris.MediaPlayer2.Playlists': {
                'ActivatePlaylist': activate_playlist,
                'GetPlaylists': get_playlists,
            },
Sumner Evans's avatar
Sumner Evans committed
342
        }
343
        method = method_call_map.get(interface, {}).get(method)
Sumner Evans's avatar
Sumner Evans committed
344
        if method is None:
Sumner Evans's avatar
Sumner Evans committed
345 346
            logging.warning(
                f'Unknown/unimplemented method: {interface}.{method}.')
Sumner Evans's avatar
Sumner Evans committed
347 348 349
        invocation.return_value(method(*params) if callable(method) else None)

    def on_dbus_set_property(
350 351 352 353 354 355 356
        self,
        connection,
        sender,
        path,
        interface,
        property_name,
        value,
Sumner Evans's avatar
Sumner Evans committed
357 358 359 360 361 362
    ):
        def change_loop(new_loop_status):
            self.state.repeat_type = RepeatType.from_mpris_loop_status(
                new_loop_status.get_string())
            self.update_window()

363
        def set_shuffle(new_val):
Sumner Evans's avatar
Sumner Evans committed
364 365 366
            if new_val.get_boolean() != self.state.shuffle_on:
                self.on_shuffle_press(None, None)

367 368 369
        def set_volume(new_val):
            self.on_volume_change(None, value.get_double() * 100)

Sumner Evans's avatar
Sumner Evans committed
370 371 372 373
        setter_map = {
            'org.mpris.MediaPlayer2.Player': {
                'LoopStatus': change_loop,
                'Rate': lambda _: None,
374 375
                'Shuffle': set_shuffle,
                'Volume': set_volume,
Sumner Evans's avatar
Sumner Evans committed
376 377 378 379 380
            }
        }

        setter = setter_map.get(interface).get(property_name)
        if setter is None:
Sumner Evans's avatar
Sumner Evans committed
381
            logging.warning('Set: Unknown property: {property_name}.')
Sumner Evans's avatar
Sumner Evans committed
382 383 384 385
            return
        if callable(setter):
            setter(value)

Sumner Evans's avatar
Sumner Evans committed
386
    # ########## ACTION HANDLERS ########## #
387
    @dbus_propagate()
388
    def on_refresh_window(self, _, state_updates, force=False):
389 390
        for k, v in state_updates.items():
            setattr(self.state, k, v)
391
        self.update_window(force=force)
392

393 394 395
    def on_configure_servers(self, action, param):
        self.show_configure_servers_dialog()

Sumner Evans's avatar
Sumner Evans committed
396 397 398 399 400
    def on_settings(self, action, param):
        """Show the Settings dialog."""
        dialog = SettingsDialog(self.window, self.state.config)
        result = dialog.run()
        if result == Gtk.ResponseType.OK:
401 402
            self.state.config.port_number = int(
                dialog.data['port_number'].get_text())
Sumner Evans's avatar
Sumner Evans committed
403 404 405 406
            self.state.config.always_stream = dialog.data[
                'always_stream'].get_active()
            self.state.config.download_on_stream = dialog.data[
                'download_on_stream'].get_active()
407 408
            self.state.config.song_play_notification = dialog.data[
                'song_play_notification'].get_active()
409 410
            self.state.config.serve_over_lan = dialog.data[
                'serve_over_lan'].get_active()
Sumner Evans's avatar
Sumner Evans committed
411 412
            self.state.config.prefetch_amount = dialog.data[
                'prefetch_amount'].get_value_as_int()
413 414
            self.state.config.concurrent_download_limit = dialog.data[
                'concurrent_download_limit'].get_value_as_int()
415 416
            self.state.save_config()
            self.reset_state()
Sumner Evans's avatar
Sumner Evans committed
417 418
        dialog.destroy()

419 420 421 422 423 424 425
    def on_window_go_to(self, win, action, value):
        {
            'album': self.on_go_to_album,
            'artist': self.on_go_to_artist,
            'playlist': self.on_go_to_playlist,
        }[action](None, GLib.Variant('s', value))

426
    @dbus_propagate()
427
    def on_play_pause(self, *args):
428
        if self.state.current_song_index < 0:
Sumner Evans's avatar
Sumner Evans committed
429 430
            return

Sumner Evans's avatar
Sumner Evans committed
431
        if self.player.song_loaded:
Sumner Evans's avatar
Sumner Evans committed
432
            self.player.toggle_play()
433
            self.save_play_queue()
Sumner Evans's avatar
Sumner Evans committed
434 435
        else:
            # This is from a restart, start playing the file.
436
            self.play_song(self.state.current_song_index)
Sumner Evans's avatar
Sumner Evans committed
437

Sumner Evans's avatar
Sumner Evans committed
438
        self.state.playing = not self.state.playing
Sumner Evans's avatar
Sumner Evans committed
439 440
        self.update_window()

441
    def on_next_track(self, *args):
Sumner Evans's avatar
Sumner Evans committed
442 443
        # Handle song repeating
        if self.state.repeat_type == RepeatType.REPEAT_SONG:
444
            song_index_to_play = self.state.current_song_index
Sumner Evans's avatar
Sumner Evans committed
445
        # Wrap around the play queue if at the end.
446
        elif self.state.current_song_index == len(self.state.play_queue) - 1:
447 448 449
            # This may happen due to D-Bus.
            if self.state.repeat_type == RepeatType.NO_REPEAT:
                return
450 451 452
            song_index_to_play = 0
        else:
            song_index_to_play = self.state.current_song_index + 1
Sumner Evans's avatar
Sumner Evans committed
453

454
        self.play_song(song_index_to_play, reset=True)
Sumner Evans's avatar
Sumner Evans committed
455

Sumner Evans's avatar
Sumner Evans committed
456
    def on_prev_track(self, *args):
457 458
        # Go back to the beginning of the song if we are past 5 seconds.
        # Otherwise, go to the previous song.
Sumner Evans's avatar
Sumner Evans committed
459
        if self.state.repeat_type == RepeatType.REPEAT_SONG:
460
            song_index_to_play = self.state.current_song_index
461
        elif self.state.song_progress < 5:
462
            if (self.state.current_song_index == 0
Sumner Evans's avatar
Sumner Evans committed
463
                    and self.state.repeat_type == RepeatType.NO_REPEAT):
464
                song_index_to_play = 0
Sumner Evans's avatar
Sumner Evans committed
465
            else:
466 467
                song_index_to_play = (self.state.current_song_index - 1) % len(
                    self.state.play_queue)
468
        else:
469
            song_index_to_play = self.state.current_song_index
470

471
        self.play_song(song_index_to_play, reset=True)
Sumner Evans's avatar
Sumner Evans committed
472

473
    @dbus_propagate()
Sumner Evans's avatar
Sumner Evans committed
474
    def on_repeat_press(self, action, params):
Sumner Evans's avatar
Sumner Evans committed
475 476 477 478
        # Cycle through the repeat types.
        new_repeat_type = RepeatType((self.state.repeat_type.value + 1) % 3)
        self.state.repeat_type = new_repeat_type
        self.update_window()
Sumner Evans's avatar
Sumner Evans committed
479

480
    @dbus_propagate()
Sumner Evans's avatar
Sumner Evans committed
481
    def on_shuffle_press(self, action, params):
Sumner Evans's avatar
Sumner Evans committed
482 483
        if self.state.shuffle_on:
            # Revert to the old play queue.
484 485 486
            self.state.current_song_index = self.state.old_play_queue.index(
                self.state.current_song.id)
            self.state.play_queue = self.state.old_play_queue.copy()
Sumner Evans's avatar
Sumner Evans committed
487 488 489 490 491
        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
492
            del self.state.play_queue[self.state.current_song_index]
Sumner Evans's avatar
Sumner Evans committed
493 494
            random.shuffle(self.state.play_queue)
            self.state.play_queue = [song_id] + self.state.play_queue
495
            self.state.current_song_index = 0
Sumner Evans's avatar
Sumner Evans committed
496

Sumner Evans's avatar
Sumner Evans committed
497 498
        self.state.shuffle_on = not self.state.shuffle_on
        self.update_window()
Sumner Evans's avatar
Sumner Evans committed
499

500
    @dbus_propagate()
Sumner Evans's avatar
Sumner Evans committed
501
    def on_play_next(self, action, song_ids):
502 503 504
        if self.state.current_song is None:
            insert_at = 0
        else:
505
            insert_at = self.state.current_song_index + 1
506

507 508 509
        self.state.play_queue = (
            self.state.play_queue[:insert_at] + list(song_ids)
            + self.state.play_queue[insert_at:])
510 511
        self.state.old_play_queue.extend(song_ids)
        self.update_window()
Sumner Evans's avatar
Sumner Evans committed
512

513
    @dbus_propagate()
Sumner Evans's avatar
Sumner Evans committed
514
    def on_add_to_queue(self, action, song_ids):
515 516 517
        self.state.play_queue.extend(song_ids)
        self.state.old_play_queue.extend(song_ids)
        self.update_window()
Sumner Evans's avatar
Sumner Evans committed
518 519

    def on_go_to_album(self, action, album_id):
520 521
        # Switch to the By Year view (or genre, if year is not available) to
        # guarantee that the album is there.
522
        album = CacheManager.get_album(album_id.get_string()).result()
Sumner Evans's avatar
Sumner Evans committed
523 524 525 526
        if isinstance(album, Directory):
            if len(album.child) > 0:
                album = album.child[0]

527 528 529 530 531 532 533 534 535 536 537
        if album.get('year'):
            self.state.current_album_sort = 'byYear'
            self.state.current_album_from_year = album.year
            self.state.current_album_to_year = album.year
        elif album.get('genre'):
            self.state.current_album_sort = 'byGenre'
            self.state.current_album_genre = album.genre
        else:
            # TODO message?
            return

Sumner Evans's avatar
Sumner Evans committed
538
        self.state.current_tab = 'albums'
539
        self.state.selected_album_id = album_id.get_string()
540
        self.update_window(force=True)
Sumner Evans's avatar
Sumner Evans committed
541 542 543 544 545 546

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

547
    def browse_to(self, action, item_id):
548
        self.state.current_tab = 'browse'
549
        self.state.selected_browse_element_id = item_id.get_string()
550 551
        self.update_window()

552 553 554 555 556
    def on_go_to_playlist(self, action, playlist_id):
        self.state.current_tab = 'playlists'
        self.state.selected_playlist_id = playlist_id.get_string()
        self.update_window()

Sumner Evans's avatar
Sumner Evans committed
557
    def on_server_list_changed(self, action, servers):
Sumner Evans's avatar
Sumner Evans committed
558
        self.state.config.servers = servers
559
        self.state.save_config()
Sumner Evans's avatar
Sumner Evans committed
560

Sumner Evans's avatar
Sumner Evans committed
561
    def on_connected_server_changed(self, action, current_server):
562
        if self.state.config.server:
563
            self.state.save()
564
        self.state.config.current_server = current_server
565
        self.state.save_config()
Sumner Evans's avatar
Sumner Evans committed
566

567
        self.reset_state()
568

569 570 571 572 573 574 575
    def reset_state(self):
        if self.state.playing:
            self.on_play_pause()
        self.loading_state = True
        self.state.load()
        self.player.reset()
        self.loading_state = False
Sumner Evans's avatar
Sumner Evans committed
576

Sumner Evans's avatar
Sumner Evans committed
577
        # Update the window according to the new server configuration.
578
        self.update_window()
Sumner Evans's avatar
Sumner Evans committed
579

Sumner Evans's avatar
Sumner Evans committed
580
    def on_stack_change(self, stack, child):
Sumner Evans's avatar
Sumner Evans committed
581
        self.state.current_tab = stack.get_visible_child_name()
Sumner Evans's avatar
Sumner Evans committed
582 583
        self.update_window()

584
    def on_song_clicked(self, win, song_index, song_queue, metadata):
585 586 587 588
        # Reset the play queue so that we don't ever revert back to the
        # previous one.
        old_play_queue = song_queue.copy()

Sumner Evans's avatar
Sumner Evans committed
589 590 591
        if metadata.get('force_shuffle_state') is not None:
            self.state.shuffle_on = metadata['force_shuffle_state']

592 593
        if metadata.get('active_playlist_id') is not None:
            self.state.active_playlist_id = metadata.get('active_playlist_id')
Sumner Evans's avatar
Cleanup  
Sumner Evans committed
594 595
        else:
            self.state.active_playlist_id = None
596

597
        # If shuffle is enabled, then shuffle the playlist.
598
        if self.state.shuffle_on and not metadata.get('no_reshuffle'):
599 600 601
            song_id = song_queue[song_index]

            del song_queue[song_index]
602 603
            random.shuffle(song_queue)
            song_queue = [song_id] + song_queue
604
            song_index = 0
605 606

        self.play_song(
607
            song_index,
608 609 610 611
            reset=True,
            old_play_queue=old_play_queue,
            play_queue=song_queue,
        )
612

613 614 615 616 617
    def on_songs_removed(self, win, song_indexes_to_remove):
        self.state.play_queue = [
            song_id for i, song_id in enumerate(self.state.play_queue)
            if i not in song_indexes_to_remove
        ]
618 619 620

        # Determine how many songs before the currently playing one were also
        # deleted.
621 622 623 624 625 626
        before_current = [
            i for i in song_indexes_to_remove
            if i < self.state.current_song_index
        ]

        if self.state.current_song_index in song_indexes_to_remove:
627 628 629 630 631 632
            if len(self.state.play_queue) == 0:
                self.on_play_pause()
                self.state.current_song_index = -1
                self.update_window()
                return

633 634 635 636 637 638 639
            self.state.current_song_index -= len(before_current)
            self.play_song(self.state.current_song_index, reset=True)
        else:
            self.state.current_song_index -= len(before_current)
            self.update_window()
            self.save_play_queue()

640
    @dbus_propagate()
641 642 643 644 645
    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)
646

647 648 649 650 651 652
        self.state.song_progress = new_time
        self.window.player_controls.update_scrubber(
            self.state.song_progress, self.state.current_song.duration)

        # If already playing, then make the player itself seek.
        if self.player.song_loaded:
Sumner Evans's avatar
Sumner Evans committed
653
            self.player.seek(new_time)
654 655

        self.save_play_queue()
Sumner Evans's avatar
Sumner Evans committed
656

Sumner Evans's avatar
Sumner Evans committed
657
    def on_device_update(self, _, device_uuid):
Sumner Evans's avatar
Sumner Evans committed
658
        if device_uuid == self.state.current_device:
Sumner Evans's avatar
Sumner Evans committed
659
            return
Sumner Evans's avatar
Sumner Evans committed
660
        self.state.current_device = device_uuid
Sumner Evans's avatar
Sumner Evans committed
661

Sumner Evans's avatar
Sumner Evans committed
662 663 664 665
        was_playing = self.state.playing
        self.player.pause()
        self.player._song_loaded = False
        self.state.playing = False
666 667

        self.dbus_manager.property_diff()
Sumner Evans's avatar
Sumner Evans committed
668 669 670 671 672 673 674 675 676 677
        self.update_window()

        if device_uuid == 'this device':
            self.player = self.mpv_player
        else:
            self.chromecast_player.set_playing_chromecast(device_uuid)
            self.player = self.chromecast_player

        if was_playing:
            self.on_play_pause()
678
            self.dbus_manager.property_diff()
Sumner Evans's avatar
Sumner Evans committed
679

680
    @dbus_propagate()
681
    def on_mute_toggle(self, action, _):
682 683
        self.state.is_muted = not self.state.is_muted
        self.player.is_muted = self.state.is_muted
684 685
        self.update_window()

686
    @dbus_propagate()
687 688
    def on_volume_change(self, _, value):
        self.state.volume = value
689 690 691
        self.player.volume = self.state.volume
        self.update_window()

692
    def on_window_key_press(self, window, event):
693
        # Need to use bitwise & here to see if CTRL is pressed.
694
        if (event.keyval == 102
695
                and event.state & Gdk.ModifierType.CONTROL_MASK):
696 697 698 699 700 701 702
            # Ctrl + F
            window.search_entry.grab_focus()
            return

        if window.search_entry.has_focus():
            return False

Sumner Evans's avatar
Sumner Evans committed
703 704 705 706 707 708 709 710 711
        keymap = {
            32: self.on_play_pause,
            65360: self.on_prev_track,
            65367: self.on_next_track,
        }

        action = keymap.get(event.keyval)
        if action:
            action()
712 713 714
            return True

    def on_app_shutdown(self, app):
715
        Notify.uninit()
716

717
        if self.state.config.server is None:
718 719
            return

Sumner Evans's avatar
Sumner Evans committed
720
        self.player.pause()
721 722 723
        self.chromecast_player.shutdown()
        self.mpv_player.shutdown()

724 725
        self.state.save()
        self.save_play_queue()
726
        self.dbus_manager.shutdown()
727
        CacheManager.shutdown()
728

Sumner Evans's avatar
Sumner Evans committed
729
    # ########## HELPER METHODS ########## #
730
    def show_configure_servers_dialog(self):
Sumner Evans's avatar
Sumner Evans committed
731
        """Show the Connect to Server dialog."""
Sumner Evans's avatar
Sumner Evans committed
732
        dialog = ConfigureServersDialog(self.window, self.state.config)
Sumner Evans's avatar
Sumner Evans committed
733
        dialog.connect('server-list-changed', self.on_server_list_changed)
734 735
        dialog.connect(
            'connected-server-changed', self.on_connected_server_changed)
736 737
        dialog.run()
        dialog.destroy()
Sumner Evans's avatar
Sumner Evans committed
738

739 740
    def update_window(self, force=False):
        GLib.idle_add(lambda: self.window.update(self.state, force=force))
741

742
    def update_play_state_from_server(self, prompt_confirm=False):
Sumner Evans's avatar
Sumner Evans committed
743 744
        # TODO need to make the up next list loading for the duration here
        was_playing = self.state.playing
745 746
        self.player.pause()
        self.state.playing = False
Sumner Evans's avatar
Sumner Evans committed
747
        self.update_window()
748

Sumner Evans's avatar
Sumner Evans committed
749 750
        def do_update(f):
            play_queue = f.result()
751 752 753 754 755 756
            new_play_queue = [s.id for s in play_queue.entry]
            new_current_song_id = str(play_queue.current)
            new_song_progress = play_queue.position / 1000

            if prompt_confirm:
                # If there's not a significant enough difference, don't prompt.
757 758 759 760 761
                progress_diff = 15
                if self.state.song_progress:
                    progress_diff = abs(
                        self.state.song_progress - new_song_progress)

762
                if (self.state.play_queue == new_play_queue
763
                        and self.state.current_song
764
                        and self.state.current_song.id == new_current_song_id
Sumner Evans's avatar
Sumner Evans committed
765
                        and progress_diff < 15):
766 767 768
                    return

                dialog = Gtk.MessageDialog(
769
                    transient_for=self.window,
770
                    message_type=Gtk.MessageType.INFO,
771
                    buttons=Gtk.ButtonsType.YES_NO,
772 773
                    text='Resume Playback?',
                )
774

775
                dialog.format_secondary_markup(
776 777
                    'Do you want to resume the play queue saved by '
                    + str(play_queue.changedBy) + ' at '
778 779
                    + play_queue.changed.astimezone(
                        tz=None).strftime('%H:%M on %Y-%m-%d') + '?')
780
                result = dialog.run()
781 782 783
                dialog.destroy()
                if result != Gtk.ResponseType.YES:
                    return