import logging
import math
import os
import random
import sys
from concurrent.futures import Future
from pathlib import Path
from typing import Any, Callable, Dict, Iterable, List, Optional, Tuple

try:
    import osxmmkeys

    tap_imported = True
except Exception:
    tap_imported = False

import gi

gi.require_version("Gtk", "3.0")
from gi.repository import Gdk, GdkPixbuf, Gio, GLib, Gtk

try:
    gi.require_version("Notify", "0.7")
    from gi.repository import Notify

    glib_notify_exists = True
except Exception:
    # I really don't care what kind of exception it is, all that matters is the
    # import failed for some reason.
    logging.warning(
        "Unable to import Notify from GLib. Notifications will be disabled."
    )
    glib_notify_exists = False

from .adapters import AdapterManager
from .adapters.api_objects import Playlist
from .cache_manager import CacheManager
from .config import AppConfiguration, ReplayGainType
from .dbus import dbus_propagate, DBusManager
from .players import ChromecastPlayer, MPVPlayer, PlayerEvent
from .server.api_objects import Child, Directory
from .ui.configure_servers import ConfigureServersDialog
from .ui.main import MainWindow
from .ui.settings import SettingsDialog
from .ui.state import RepeatType


class SublimeMusicApp(Gtk.Application):
    def __init__(self, config_file: Path):
        super().__init__(application_id="com.sumnerevans.sublimemusic")
        if glib_notify_exists:
            Notify.init("Sublime Music")

        self.window: Optional[Gtk.Window] = None
        self.app_config = AppConfiguration.load_from_file(config_file)
        self.player = None
        self.dbus_manager: Optional[DBusManager] = None

        self.connect("shutdown", self.on_app_shutdown)

    def do_startup(self):
        Gtk.Application.do_startup(self)

        def add_action(name: str, fn: Callable, parameter_type: str = None):
            """Registers an action with the application."""
            if type(parameter_type) == str:
                parameter_type = GLib.VariantType(parameter_type)
            action = Gio.SimpleAction.new(name, parameter_type)
            action.connect("activate", fn)
            self.add_action(action)

        # Add action for menu items.
        add_action("configure-servers", self.on_configure_servers)
        add_action("settings", self.on_settings)

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

        # 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")
        add_action("browse-to", self.browse_to, parameter_type="s")
        add_action("go-to-playlist", self.on_go_to_playlist, parameter_type="s")

        add_action("mute-toggle", self.on_mute_toggle)
        add_action(
            "update-play-queue-from-server",
            lambda a, p: self.update_play_state_from_server(),
        )

        if tap_imported:
            self.tap = osxmmkeys.Tap()
            self.tap.on("play_pause", self.on_play_pause)
            self.tap.on("next_track", self.on_next_track)
            self.tap.on("prev_track", self.on_prev_track)
            self.tap.start()

    def do_activate(self):
        # We only allow a single window and raise any existing ones
        if self.window:
            self.window.present()
            return

        # Windows are associated with the application when the last one is
        # closed the application shuts down.
        self.window = MainWindow(application=self, title="Sublime Music")

        # 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()
        context.add_provider_for_screen(
            screen, css_provider, Gtk.STYLE_PROVIDER_PRIORITY_USER
        )

        self.window.stack.connect(
            "notify::visible-child", self.on_stack_change,
        )
        self.window.connect("song-clicked", self.on_song_clicked)
        self.window.connect("songs-removed", self.on_songs_removed)
        self.window.connect("refresh-window", self.on_refresh_window)
        self.window.connect("go-to", self.on_window_go_to)
        self.window.connect("key-press-event", self.on_window_key_press)
        self.window.player_controls.connect("song-scrub", self.on_song_scrub)
        self.window.player_controls.connect("device-update", self.on_device_update)
        self.window.player_controls.connect("volume-change", self.on_volume_change)

        self.window.show_all()
        self.window.present()

        # Load the state for the server, if it exists.
        self.app_config.load_state()

        # If there is no current server, show the dialog to select a server.
        if self.app_config.server is None:
            self.show_configure_servers_dialog()

            # If they didn't add one with the dialog, close the window.
            if self.app_config.server is None:
                self.window.close()
                return

        self.update_window()

        # Configure the players
        self.last_play_queue_update = 0
        self.loading_state = False
        self.should_scrobble_song = False

        def time_observer(value: Optional[float]):
            if (
                self.loading_state
                or not self.window
                or not self.app_config.state.current_song
            ):
                return

            if value is None:
                self.last_play_queue_update = 0
                return

            self.app_config.state.song_progress = value
            GLib.idle_add(
                self.window.player_controls.update_scrubber,
                self.app_config.state.song_progress,
                self.app_config.state.current_song.duration,
            )

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

            if value > 5 and self.should_scrobble_song:
                CacheManager.scrobble(self.app_config.state.current_song.id)
                self.should_scrobble_song = False

        def on_track_end():
            at_end = (
                self.app_config.state.current_song_index
                == len(self.app_config.state.play_queue) - 1
            )
            no_repeat = self.app_config.state.repeat_type == RepeatType.NO_REPEAT
            if at_end and no_repeat:
                self.app_config.state.playing = False
                self.app_config.state.current_song_index = -1
                GLib.idle_add(self.update_window)
                return

            GLib.idle_add(self.on_next_track)

        @dbus_propagate(self)
        def on_player_event(event: PlayerEvent):
            if event.name == "play_state_change":
                self.app_config.state.playing = event.value
            elif event.name == "volume_change":
                self.app_config.state.volume = event.value

            GLib.idle_add(self.update_window)

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

        if self.app_config.state.current_device != "this device":
            # TODO (#120)
            pass

        self.app_config.state.current_device = "this device"

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

        # Prompt to load the play queue from the server.
        if self.app_config.server.sync_enabled:
            self.update_play_state_from_server(prompt_confirm=True)

        # Send out to the bus that we exist.
        if self.dbus_manager:
            self.dbus_manager.property_diff()

    # ########## DBUS MANAGMENT ########## #
    def do_dbus_register(self, connection: Gio.DBusConnection, path: str) -> bool:
        self.dbus_manager = DBusManager(
            connection,
            self.on_dbus_method_call,
            self.on_dbus_set_property,
            lambda: (self.app_config, self.player),
        )
        return True

    def on_dbus_method_call(
        self,
        connection: Gio.DBusConnection,
        sender: str,
        path: str,
        interface: str,
        method: str,
        params: GLib.Variant,
        invocation: Gio.DBusMethodInvocation,
    ):
        second_microsecond_conversion = 1000000

        def seek_fn(offset: float):
            if not self.app_config.state.current_song:
                return
            offset_seconds = offset / second_microsecond_conversion
            new_seconds = self.app_config.state.song_progress + offset_seconds

            # This should not ever happen. The current_song should always have
            # a duration, but the Child object has `duration` optional because
            # it could be a directory.
            assert self.app_config.state.current_song.duration is not None
            self.on_song_scrub(
                None, new_seconds / self.app_config.state.current_song.duration * 100,
            )

        def set_pos_fn(track_id: str, position: float = 0):
            if self.app_config.state.playing:
                self.on_play_pause()
            pos_seconds = position / second_microsecond_conversion
            self.app_config.state.song_progress = pos_seconds
            track_id, occurrence = track_id.split("/")[-2:]

            # 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 = [
                i
                for i, x in enumerate(self.app_config.state.play_queue)
                if x == track_id
            ][int(occurrence) or 0]

            self.play_song(song_index)

        def get_tracks_metadata(track_ids: List[str]) -> GLib.Variant:
            if not self.dbus_manager:
                return

            if len(track_ids) == 0:
                # We are lucky, just return an empty list.
                return GLib.Variant("(aa{sv})", ([],))

            # Have to calculate all of the metadatas so that we can deal with
            # repeat song IDs.
            metadatas: Iterable[Any] = [
                self.dbus_manager.get_mpris_metadata(
                    i, self.app_config.state.play_queue,
                )
                for i in range(len(self.app_config.state.play_queue))
            ]

            # Get rid of all of the tracks that were not requested.
            metadatas = list(
                filter(lambda m: m["mpris:trackid"] in track_ids, metadatas)
            )

            assert len(metadatas) == len(track_ids)

            # Sort them so they get returned in the same order as they were
            # requested.
            metadatas = sorted(
                metadatas, key=lambda m: track_ids.index(m["mpris:trackid"])
            )

            # Turn them into dictionaries that can actually be serialized into
            # a GLib.Variant.
            metadatas = map(
                lambda m: {k: DBusManager.to_variant(v) for k, v in m.items()},
                metadatas,
            )

            return GLib.Variant("(aa{sv})", (list(metadatas),))

        def activate_playlist(playlist_id: str):
            playlist_id = playlist_id.split("/")[-1]
            playlist = AdapterManager.get_playlist_details(playlist_id).result()

            # Calculate the song id to play.
            song_idx = 0
            if self.app_config.state.shuffle_on:
                song_idx = random.randint(0, len(playlist.songs) - 1)

            self.on_song_clicked(
                None,
                song_idx,
                [s.id for s in playlist.songs],
                {"active_playlist_id": playlist_id},
            )

        def get_playlists(
            index: int, max_count: int, order: str, reverse_order: bool,
        ) -> GLib.Variant:
            playlists_result = AdapterManager.get_playlists()
            if not playlists_result.data_is_available:
                # We don't want to wait for the response in this case, so just
                # return an empty array.
                return GLib.Variant("(a(oss))", ([],))

            playlists = list(playlists_result.result())

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

            def make_playlist_tuple(p: Playlist) -> GLib.Variant:
                cover_art_filename = CacheManager.get_cover_art_filename(
                    p.cover_art, allow_download=False,
                ).result()
                return (f"/playlist/{p.id}", p.name, cover_art_filename or "")

            return GLib.Variant(
                "(a(oss))",
                (
                    [
                        make_playlist_tuple(p)
                        for p in playlists[index : (index + max_count)]
                    ],
                ),
            )

        def play():
            if not self.app_config.state.playing:
                self.on_play_pause()

        def pause():
            if self.app_config.state.playing:
                self.on_play_pause()

        method_call_map: Dict[str, Dict[str, Any]] = {
            "org.mpris.MediaPlayer2": {
                "Raise": self.window and self.window.present,
                "Quit": self.window and self.window.destroy,
            },
            "org.mpris.MediaPlayer2.Player": {
                "Next": self.on_next_track,
                "Previous": self.on_prev_track,
                "Pause": pause,
                "PlayPause": self.on_play_pause,
                "Stop": pause,
                "Play": play,
                "Seek": seek_fn,
                "SetPosition": set_pos_fn,
            },
            "org.mpris.MediaPlayer2.TrackList": {
                "GoTo": set_pos_fn,
                "GetTracksMetadata": get_tracks_metadata,
                # 'RemoveTrack': remove_track,
            },
            "org.mpris.MediaPlayer2.Playlists": {
                "ActivatePlaylist": activate_playlist,
                "GetPlaylists": get_playlists,
            },
        }
        method_fn = method_call_map.get(interface, {}).get(method)
        if method_fn is None:
            logging.warning(f"Unknown/unimplemented method: {interface}.{method}.")
        invocation.return_value(method_fn(*params) if callable(method_fn) else None)

    def on_dbus_set_property(
        self,
        connection: Gio.DBusConnection,
        sender: str,
        path: str,
        interface: str,
        property_name: str,
        value: GLib.Variant,
    ):
        def change_loop(new_loop_status: GLib.Variant):
            self.app_config.state.repeat_type = RepeatType.from_mpris_loop_status(
                new_loop_status.get_string()
            )
            self.update_window()

        def set_shuffle(new_val: GLib.Variant):
            if new_val.get_boolean() != self.app_config.state.shuffle_on:
                self.on_shuffle_press(None, None)

        def set_volume(new_val: GLib.Variant):
            self.on_volume_change(None, new_val.get_double() * 100)

        setter_map: Dict[str, Dict[str, Any]] = {
            "org.mpris.MediaPlayer2.Player": {
                "LoopStatus": change_loop,
                "Rate": lambda _: None,
                "Shuffle": set_shuffle,
                "Volume": set_volume,
            }
        }

        setter = setter_map.get(interface, {}).get(property_name)
        if setter is None:
            logging.warning("Set: Unknown property: {property_name}.")
            return
        if callable(setter):
            setter(value)

    # ########## ACTION HANDLERS ########## #
    @dbus_propagate()
    def on_refresh_window(
        self, _, state_updates: Dict[str, Any], force: bool = False,
    ):
        for k, v in state_updates.items():
            setattr(self.app_config.state, k, v)
        self.update_window(force=force)

    def on_configure_servers(self, *args):
        self.show_configure_servers_dialog()

    def on_settings(self, *args):
        """Show the Settings dialog."""
        dialog = SettingsDialog(self.window, self.app_config)
        result = dialog.run()
        if result == Gtk.ResponseType.OK:
            self.app_config.port_number = int(dialog.data["port_number"].get_text())
            self.app_config.always_stream = dialog.data["always_stream"].get_active()
            self.app_config.download_on_stream = dialog.data[
                "download_on_stream"
            ].get_active()
            self.app_config.song_play_notification = dialog.data[
                "song_play_notification"
            ].get_active()
            self.app_config.serve_over_lan = dialog.data["serve_over_lan"].get_active()
            self.app_config.prefetch_amount = dialog.data[
                "prefetch_amount"
            ].get_value_as_int()
            self.app_config.concurrent_download_limit = dialog.data[
                "concurrent_download_limit"
            ].get_value_as_int()
            self.app_config.replay_gain = ReplayGainType.from_string(
                dialog.data["replay_gain"].get_active_id()
            )
            self.app_config.save()
            self.reset_state()
        dialog.destroy()

    def on_window_go_to(self, win: Any, action: str, value: str):
        {
            "album": self.on_go_to_album,
            "artist": self.on_go_to_artist,
            "playlist": self.on_go_to_playlist,
        }[action](None, GLib.Variant("s", value))

    @dbus_propagate()
    def on_play_pause(self, *args):
        if self.app_config.state.current_song_index < 0:
            return

        if self.player.song_loaded:
            self.player.toggle_play()
            self.save_play_queue()
        else:
            # This is from a restart, start playing the file.
            self.play_song(self.app_config.state.current_song_index)

        self.app_config.state.playing = not self.app_config.state.playing
        self.update_window()

    def on_next_track(self, *args):
        # Handle song repeating
        if self.app_config.state.repeat_type == RepeatType.REPEAT_SONG:
            song_index_to_play = self.app_config.state.current_song_index
        # Wrap around the play queue if at the end.
        elif (
            self.app_config.state.current_song_index
            == len(self.app_config.state.play_queue) - 1
        ):
            # This may happen due to D-Bus.
            if self.app_config.state.repeat_type == RepeatType.NO_REPEAT:
                return
            song_index_to_play = 0
        else:
            song_index_to_play = self.app_config.state.current_song_index + 1

        self.play_song(song_index_to_play, reset=True)

    def on_prev_track(self, *args):
        # Go back to the beginning of the song if we are past 5 seconds.
        # Otherwise, go to the previous song.
        no_repeat = self.app_config.state.repeat_type == RepeatType.NO_REPEAT
        if self.app_config.state.repeat_type == RepeatType.REPEAT_SONG:
            song_index_to_play = self.app_config.state.current_song_index
        elif self.app_config.state.song_progress < 5:
            if self.app_config.state.current_song_index == 0 and no_repeat:
                song_index_to_play = 0
            else:
                song_index_to_play = (
                    self.app_config.state.current_song_index - 1
                ) % len(self.app_config.state.play_queue)
        else:
            # Go back to the beginning of the song.
            song_index_to_play = self.app_config.state.current_song_index

        self.play_song(song_index_to_play, reset=True)

    @dbus_propagate()
    def on_repeat_press(self, *args):
        # Cycle through the repeat types.
        new_repeat_type = RepeatType((self.app_config.state.repeat_type.value + 1) % 3)
        self.app_config.state.repeat_type = new_repeat_type
        self.update_window()

    @dbus_propagate()
    def on_shuffle_press(self, *args):
        if self.app_config.state.shuffle_on:
            # Revert to the old play queue.
            self.app_config.state.current_song_index = self.app_config.state.old_play_queue.index(
                self.app_config.state.current_song.id
            )
            self.app_config.state.play_queue = (
                self.app_config.state.old_play_queue.copy()
            )
        else:
            self.app_config.state.old_play_queue = (
                self.app_config.state.play_queue.copy()
            )

            # Remove the current song, then shuffle and put the song back.
            song_id = self.app_config.state.current_song.id
            del self.app_config.state.play_queue[
                self.app_config.state.current_song_index
            ]
            random.shuffle(self.app_config.state.play_queue)
            self.app_config.state.play_queue = [
                song_id
            ] + self.app_config.state.play_queue
            self.app_config.state.current_song_index = 0

        self.app_config.state.shuffle_on = not self.app_config.state.shuffle_on
        self.update_window()

    @dbus_propagate()
    def on_play_next(self, action: Any, song_ids: List[str]):
        if self.app_config.state.current_song is None:
            insert_at = 0
        else:
            insert_at = self.app_config.state.current_song_index + 1

        self.app_config.state.play_queue = (
            self.app_config.state.play_queue[:insert_at]
            + list(song_ids)
            + self.app_config.state.play_queue[insert_at:]
        )
        self.app_config.state.old_play_queue.extend(song_ids)
        self.update_window()

    @dbus_propagate()
    def on_add_to_queue(self, action: Any, song_ids: GLib.Variant):
        self.app_config.state.play_queue.extend(song_ids)
        self.app_config.state.old_play_queue.extend(song_ids)
        self.update_window()

    def on_go_to_album(self, action: Any, album_id: GLib.Variant):
        # Switch to the By Year view (or genre, if year is not available) to
        # guarantee that the album is there.
        album = CacheManager.get_album(album_id.get_string()).result()
        if isinstance(album, Directory):
            if len(album.child) > 0:
                album = album.child[0]

        if year := album.get("year"):
            self.app_config.state.current_album_sort = "byYear"
            self.app_config.state.current_album_from_year = year
            self.app_config.state.current_album_to_year = year
        elif genre := album.get("genre"):
            self.app_config.state.current_album_sort = "byGenre"
            self.app_config.state.current_album_genre = genre
        else:
            dialog = Gtk.MessageDialog(
                transient_for=self.window,
                message_type=Gtk.MessageType.ERROR,
                buttons=Gtk.ButtonsType.OK,
                text="Could not go to album",
            )
            dialog.format_secondary_markup(
                "Could not go to the album because it does not have a year or " "genre."
            )
            dialog.run()
            dialog.destroy()
            return

        self.app_config.state.current_tab = "albums"
        self.app_config.state.selected_album_id = album_id.get_string()
        self.update_window(force=True)

    def on_go_to_artist(self, action: Any, artist_id: GLib.Variant):
        self.app_config.state.current_tab = "artists"
        self.app_config.state.selected_artist_id = artist_id.get_string()
        self.update_window()

    def browse_to(self, action: Any, item_id: GLib.Variant):
        self.app_config.state.current_tab = "browse"
        self.app_config.state.selected_browse_element_id = item_id.get_string()
        self.update_window()

    def on_go_to_playlist(self, action: Any, playlist_id: GLib.Variant):
        self.app_config.state.current_tab = "playlists"
        self.app_config.state.selected_playlist_id = playlist_id.get_string()
        self.update_window()

    def on_server_list_changed(self, action: Any, servers: GLib.Variant):
        # TODO do the save to the keyring here

        # keyring.set_password(
        #     'com.sumnerevans.SublimeMusic',
        #     f'{self.username}@{self.server_address}',
        #     password,
        # )
        # def get_password(self) -> str:
        #     try:
        #         return keyring.get_password(
        #             'com.sumnerevans.SublimeMusic',
        #             f'{self.username}@{self.server_address}',
        #         )
        #     except Exception:
        #         return self.password
        self.app_config.servers = servers
        self.app_config.save()

    def on_connected_server_changed(
        self, action: Any, current_server_index: int,
    ):
        if self.app_config.server:
            self.app_config.save()
        self.app_config.current_server_index = current_server_index
        self.app_config.save()

        self.reset_state()

    def reset_state(self):
        if self.app_config.state.playing:
            self.on_play_pause()
        self.loading_state = True
        self.app_config.state.load()
        self.player.reset()
        self.loading_state = False

        # Update the window according to the new server configuration.
        self.update_window()

    def on_stack_change(self, stack: Gtk.Stack, _):
        self.app_config.state.current_tab = stack.get_visible_child_name()
        self.update_window()

    def on_song_clicked(
        self,
        win: Any,
        song_index: int,
        song_queue: List[str],
        metadata: Dict[str, Any],
    ):
        # Reset the play queue so that we don't ever revert back to the
        # previous one.
        old_play_queue = song_queue.copy()

        if (force_shuffle := metadata.get("force_shuffle_state")) is not None:
            self.app_config.state.shuffle_on = force_shuffle

        self.app_config.state.active_playlist_id = metadata.get("active_playlist_id")

        # If shuffle is enabled, then shuffle the playlist.
        if self.app_config.state.shuffle_on and not metadata.get("no_reshuffle"):
            song_id = song_queue[song_index]

            del song_queue[song_index]
            random.shuffle(song_queue)
            song_queue = [song_id] + song_queue
            song_index = 0

        self.play_song(
            song_index,
            reset=True,
            old_play_queue=old_play_queue,
            play_queue=song_queue,
        )

    def on_songs_removed(self, win: Any, song_indexes_to_remove: List[int]):
        self.app_config.state.play_queue = [
            song_id
            for i, song_id in enumerate(self.app_config.state.play_queue)
            if i not in song_indexes_to_remove
        ]

        # Determine how many songs before the currently playing one were also
        # deleted.
        before_current = [
            i
            for i in song_indexes_to_remove
            if i < self.app_config.state.current_song_index
        ]

        if self.app_config.state.current_song_index in song_indexes_to_remove:
            if len(self.app_config.state.play_queue) == 0:
                self.on_play_pause()
                self.app_config.state.current_song_index = -1
                self.update_window()
                return

            self.app_config.state.current_song_index -= len(before_current)
            self.play_song(
                self.app_config.state.current_song_index, reset=True,
            )
        else:
            self.app_config.state.current_song_index -= len(before_current)
            self.update_window()
            self.save_play_queue()

    @dbus_propagate()
    def on_song_scrub(self, win: Any, scrub_value: float):
        if not self.app_config.state.current_song or not self.window:
            return

        # This should not ever happen. The current_song should always have
        # a duration, but the Child object has `duration` optional because
        # it could be a directory.
        assert self.app_config.state.current_song.duration is not None
        new_time = self.app_config.state.current_song.duration * (scrub_value / 100)

        self.app_config.state.song_progress = new_time
        self.window.player_controls.update_scrubber(
            self.app_config.state.song_progress,
            self.app_config.state.current_song.duration,
        )

        # If already playing, then make the player itself seek.
        if self.player.song_loaded:
            self.player.seek(new_time)

        self.save_play_queue()

    def on_device_update(self, win: Any, device_uuid: str):
        if device_uuid == self.app_config.state.current_device:
            return
        self.app_config.state.current_device = device_uuid

        was_playing = self.app_config.state.playing
        self.player.pause()
        self.player._song_loaded = False
        self.app_config.state.playing = False

        if self.dbus_manager:
            self.dbus_manager.property_diff()

        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()
            if self.dbus_manager:
                self.dbus_manager.property_diff()

    @dbus_propagate()
    def on_mute_toggle(self, *args):
        self.app_config.state.is_muted = not self.app_config.state.is_muted
        self.player.is_muted = self.app_config.state.is_muted
        self.update_window()

    @dbus_propagate()
    def on_volume_change(self, _, value: float):
        self.app_config.state.volume = value
        self.player.volume = self.app_config.state.volume
        self.update_window()

    def on_window_key_press(self, window: Gtk.Window, event: Gdk.EventKey,) -> bool:
        # Need to use bitwise & here to see if CTRL is pressed.
        if event.keyval == 102 and event.state & Gdk.ModifierType.CONTROL_MASK:
            # Ctrl + F
            window.search_entry.grab_focus()
            return False

        if window.search_entry.has_focus():
            return False

        keymap = {
            32: self.on_play_pause,
            65360: self.on_prev_track,
            65367: self.on_next_track,
        }

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

        return False

    def on_app_shutdown(self, app: "SublimeMusicApp"):
        if glib_notify_exists:
            Notify.uninit()

        if tap_imported and self.tap:
            self.tap.stop()

        if self.app_config.server is None:
            return

        self.player.pause()
        self.chromecast_player.shutdown()
        self.mpv_player.shutdown()

        self.app_config.save()
        self.save_play_queue()
        if self.dbus_manager:
            self.dbus_manager.shutdown()
        CacheManager.shutdown()
        AdapterManager.shutdown()

    # ########## HELPER METHODS ########## #
    def show_configure_servers_dialog(self):
        """Show the Connect to Server dialog."""
        dialog = ConfigureServersDialog(self.window, self.app_config)
        dialog.connect("server-list-changed", self.on_server_list_changed)
        dialog.connect("connected-server-changed", self.on_connected_server_changed)
        dialog.run()
        dialog.destroy()

    def update_window(self, force: bool = False):
        if not self.window:
            return
        GLib.idle_add(lambda: self.window.update(self.app_config, force=force))

    def update_play_state_from_server(self, prompt_confirm: bool = False):
        # TODO (#129): need to make the up next list loading for the duration
        # here if prompt_confirm is False.
        was_playing = self.app_config.state.playing
        self.player.pause()
        self.app_config.state.playing = False
        self.update_window()

        def do_update(f: Future):
            play_queue = f.result()
            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.
                progress_diff = 15
                if self.app_config.state.song_progress:
                    progress_diff = abs(
                        self.app_config.state.song_progress - new_song_progress
                    )

                if (
                    self.app_config.state.play_queue == new_play_queue
                    and self.app_config.state.current_song
                ):
                    song_id = self.app_config.state.current_song.id
                    if song_id == new_current_song_id and progress_diff < 15:
                        return

                dialog = Gtk.MessageDialog(
                    transient_for=self.window,
                    message_type=Gtk.MessageType.INFO,
                    buttons=Gtk.ButtonsType.YES_NO,
                    text="Resume Playback?",
                )

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

            self.app_config.state.play_queue = new_play_queue
            self.app_config.state.song_progress = play_queue.position / 1000

            self.app_config.state.current_song_index = self.app_config.state.play_queue.index(
                new_current_song_id
            )

            self.player.reset()
            self.update_window()

            if was_playing:
                self.on_play_pause()

        play_queue_future = CacheManager.get_play_queue()
        play_queue_future.add_done_callback(lambda f: GLib.idle_add(do_update, f))

    song_playing_order_token = 0

    def play_song(
        self,
        song_index: int,
        reset: bool = False,
        old_play_queue: List[str] = None,
        play_queue: List[str] = None,
    ):
        # Do this the old fashioned way so that we can have access to ``reset``
        # in the callback.
        @dbus_propagate(self)
        def do_play_song(song: Child):
            uri, stream = CacheManager.get_song_filename_or_stream(
                song, force_stream=self.app_config.always_stream,
            )
            # Prevent it from doing the thing where it continually loads
            # songs when it has to download.
            if reset:
                self.player.reset()
                self.app_config.state.song_progress = 0
                self.should_scrobble_song = True

            # Show a song play notification.
            if self.app_config.song_play_notification:
                try:
                    if glib_notify_exists:
                        notification_lines = []
                        if song.album:
                            notification_lines.append(f"<i>{song.album}</i>")
                        if song.artist:
                            notification_lines.append(song.artist)
                        song_notification = Notify.Notification.new(
                            song.title, "\n".join(notification_lines),
                        )
                        song_notification.add_action(
                            "clicked",
                            "Open Sublime Music",
                            lambda *a: self.window.present() if self.window else None,
                        )
                        song_notification.show()

                        def on_cover_art_download_complete(
                            cover_art_filename: str, order_token: int,
                        ):
                            if order_token != self.song_playing_order_token:
                                return

                            # Add the image to the notification, and re-show
                            # the notification.
                            song_notification.set_image_from_pixbuf(
                                GdkPixbuf.Pixbuf.new_from_file_at_scale(
                                    cover_art_filename, 70, 70, True
                                )
                            )
                            song_notification.show()

                        def get_cover_art_filename(order_token: int) -> Tuple[str, int]:
                            return (
                                CacheManager.get_cover_art_filename(
                                    song.coverArt
                                ).result(),
                                order_token,
                            )

                        self.song_playing_order_token += 1
                        cover_art_future = CacheManager.create_future(
                            get_cover_art_filename, self.song_playing_order_token,
                        )
                        cover_art_future.add_done_callback(
                            lambda f: on_cover_art_download_complete(*f.result())
                        )
                    if sys.platform == "darwin":
                        notification_lines = []
                        if song.album:
                            notification_lines.append(song.album)
                        if song.artist:
                            notification_lines.append(song.artist)
                        notification_text = "\n".join(notification_lines)
                        osascript_command = [
                            "display",
                            "notification",
                            f'"{notification_text}"',
                            "with",
                            "title",
                            f'"{song.title}"',
                        ]

                        os.system(f"osascript -e '{' '.join(osascript_command)}'")
                except Exception:
                    logging.warning(
                        "Unable to display notification. Is a notification "
                        "daemon running?"
                    )

            def on_song_download_complete(song_id: int):
                if (
                    self.app_config.state.current_song
                    and self.app_config.state.current_song.id != song.id
                ):
                    return
                if not self.app_config.state.playing:
                    return

                # Switch to the local media if the player can hotswap (MPV can,
                # Chromecast cannot hotswap without lag).
                if self.player.can_hotswap_source:
                    self.player.play_media(
                        CacheManager.get_song_filename_or_stream(song)[0],
                        self.app_config.state.song_progress,
                        song,
                    )
                GLib.idle_add(self.update_window)

            # If streaming, also download the song, unless configured not to,
            # or configured to always stream.
            if (
                stream
                and self.app_config.download_on_stream
                and not self.app_config.always_stream
            ):
                CacheManager.batch_download_songs(
                    [song.id],
                    before_download=lambda: self.update_window(),
                    on_song_download_complete=on_song_download_complete,
                )

            self.player.play_media(
                uri, 0 if reset else self.app_config.state.song_progress, song,
            )
            self.app_config.state.playing = True
            self.update_window()

            # Prefetch songs
            if self.app_config.state.repeat_type != RepeatType.REPEAT_SONG:
                song_idx = self.app_config.state.play_queue.index(song.id)
                repeat_type = self.app_config.state.repeat_type
                is_repeat_queue = RepeatType.REPEAT_QUEUE == repeat_type
                prefetch_idxs = []
                for i in range(self.app_config.prefetch_amount):
                    prefetch_idx: int = song_idx + 1 + i
                    play_queue_len: int = len(self.app_config.state.play_queue)
                    if is_repeat_queue or prefetch_idx < play_queue_len:
                        prefetch_idxs.append(
                            prefetch_idx % play_queue_len
                        )  # noqa: S001
                CacheManager.batch_download_songs(
                    [self.app_config.state.play_queue[i] for i in prefetch_idxs],
                    before_download=lambda: GLib.idle_add(self.update_window),
                    on_song_download_complete=lambda _: GLib.idle_add(
                        self.update_window
                    ),
                )

        if old_play_queue:
            self.app_config.state.old_play_queue = old_play_queue

        if play_queue:
            self.app_config.state.play_queue = play_queue

        self.app_config.state.current_song_index = song_index

        if play_queue:
            self.save_play_queue()

        song_details_future = CacheManager.get_song_details(
            self.app_config.state.play_queue[self.app_config.state.current_song_index]
        )
        song_details_future.add_done_callback(
            lambda f: GLib.idle_add(do_play_song, f.result()),
        )

    def save_play_queue(self):
        if len(self.app_config.state.play_queue) == 0:
            return

        position = self.app_config.state.song_progress
        self.last_play_queue_update = position or 0

        if self.app_config.server.sync_enabled and self.app_config.state.current_song:
            CacheManager.save_play_queue(
                play_queue=self.app_config.state.play_queue,
                current=self.app_config.state.current_song.id,
                position=math.floor(position * 1000) if position else None,
            )
