From cfecbf421400ac2e1f6c6af8552d957c57332f22 Mon Sep 17 00:00:00 2001 From: Jakob Meier Date: Mon, 11 Mar 2024 21:01:36 +0100 Subject: [PATCH] basic playlist playback mode --- melon/browse/playlist.py | 14 ++- melon/models/__init__.py | 10 ++ melon/player/__init__.py | 82 +++++++++++---- melon/player/playlist.py | 157 ++++++++++++++++++++++++++++ melon/playlist/__init__.py | 10 +- melon/servers/invidious/__init__.py | 13 --- melon/window.py | 11 ++ 7 files changed, 258 insertions(+), 39 deletions(-) create mode 100644 melon/player/playlist.py diff --git a/melon/browse/playlist.py b/melon/browse/playlist.py index ee240c4..758a4c9 100644 --- a/melon/browse/playlist.py +++ b/melon/browse/playlist.py @@ -31,6 +31,17 @@ class BrowsePlaylistScreen(Adw.NavigationPage): self.thread.start() def display_info(self, texture): + box = Gtk.Box() + self.external_btn = IconButton("","modem-symbolic") + self.external_btn.connect("clicked", self.on_open_in_browser) + box.append(self.external_btn) + self.startplay_btn = IconButton("","media-playback-start-symbolic", tooltip=_("Start playing")) + self.startplay_btn.set_action_name("win.playlistplayer-external") + self.startplay_btn.set_action_target_value( + GLib.Variant("as", [self.playlist.server, self.playlist.id])) + box.append(self.startplay_btn) + self.header_bar.pack_end(box) + # base layout self.box = Adw.PreferencesPage() self.about = Adw.PreferencesGroup() @@ -102,9 +113,6 @@ class BrowsePlaylistScreen(Adw.NavigationPage): self.set_title(_("Playlist")) self.header_bar = Adw.HeaderBar() - self.external_btn = IconButton("","modem-symbolic") - self.external_btn.connect("clicked", self.on_open_in_browser) - self.header_bar.pack_end(self.external_btn) self.toolbar_view = Adw.ToolbarView() self.toolbar_view.add_top_bar(self.header_bar) diff --git a/melon/models/__init__.py b/melon/models/__init__.py index 6df48a5..1cac642 100644 --- a/melon/models/__init__.py +++ b/melon/models/__init__.py @@ -747,6 +747,16 @@ def set_video_playback_position(vid: Video, position: float, duration: float): conn.close() notify("playback_changed") +def set_video_playback_position_force(server_id: str, video_id:str, position: float, duration: float): + uts = datetime.now().timestamp() + conn = connect_to_db() + execute_sql(conn, """ + INSERT OR REPLACE INTO playback + VALUES (?,?,?,?,?) + """, (uts, server_id, video_id, duration, position)) + conn.close() + notify("playback_changed") + def get_last_playback() -> (tuple[Video, float, float] | None): conn = connect_to_db() results = conn.execute(""" diff --git a/melon/player/__init__.py b/melon/player/__init__.py index 41c130a..e402da9 100644 --- a/melon/player/__init__.py +++ b/melon/player/__init__.py @@ -13,7 +13,7 @@ from melon.widgets.feeditem import AdaptiveFeedItem from melon.widgets.preferencerow import PreferenceRow, PreferenceType, Preference from melon.widgets.iconbutton import IconButton from melon.models import get_app_settings, add_to_history, register_callback -from melon.models import get_video_playback_position, set_video_playback_position +from melon.models import get_video_playback_position, set_video_playback_position, set_video_playback_position_force from melon.utils import pass_me class PlayerScreen(Adw.NavigationPage): @@ -84,11 +84,22 @@ class PlayerScreen(Adw.NavigationPage): return False def display_error(self): - text = Gtk.Label() - text.set_label(_("Video could not be loaded")) - cb = Gtk.CenterBox() - cb.set_center_widget(text) - self.scrollview.set_child(cb) + status = Adw.StatusPage() + status.set_title(_("*crickets chirping*")) + subs = get_subscribed_channels() + status.set_description(_("Video could not be loaded")) + status.set_icon_name("weather-few-clouds-night-symbolic") + icon_button = IconButton(_("Exit Playlist mode"), "window-close-symbolic") + # TODO fix action + icon_button.set_action_name("win.browse") + icon_button.set_margin_bottom(6) + box = Gtk.CenterBox() + ls = Gtk.Box(orientation = Gtk.Orientation.VERTICAL) + ls.append(icon_button) + ls.append(refresh_btn) + box.set_center_widget(ls) + status.set_child(box) + self.scrollview.set_child(status) return False def background(self, video_id): @@ -112,32 +123,57 @@ class PlayerScreen(Adw.NavigationPage): def __init__(self, server_id, video_id, *args, **kwargs): super().__init__(*args, **kwargs) + if server_id is None or video_id is None: + return + + # show fallback title + self.set_title(_("Loading...")) + + self.header_bar = Adw.HeaderBar() + self.toolbar_view = Adw.ToolbarView() + self.toolbar_view.add_top_bar(self.header_bar) + self.set_child(self.toolbar_view) + + self.wrapper = Adw.Clamp() + self.toolbar_view.set_content(self.wrapper) + + self.scrollview = Gtk.ScrolledWindow() + self.wrapper.set_child(self.scrollview) + + self.init_video(server_id, video_id) + def init_video(self, server_id, video_id): # reset these for now self.position = None self.duration = None + # show fallback title + # in case init_video is called twice, + # this resets the title + self.set_title(_("Loading...")) + + # reset playback position if video was nearly completely watched + pos = get_video_playback_position(server_id, video_id) + dur = get_video_playback_position(server_id, video_id) + if not pos is None and not dur is None: + # TODO consider moving this to the settings panel + consider_done = 0.99*dur + # video was probably watched till end + # reset playback position + if pos >= consider_done: + # we can use force here, because if pos and dur aren't None + # set_video_playback_position was called before so the video exists + set_video_playback_position_force(server_id, video_id, 0, dur) + self.video_id = video_id # get instance handle server = get_servers_list()[server_id] self.instance = get_server_instance(server) - # show fallback title - self.set_title(_("Player")) - - self.header_bar = Adw.HeaderBar() self.external_btn = IconButton("","modem-symbolic") self.external_btn.connect("clicked", self.on_open_in_browser) self.header_bar.pack_end(self.external_btn) - self.toolbar_view = Adw.ToolbarView() - self.toolbar_view.add_top_bar(self.header_bar) - self.set_child(self.toolbar_view) - - self.wrapper = Adw.Clamp() - self.toolbar_view.set_content(self.wrapper) - - self.scrollview = Gtk.ScrolledWindow() # show spinner # will be cleared by display_info spinner = Gtk.Spinner() @@ -147,8 +183,6 @@ class PlayerScreen(Adw.NavigationPage): cb.set_center_widget(spinner) self.scrollview.set_child(cb) - self.wrapper.set_child(self.scrollview) - self.box = Gtk.Box(orientation = Gtk.Orientation.VERTICAL) # start background thread @@ -204,11 +238,15 @@ class PlayerScreen(Adw.NavigationPage): # runs loop every two seconds # should probably happen every second, # but I fear that this would consume to many resources - #self.loop() + self.loop() self.instance.connect_video_ended(self.view, self.on_end) def on_end(self, state): - print("BYE") + # we've reached the end of the video + # might as well manually save the position + if not self.duration is None: + self.position = self.duration + self.commit_playback() def select_stream(self, quality, store_position=False): # get current playback position & save to db diff --git a/melon/player/playlist.py b/melon/player/playlist.py new file mode 100644 index 0000000..6c9ae78 --- /dev/null +++ b/melon/player/playlist.py @@ -0,0 +1,157 @@ +import gi +gi.require_version("WebKit", "6.0") +gi.require_version('Gtk', '4.0') +gi.require_version('Adw', '1') +from gi.repository import Gtk, Adw, WebKit, GLib +from unidecode import unidecode +from gettext import gettext as _ +import threading + +from melon.servers.utils import get_server_instance, get_servers_list +from melon.servers import SearchMode +from melon.widgets.feeditem import AdaptiveFeedItem +from melon.widgets.preferencerow import PreferenceRow, PreferenceType, Preference +from melon.widgets.iconbutton import IconButton +from melon.models import get_app_settings, add_to_history, register_callback +from melon.models import get_video_playback_position, set_video_playback_position, set_video_playback_position_force +from melon.utils import pass_me + +from melon.models import get_local_playlist, PlaylistWrapper, PlaylistWrapperType + +from melon.player import PlayerScreen + +class PlaylistPlayerScreen(PlayerScreen): + playlist = None + playlist_index = 0 + playlist_instance = None + playlist_content = [] + + def __init__(self, ref: (tuple[str, str] | int), index=0, *args, **kwargs): + # make sure to initalize widget + # PlayerScreen.__init__ will break after initializing parents + # because neither server or video are supplied + super().__init__(None, None) + + self.playlist_index = index + self.playlist = None + self.video_duration = None + self.video_position = None + self.playlist_instance = None + + # prepare base layout + self.header_bar = Adw.HeaderBar() + self.set_title(_("Loading...")) + # show spinner + self.toolbar_view = Adw.ToolbarView() + self.toolbar_view.add_top_bar(self.header_bar) + self.set_child(self.toolbar_view) + + self.wrapper = Adw.Clamp() + self.toolbar_view.set_content(self.wrapper) + + self.scrollview = Gtk.ScrolledWindow() + # show spinner + # will be overwritten by init_load_video + spinner = Gtk.Spinner() + spinner.set_size_request(50, 50) + spinner.start() + cb = Gtk.CenterBox() + cb.set_center_widget(spinner) + self.scrollview.set_child(cb) + self.wrapper.set_child(self.scrollview) + + # start prepare_playlist thread + self.visible = True + self.thread = threading.Thread(target=self.prepare_playlist_task, args=[ref]) + self.thread.daemon = True + self.thread.start() + + # fetch playlist data & store it interally + def prepare_playlist_task(self, ref: (tuple[str, str] | int)): + if isinstance(ref, int): + playlist_id = ref + # local playlist + pl = get_local_playlist(playlist_id) + self.playlist = PlaylistWrapper.from_local(pl) + self.playlist_content = pl.content + elif isinstance(ref, tuple): + server_id = ref[0] + playlist_id = ref[1] + # external playlist + server = get_servers_list()[server_id] + self.playlist_instance = get_server_instance(server) + pl = self.playlist_instance.get_playlist_info(playlist_id) + self.playlist = PlaylistWrapper.from_external(pl) + cont = self.playlist_instance.get_playlist_content(playlist_id) + self.playlist_content = cont + else: + # shouldn't be reachable + # if properly used + # (see argument type bounds) + GLib.idle_add(self.playlist_error) + return + + # show empty playlist screen if there are no videos + if not self.playlist_content: + GLib.idle_add(self.empty_playlist) + return + + # show playlist error screen if something didn't quite work out + if self.playlist_index > len(self.playlist_content): + GLib.idle_add(self.playlist_error) + return + self.prepare(0) + + def prepare(self, index): + dt = self.playlist_content[index] + server_id = dt.server + video_id = dt.id + + GLib.idle_add(self.init_video, server_id, video_id) + + def on_end(self, state): + super().on_end(state) + # if there are more videos in the playlist + # load the next one + # do nothing if there are no more videos + if self.playlist_index+1 < len(self.playlist_content): + self.playlist_index += 1 + self.prepare(self.playlist_index) + + def empty_playlist(self): + # display playlist is empty banner + # acts as a drop in replacement for the player screen + status = Adw.StatusPage() + status.set_title(_("*crickets chirping*")) + subs = get_subscribed_channels() + status.set_description(_("This playlist is empty")) + status.set_icon_name("weather-few-clouds-night-symbolic") + icon_button = IconButton(_("Exit Playlist mode"), "window-close-symbolic") + # TODO fix action + icon_button.set_action_name("win.browse") + icon_button.set_margin_bottom(6) + box = Gtk.CenterBox() + ls = Gtk.Box(orientation = Gtk.Orientation.VERTICAL) + ls.append(icon_button) + ls.append(refresh_btn) + box.set_center_widget(ls) + status.set_child(box) + self.scrollview.set_child(status) + + def playlist_error(self): + status = Adw.StatusPage() + status.set_title(_("*crickets chirping*")) + subs = get_subscribed_channels() + status.set_description(_("There was an error loading the playlist")) + status.set_icon_name("weather-few-clouds-night-symbolic") + icon_button = IconButton(_("Close"), "window-close-symbolic") + # TODO fix action + icon_button.set_action_name("win.browse") + icon_button.set_margin_bottom(6) + box = Gtk.CenterBox() + ls = Gtk.Box(orientation = Gtk.Orientation.VERTICAL) + ls.append(icon_button) + ls.append(refresh_btn) + box.set_center_widget(ls) + status.set_child(box) + self.scrollview.set_child(status) diff --git a/melon/playlist/__init__.py b/melon/playlist/__init__.py index 1c43eae..c29ad09 100644 --- a/melon/playlist/__init__.py +++ b/melon/playlist/__init__.py @@ -2,7 +2,7 @@ import sys import gi gi.require_version('Gtk', '4.0') gi.require_version('Adw', '1') -from gi.repository import Gtk, Adw +from gi.repository import Gtk, Adw, GLib from unidecode import unidecode from gettext import gettext as _ @@ -39,6 +39,14 @@ class LocalPlaylistScreen(Adw.NavigationPage): edit_button = IconButton(_("Edit"), "document-edit-symbolic") edit_button.connect("clicked", lambda _: self.open_edit()) + box = Gtk.Box() + self.startplay_btn = IconButton("","media-playback-start-symbolic", tooltip=_("Start playing")) + self.startplay_btn.set_action_name("win.playlistplayer-local") + self.startplay_btn.set_action_target_value( + GLib.Variant("u", self.playlist.id)) + box.append(self.startplay_btn) + self.header_bar.pack_end(box) + if len(playlist.content) == 0: status = Adw.StatusPage() status.set_title(_("*crickets chirping*")) diff --git a/melon/servers/invidious/__init__.py b/melon/servers/invidious/__init__.py index da10c3a..640f1cc 100644 --- a/melon/servers/invidious/__init__.py +++ b/melon/servers/invidious/__init__.py @@ -334,19 +334,6 @@ class Invidious(Server): }); }); } - if (player.readyState < min_ready_state) { - await new Promise((res) => { - if (player.readyState >= min_ready_state) { res(); return undefined; } - let list = player.addEventListener( - 'loadeddata', - ()=>{ - if (player.readyState >= min_ready_state) { - player.removeEventListener('loadeddata', list); - res(); - } - }); - }); - } """ def get_video_playback_position(self, webview, on_data): diff --git a/melon/window.py b/melon/window.py index a421b74..2c52d33 100644 --- a/melon/window.py +++ b/melon/window.py @@ -15,6 +15,7 @@ from melon.servers.utils import get_servers_list, get_server_instance from melon.settings import SettingsScreen from melon.importer import ImporterScreen from melon.player import PlayerScreen +from melon.player.playlist import PlaylistPlayerScreen from melon.widgets.simpledialog import SimpleDialog from melon.playlist import LocalPlaylistScreen from melon.playlist.pick import PlaylistPickerDialog @@ -66,6 +67,13 @@ class MainWindow(Adw.ApplicationWindow): def open_local_playlist_viewer(self, action, prefs): self.view.push(LocalPlaylistScreen(prefs.unpack())) + def open_playlistplayer_local(self, action, prefs): + self.view.push(PlaylistPlayerScreen(prefs.unpack())) + def open_playlistplayer_external(self, action, prefs): + server_id = prefs[0] + playlist_id = prefs[1] + self.view.push(PlaylistPlayerScreen((server_id, playlist_id))) + # Opens the about app screen def open_about(self,action,prefs): dialog = Adw.AboutWindow() @@ -128,6 +136,9 @@ class MainWindow(Adw.ApplicationWindow): self.reg_action("add_to_playlist", self.open_playlist_picker, "as") # same as new_playlist but with [server_id, video_id] to identify a video self.reg_action("add_to_new_playlist", self.open_playlist_creator, "as") + # playlist player + self.reg_action("playlistplayer-local", self.open_playlistplayer_local, "u") + self.reg_action("playlistplayer-external", self.open_playlistplayer_external, "as") def reg_action(self, name, func, variant=None, target=None): vtype = None -- 2.38.5