From b48cb55898a375b1c691b2e809e0b8cc0d7209ad Mon Sep 17 00:00:00 2001 From: Jakob Meier Date: Sat, 9 Mar 2024 15:27:14 +0100 Subject: [PATCH] remember playback position & add pick up where you left off toast to news screen allows the user to continue watching the last video they watched (if they have watched less or equal than 95 percent of it) --- melon/application.py | 3 +- melon/home/__init__.py | 11 ++-- melon/home/new.py | 40 +++++++++++++- melon/models/__init__.py | 84 +++++++++++++++++++++++++++-- melon/models/callbacks.py | 4 +- melon/player/__init__.py | 23 +++++++- melon/servers/__init__.py | 8 ++- melon/servers/invidious/__init__.py | 5 ++ melon/widgets/player.py | 28 ++++++++++ melon/widgets/preferencerow.py | 6 +-- 10 files changed, 192 insertions(+), 20 deletions(-) diff --git a/melon/application.py b/melon/application.py index 5aace2f..1976704 100644 --- a/melon/application.py +++ b/melon/application.py @@ -4,7 +4,7 @@ gi.require_version('Adw', '1') from gi.repository import Gtk, Adw, Gio, GLib from melon.window import MainWindow -from melon.models import init_db +from melon.models import init_db, notify from melon.servers.utils import get_server_instance, load_server, get_servers_list class Application(Adw.Application): @@ -19,6 +19,7 @@ class Application(Adw.Application): instance = get_server_instance(server_data) load_server(instance) self.connect('activate', self.on_activate) + self.connect("shutdown", lambda _: notify("quit")) def on_activate(self, app): self.win = MainWindow(application=app) diff --git a/melon/home/__init__.py b/melon/home/__init__.py index e96e011..cf35dcc 100644 --- a/melon/home/__init__.py +++ b/melon/home/__init__.py @@ -3,6 +3,7 @@ import gi gi.require_version('Gtk', '4.0') gi.require_version('Adw', '1') from gi.repository import Gtk, Adw, Gio +from gettext import gettext as _ from melon.home.new import NewFeed from melon.home.subs import Subscriptions @@ -14,7 +15,7 @@ from melon.widgets.iconbutton import IconButton class HomeScreen(Adw.NavigationPage): def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) - self.set_title("Home") + self.set_title(_("Home")) # main stack view self.view_stack = Adw.ViewStack() @@ -35,16 +36,16 @@ class HomeScreen(Adw.NavigationPage): self.header_bar.set_title_widget(self.switcher) # add browse servers button self.add_server_button = IconButton("", "list-add-symbolic") - self.add_server_button.set_tooltip_text("Browse Servers") + self.add_server_button.set_tooltip_text(_("Browse Servers")) self.add_server_button.set_action_name("win.browse") self.header_bar.pack_start(self.add_server_button) # add menu to top bar self.menu_button = Gtk.MenuButton() self.menu_button.set_icon_name("open-menu-symbolic") model = Gio.Menu() - model.append("Preferences", "win.prefs") - model.append("Import Data", "win.import") - model.append("About Melon", "win.about") + model.append(_("Preferences"), "win.prefs") + model.append(_("Import Data"), "win.import") + model.append(_("About Melon"), "win.about") self.menu_popover = Gtk.PopoverMenu() self.menu_popover.set_menu_model(model) self.menu_button.set_popover(self.menu_popover) diff --git a/melon/home/new.py b/melon/home/new.py index de44591..c12d030 100644 --- a/melon/home/new.py +++ b/melon/home/new.py @@ -14,6 +14,7 @@ from melon.servers.utils import fetch_home_feed, group_by_date from melon.models import register_callback from melon.models import get_subscribed_channels, get_app_settings from melon.models import get_cached_feed, clear_cached_feed, update_cached_feed, get_last_feed_refresh +from melon.models import get_last_playback class NewFeed(ViewStackPage): def update(self): @@ -109,12 +110,49 @@ class NewFeed(ViewStackPage): self.thread.daemon = True self.thread.start() + ov_toast = None + def load_overlay(self): + last = get_last_playback() + if not last is None: + pos = last[1] + dur = last[2] + if pos is None or dur is None: + last = None + elif dur == 0: + # video has no length, why would anybody continue watching it + last = None + elif pos > dur*0.95: + # video was already played >0.95 percent + # so we can consider it watched + # TODO: move into settings menu + last = None + if last is None: + if not self.ov_toast is None: + self.ov_toast.dismiss() + return + if not self.ov_toast is None: + self.ov_toast.dismiss() + self.ov_toast = None + resource = last[0] + self.ov_toast = Adw.Toast.new(_("Pick up where you left off")) + self.ov_toast.set_button_label(_("Watch")) + self.ov_toast.set_action_name("win.player") + self.ov_toast.set_action_target_value( + GLib.Variant("as", [resource.server, resource.id])) + self.overlay.add_toast(self.ov_toast) + self.ov_toast.set_timeout(20) + def __init__(self): super().__init__("feed-new", _("What's new"), "user-home-symbolic") self.widget = Adw.Clamp() + self.overlay = Adw.ToastOverlay() self.inner = Gtk.ScrolledWindow() - self.widget.set_child(self.inner) + self.overlay.set_child(self.inner) + self.widget.set_child(self.overlay) # register update listener register_callback("channels_changed", "home-new", self.do_update) + register_callback("playback_changed", "home-new", self.load_overlay) + # load overlay + self.load_overlay() # manually run render self.do_update() diff --git a/melon/models/__init__.py b/melon/models/__init__.py index bb4fca1..6df48a5 100644 --- a/melon/models/__init__.py +++ b/melon/models/__init__.py @@ -5,6 +5,7 @@ from functools import cache import sqlite3 import os import json +import sys from melon.servers import Video, Channel, Playlist, Resource from melon.utils import get_data_dir @@ -74,7 +75,7 @@ def execute_sql(conn:sqlite3.Connection, sql, *sqlargs, many=False) -> bool: else: return True -VERSION = 1 +VERSION = 2 app_conf_template = { # NOTE: they are set to False to save bandwidth @@ -111,6 +112,8 @@ def init_db(): # "Houston, we have a problem" return version = conn.execute("PRAGMA user_version").fetchone()[0] + if version > VERSION: + sys.exit(1) if version == 0: # initial version (defaults to 0, so this means the DB isn't initialized) # perform initial setup @@ -243,12 +246,36 @@ def init_db(): FOREIGN KEY(video_id, video_server) REFERENCES videos(id, server) ) """) + execute_sql(conn, """ + CREATE TABLE playback( + timestamp, + video_server, + video_id, + duration, + position, + PRIMARY KEY(video_id, video_server), + FOREIGN KEY(video_id, video_server) REFERENCES videos(id, server) + ) + """) # the initial setup should always initialize the newest version # and not depend on migrations version = VERSION # MIGRATIONS go here - # if minv < = version <= maxv: + # if minv <= version <= maxv: + if version < 2: + # newly added in db v2 + execute_sql(conn, """ + CREATE TABLE playback( + timestamp, + video_server, + video_id, + duration, + position, + PRIMARY KEY(video_id, video_server), + FOREIGN KEY(video_id, video_server) REFERENCES videos(id, server) + ) + """) # MIGRATIONS FINISHED execute_sql(conn, f"PRAGMA user_version = {VERSION}") or die() @@ -284,6 +311,7 @@ def load_server(server): if server.requires_login: ensure_server_disabled(sid) servers[sid]["enabled"] = False + conn.close() notify("settings_changed") def set_server_setting(sid:str, pref:str, value): init_server_settings(sid) @@ -291,13 +319,14 @@ def set_server_setting(sid:str, pref:str, value): execute_sql(conn, "INSERT OR REPLACE INTO server_settings VALUES (?,?,?)", (sid, pref, value)) + conn.close() notify("settings_changed") def get_server_settings(sid:str): init_server_settings(sid) base = servers[sid] - conn = connect_to_db() value = is_server_enabled(sid) base["enabled"] = value + conn = connect_to_db() results = conn.execute( "SELECT key, value FROM server_settings WHERE server = ?", (sid,)).fetchall() for setting in results: @@ -329,6 +358,7 @@ def ensure_server(server_id: str, mode: bool): VALUES (?, ?) """, (server_id, mode)) # notify channels and playlists, because available channels might be different + conn.close() notify("channels_changed") notify("playlists_changed") notify("settings_changed") @@ -353,6 +383,7 @@ def set_app_setting(pref:str, value): WHERE key = ? """, (value, pref)) # notify channels and playlists, because available channels might be different + conn.close() notify("channels_changed") notify("playlists_changed") notify("settings_changed") @@ -430,6 +461,7 @@ def ensure_video(vid): VALUES (?,?,?,?,?,?,?,?) """, (vid.server, vid.id, vid.url, vid.title, vid.description, vid.thumbnail, vid.channel[1], vid.channel[0])) + conn.close() def add_videos(vids:list[Video]): conn = connect_to_db() execute_sql( @@ -440,6 +472,7 @@ def add_videos(vids:list[Video]): """, [ (vid.server, vid.id, vid.url, vid.title, vid.description, vid.thumbnail, vid.channel[1], vid.channel[0]) for vid in vids], many=True) + conn.close() notify("history_changed") def has_bookmarked_external_playlist(server_id: str, playlist_id:str) -> bool: @@ -484,6 +517,7 @@ def ensure_playlist(playlist: PlaylistWrapper): VALUES (?,?,?,?,?,?,?,?) """, (playlist.inner.server, playlist.inner.id, playlist.inner.url, playlist.inner.title, playlist.inner.description,playlist.inner.channel[1], playlist.inner.channel[0], playlist.inner.thumbnail)) + conn.close() notify("playlists_changed") def ensure_bookmark_external_playlist(playlist: Playlist): wrapped = PlaylistWrapper.from_external(playlist) @@ -496,6 +530,7 @@ def ensure_bookmark_external_playlist(playlist: Playlist): """, (playlist.server, playlist.id)) # we need to rerun the notification because now the playlist was linked + conn.close() notify("playlists_changed") def ensure_unbookmark_external_playlist(server_id: str, playlist_id: str): # only removes the playlist from the linking table @@ -505,6 +540,7 @@ def ensure_unbookmark_external_playlist(server_id: str, playlist_id: str): WHERE server = ?, playlist_id = ? """, (server_id, playlist_id)) + conn.close() notify("playlists_changed") def new_local_playlist(name: str, description: str, videos=[]) -> int: """Create a new local playlist and return id""" @@ -519,6 +555,7 @@ def new_local_playlist(name: str, description: str, videos=[]) -> int: for vid in videos: add_to_local_playlist(id, vid, index) index += 1 + conn.close() notify("playlists_changed") return id def ensure_delete_local_playlist(playlist_id:int): @@ -568,6 +605,7 @@ def add_to_local_playlist(playlist_id:int, vid, pos=None): INSERT INTO local_playlist_content VALUES (?, ?, ?, ?) """, (playlist_id, vid.id, vid.server, pos)) + conn.close() notify("playlists_changed") def get_local_playlist(playlist_id:int) -> LocalPlaylist: conn = connect_to_db() @@ -593,6 +631,7 @@ def set_local_playlist_thumbnail(playlist_id: int, thumb: str): SET thumbnail = ? WHERE id = ? """, (thumb, playlist_id)) + conn.close() notify("playlists_changed") def add_to_history(vid, uts=None): @@ -609,7 +648,9 @@ def add_to_history(vid, uts=None): VALUES (?, ?, ?) """, (uts, vid.id, vid.server)) + conn.close() notify("history_changed") + return False def add_history_items(items:list[(Video, int)]): # NOTE: you have to ensure the videos exist yourself conn = connect_to_db() @@ -621,6 +662,7 @@ def add_history_items(items:list[(Video, int)]): """, [(d[1], d[0].id, d[0].server) for d in items], many=True) + conn.close() notify("history_changed") def get_history(): @@ -678,8 +720,42 @@ def update_cached_feed(ls: list[(Video, int)]): """, (uts, vid.id, vid.server)) def get_last_feed_refresh() -> int: - conn = connect_to_db() app_conf = get_app_settings() if "news-feed-refresh" in app_conf: return app_conf["news-feed-refresh"] return None + +def get_video_playback_position(server_id:str, video_id:str) -> (float|None): + conn = connect_to_db() + results = conn.execute(""" + SELECT position + FROM playback + WHERE video_id = ? AND video_server = ? + """, (video_id, server_id)).fetchone() + if results is None: + return None + return results[0] + +def set_video_playback_position(vid: Video, position: float, duration: float): + uts = datetime.now().timestamp() + ensure_video(vid) + conn = connect_to_db() + execute_sql(conn, """ + INSERT OR REPLACE INTO playback + VALUES (?,?,?,?,?) + """, (uts, vid.server, vid.id, duration, position)) + conn.close() + notify("playback_changed") + +def get_last_playback() -> (tuple[Video, float, float] | None): + conn = connect_to_db() + results = conn.execute(""" + SELECT server, id, url, title, description, thumbnail, channel_id, channel_name, position, duration + FROM playback join videos ON playback.video_id = videos.id AND playback.video_server = videos.server + ORDER BY playback.timestamp DESC + LIMIT 1 + """).fetchone() + if results is None: + return None + d = results + return (Video(d[0], d[2], d[1], d[3], (d[7], d[6]), d[4], d[5]), d[8], d[9]) diff --git a/melon/models/callbacks.py b/melon/models/callbacks.py index de647bd..ec2e045 100644 --- a/melon/models/callbacks.py +++ b/melon/models/callbacks.py @@ -10,7 +10,9 @@ callbacks = { "channels_changed": {}, "playlists_changed": {}, "history_changed": {}, - "settings_changed": {} + "settings_changed": {}, + "playback_changed": {}, + "quit": {} } frozen = False def freeze(): diff --git a/melon/player/__init__.py b/melon/player/__init__.py index 2b71686..9901c1e 100644 --- a/melon/player/__init__.py +++ b/melon/player/__init__.py @@ -14,7 +14,9 @@ from melon.widgets.player import VideoPlayer 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, notify, add_to_history +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.utils import pass_me class PlayerScreen(Adw.NavigationPage): def on_open_in_browser(self, arg): @@ -53,12 +55,26 @@ class PlayerScreen(Adw.NavigationPage): def display_player(self): player = VideoPlayer(self.streams) + pos = get_video_playback_position(self.video.server, self.video.id) + if not pos is None: + player.goto(pos) + player.connect_update(self.on_player_data) + # do not autoplay + player.pause() self.box.append(player) + # TODO: stop + kill player on hide + + def on_player_data(self, position, duration): + if position is None or duration is None: + # we only want to save valid datasets + return + set_video_playback_position(self.video, position, duration) channel = None def display_channel(self): if not self.channel is None: self.about.add(AdaptiveFeedItem(self.channel)) + return False def display_error(self): text = Gtk.Label() @@ -66,6 +82,7 @@ class PlayerScreen(Adw.NavigationPage): cb = Gtk.CenterBox() cb.set_center_widget(text) self.scrollview.set_child(cb) + return False def background(self, video_id): # obtain video_information @@ -83,11 +100,12 @@ class PlayerScreen(Adw.NavigationPage): GLib.idle_add(self.display_info) # get channel details - channel = self.instance.get_channel_info(self.video.channel[1]) + self.channel = self.instance.get_channel_info(self.video.channel[1]) GLib.idle_add(self.display_channel) def __init__(self, server_id, video_id, *args, **kwargs): super().__init__(*args, **kwargs) + self.video_id = video_id # get instance handle server = get_servers_list()[server_id] @@ -130,6 +148,7 @@ class PlayerScreen(Adw.NavigationPage): self.box.set_margin_bottom(padding) # start background thread + self.visible = True self.thread = threading.Thread(target=self.background, args=[video_id]) self.thread.daemon = True self.thread.start() diff --git a/melon/servers/__init__.py b/melon/servers/__init__.py index 9d7515b..c457140 100644 --- a/melon/servers/__init__.py +++ b/melon/servers/__init__.py @@ -3,7 +3,9 @@ from abc import ABC,abstractmethod import gi gi.require_version('Gtk', '4.0') gi.require_version('Adw', '1') -from gi.repository import GObject +gi.require_version('WebKit', '6.0') +from gi.repository import GObject, WebKit +from typing import Callable from melon.servers.loader import server_finder from melon.import_providers import ImportProvider @@ -249,4 +251,8 @@ class Server(ABC): """ return [] def get_import_providers(self) -> list[ImportProvider]: + """ + Returns a list of import providers, + that import data used by this server + """ return [] diff --git a/melon/servers/invidious/__init__.py b/melon/servers/invidious/__init__.py index f530d4f..b1aecac 100644 --- a/melon/servers/invidious/__init__.py +++ b/melon/servers/invidious/__init__.py @@ -3,11 +3,16 @@ import requests from urllib.parse import urlparse,parse_qs from datetime import datetime from gettext import gettext as _ +import gi +gi.require_version("WebKit", "6.0") +from gi.repository import GLib, WebKit + from melon.import_providers.newpipe import NewpipeImporter from melon.servers import Server, Preference, PreferenceType from melon.servers import Feed, Channel, Video, Playlist, Stream, SearchMode from melon.servers import USER_AGENT +from melon.utils import pass_me class NewpipeInvidiousImporter(NewpipeImporter): server_id = "invidious" diff --git a/melon/widgets/player.py b/melon/widgets/player.py index 4cf4df4..c7fe255 100644 --- a/melon/widgets/player.py +++ b/melon/widgets/player.py @@ -23,6 +23,7 @@ class VideoPlayer(Gtk.Overlay): self.position = None self.duration = None self.paused = True + self.update_callback=None overlay = Gtk.Overlay() self.set_child(overlay) @@ -126,6 +127,9 @@ class VideoPlayer(Gtk.Overlay): self._start_loop() + def connect_update(self, callback=None): + self.update_callback = callback + def _show_controls(self): self.controls.set_opacity(1) def _hide_controls(self, force=False): @@ -188,12 +192,27 @@ class VideoPlayer(Gtk.Overlay): if pos[0]: # convert nanoseconds to senconds pos = pos[1]/(10**9) + # override position with target + # so the preview shows the correct timestamp + # has the amazing side effect of also sending + # the requested timestamp on update + if not self.target_position is None: + pos = self.target_position else: pos = None if not pos is None and not dur is None: # use combined method if we have a value for both # so we don't call the update function twice self.set_position_and_duration(pos, dur) + + # a video position was requested but not yet seeked to + if not self.target_position is None: + position = self.target_position + self.target_position = None + self.source.seek_simple( + Gst.Format.TIME, + Gst.SeekFlags.FLUSH, + position*Gst.SECOND) elif not pos is None: self.set_position(pos) elif not dur is None: @@ -213,11 +232,18 @@ class VideoPlayer(Gtk.Overlay): self.duration = dur self._update_playhead() + target_position = None + def goto(self, position): + # the seeking will happen in the _loop function + self.target_position = position + def _update_playhead(self): pos_text = "00:00" dur_text = "--:--" if not self.position is None: pos_text = format_seconds(self.position) + elif not self.target_position is None: + pos_text = format_seconds(self.target_position) if not self.duration is None: dur_text = format_seconds(self.duration) self.position_display.set_label(pos_text) @@ -227,6 +253,8 @@ class VideoPlayer(Gtk.Overlay): if not self.duration is None and not self.position is None: self.progress_display.set_range(0, self.duration) self.progress_display.set_value(self.position) + if not self.update_callback is None: + self.update_callback(self.position, self.duration) else: self.progress_display.set_value(0) diff --git a/melon/widgets/preferencerow.py b/melon/widgets/preferencerow.py index 949ca97..f577c2c 100644 --- a/melon/widgets/preferencerow.py +++ b/melon/widgets/preferencerow.py @@ -11,6 +11,7 @@ from melon.servers import Resource,Video,Playlist,Channel,Preference,PreferenceT from melon.servers.utils import pixbuf_from_url from melon.widgets.iconbutton import IconButton from melon.widgets.simpledialog import SimpleDialog +from melon.utils import pass_me, many class PreferenceRow(): def __init__(self, pref: Preference, *args, **kwargs): @@ -252,8 +253,3 @@ class MultiRow(Adw.PreferencesRow): val = self.values.pop(counter) self.values.insert(counter+1, val) self.notify() - -def pass_me(func, *args): - return lambda *a: func(*a, *args) -def many(*funcs): - return [funcs] -- 2.38.5