From 348bce0421395e6c013a1748369c92daf0a0083d Mon Sep 17 00:00:00 2001 From: Jakob Meier Date: Sat, 9 Mar 2024 15:27:14 +0100 Subject: [PATCH] remeber playback position when changing quality & add pick up where you left off toast to news screen - seamless quality switching on plugins that support it (invidious only) - 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 | 38 +++++++- melon/models/__init__.py | 84 ++++++++++++++++- melon/models/callbacks.py | 4 +- melon/player/__init__.py | 101 +++++++++++++++++++- melon/servers/__init__.py | 46 +++++++++- melon/servers/invidious/__init__.py | 138 ++++++++++++++++++++++++++++ melon/utils.py | 11 +++ melon/widgets/preferencerow.py | 6 +- 10 files changed, 420 insertions(+), 22 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..20dff93 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,47 @@ 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 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 e939f16..41c130a 100644 --- a/melon/player/__init__.py +++ b/melon/player/__init__.py @@ -12,9 +12,13 @@ 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, 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): + duration = None + position = None def on_open_in_browser(self, arg): Gtk.UriLauncher.new(uri=self.video.url).launch() @@ -53,7 +57,9 @@ class PlayerScreen(Adw.NavigationPage): self.view = WebKit.WebView() self.view.set_hexpand(True) self.view.set_vexpand(True) + self.view.connect("load-changed", self.on_load) default_stream = self.streams[0].quality + # load default stream self.select_stream(default_stream) self.box.append(self.view) # stream selector @@ -65,13 +71,17 @@ class PlayerScreen(Adw.NavigationPage): [ unidecode(stream.quality) for stream in self.streams ], default_stream) row = PreferenceRow(pref) - row.set_callback(self.select_stream) + # pass true to select_stream to store the current playback position + # and restore it after changing it + row.set_callback(pass_me(self.select_stream, True)) self.quality_select = row.get_widget() + return False 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() @@ -79,6 +89,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 @@ -96,11 +107,16 @@ 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) + + # reset these for now + self.position = None + self.duration = None + self.video_id = video_id # get instance handle server = get_servers_list()[server_id] @@ -136,12 +152,89 @@ class PlayerScreen(Adw.NavigationPage): self.box = Gtk.Box(orientation = Gtk.Orientation.VERTICAL) # start background thread + self.visible = True self.thread = threading.Thread(target=self.background, args=[video_id]) self.thread.daemon = True self.thread.start() - def select_stream(self, quality): + def on_hide(self): + self.visible = False + # save playback position & duration when closing the video + # NOTE: this does not include closing the app + self.store_position() + def on_quit(self): + self.commit_playback() + def loop(self): + if not self.visible: + return False + # store playback position locally every couple of seconds + self.store_position(None, False) + # add this function to the queue (in 2sec) + # better than running it every 2seconds + # in case it takes longer than 2 seconds to run + # as it doesn't push as many tasks into the queue + GLib.timeout_add_seconds(2, self.loop) + return False + + def on_load(self, view, event): + if event == self.instance.WEBVIEW_READY: + # video meta is ready now + # update playback position from db + pos = get_video_playback_position(self.video.server, self.video.id) + if not pos is None: + self.position = pos + else: + self.position = None + # get video duration & update db + # handled by self.on_duration + self.instance.get_video_duration(self.view, self.on_duration) + + def on_duration(self, duration): + self.duration = duration + pos = self.position + if not pos is None: + self.instance.set_video_playback_position(self.view, pos, lambda s: self.on_player_setup_done()) + else: + self.on_player_setup_done() + def on_player_setup_done(self): + self.commit_playback() + # connect to events + self.connect("hiding", lambda _: self.on_hide()) + register_callback("quit", "player", self.commit_playback) + # runs loop every two seconds + # should probably happen every second, + # but I fear that this would consume to many resources + #self.loop() + self.instance.connect_video_ended(self.view, self.on_end) + + def on_end(self, state): + print("BYE") + + def select_stream(self, quality, store_position=False): + # get current playback position & save to db + # redirects to select_stream2 where the stream will be replaced + if store_position: + self.store_position(pass_me(self.select_stream2, quality)) + else: + self.select_stream2(quality) + def select_stream2(self, quality): + # set stream details for stream in self.streams: if stream.quality == quality: self.view.load_uri(stream.url) break + + def store_position(self, after=None, commit=True): + self.instance.get_video_playback_position(self.view, pass_me(self.on_position, after, commit)) + def on_position(self, position, after=None, commit=True): + self.position = position + if commit: + self.commit_playback() + if not after is None: + after() + + def commit_playback(self): + # only save to db if playhead position and video duration are known + if self.position is None or self.duration is None: + return + set_video_playback_position(self.video, self.position, self.duration) diff --git a/melon/servers/__init__.py b/melon/servers/__init__.py index 9d7515b..b636ec0 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,46 @@ 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 [] + + WEBVIEW_READY = None + + def get_video_duration(self, webview: WebKit.WebView, on_data: Callable[[(None|float)], None]): + """ + Should attempt to get the video duration + using the player in the webview + Should call onData once completed + with the video duration in seconds + or None if it isn't possible to obtain i.e. for a livestream + """ + on_data(None) + + def get_video_playback_position(self, webview: WebKit.WebView, on_data: Callable[[None|float], None]): + """ + Should attempt to get the video playback position + from the webview i.e. by executing javascript to read html elements + Should call onData once complete + with the video playback position in seconds + or None if it couldn't detect the playback position + """ + on_data(None) + def set_video_playback_position(self, webview: WebKit.WebView, timestamp: float, on_done: Callable[[bool], None]): + """ + Takes the video playback position in seconds + Should attempt to set the video playback position in the webview + i.e. by calling a javascript function to set the position + Should call onDone with true after successfully setting the position + pass false if there was a problem + """ + on_done(False) + def connect_video_ended(self, webview: WebKit.WebView, on_done: Callable[[bool], None]): + """ + Should call onDone once the video ended + only has to work the first time + should pass False to onDone if there was an error + """ + on_done(False) diff --git a/melon/servers/invidious/__init__.py b/melon/servers/invidious/__init__.py index f530d4f..da10c3a 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" @@ -310,3 +315,136 @@ class Invidious(Server): return [ NewpipeInvidiousImporter(self.get_external_url()) ] + + # + # WEBVIEW CONTROL BRIDGE + # + WEBVIEW_READY = WebKit.LoadEvent.COMMITTED + + js_player_ready = """ + let min_ready_state = 3; + if (isNaN(player.duration)) { + await new Promise((res)=>{ + if (!isNaN(player.duration)) { res(); return undefined; } + let list = player.addEventListener( + 'loadedmetadata', + ()=>{ + player.removeEventListener('loadedmetadata', list); + res(); + }); + }); + } + 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): + js = f""" + let players = document.getElementsByTagName('video'); + if (players.length != 1) return undefined; + let player = players[0]; + {self.js_player_ready} + if (isNaN(player.duration)) return undefined; + return player.currentTime; + """ + webview.call_async_javascript_function( + js, len(js), + None, + None, + self.id, + None, + pass_me(self.on_js_double, webview, on_data) + ) + def get_video_duration(self, webview, on_data): + js = f""" + let players = document.getElementsByTagName('video'); + if (players.length != 1) return undefined; + let player = players[0]; + {self.js_player_ready} + if (isNaN(player.duration)) return undefined; + return player.duration; + """ + webview.call_async_javascript_function( + js, len(js), + None, + None, + self.id, + None, + pass_me(self.on_js_double, webview, on_data) + ) + def set_video_playback_position(self, webview, timestamp, on_done): + js = f""" + let players = document.getElementsByTagName('video'); + if (players.length != 1) return false; + let player = players[0]; + {self.js_player_ready} + if (timestamp > player.duration || timestamp < 0) return false; + if (!player.seekable) return false; + player.currentTime = timestamp; + return true; + """ + tab = GLib.VariantDict() + tab.insert_value("timestamp", GLib.Variant("d", timestamp)) + args = tab.end() + webview.call_async_javascript_function( + js, len(js), + args, + None, + self.id, + None, + pass_me(self.on_js_bool, webview, on_done) + ) + def connect_video_ended(self, webview, on_done): + js = """ + let players = document.getElementsByTagName('video'); + if (players.length != 1) { return undefined; } + let player = players[0]; + if (!player.ended) { + await new Promise((res)=>{ + let list = player.addEventListener( + 'ended', + ()=>{ + player.removeEventListener('ended', list); + res(true); + } + ); + }); + } + return player.ended; + """ + webview.call_async_javascript_function( + js, len(js), + None, + None, + self.id, + None, + pass_me(self.on_js_bool, webview, on_done) + ) + + def on_js_bool(self, obj, async_res, webview, on_bool): + if on_bool is None: + return + val = webview.call_async_javascript_function_finish(async_res) + if val.is_boolean(): + on_bool(val.to_boolean()) + else: + on_bool(False) + def on_js_double(self, obj, async_res, webview, on_double): + if on_double is None: + return + val = webview.call_async_javascript_function_finish(async_res) + if val.is_number(): + on_double(val.to_double()) + else: + on_double(None) diff --git a/melon/utils.py b/melon/utils.py index 4632d22..70dba15 100644 --- a/melon/utils.py +++ b/melon/utils.py @@ -19,3 +19,14 @@ def get_data_dir(): os.mkdir(data_dir_path) return data_dir_path + +def pass_me(func, *args): + """ + Pass additional arguments to lambda functions + """ + return lambda *a: func(*a, *args) +def many(*funcs): + """ + Make multiple function steps easier to read + """ + return [funcs] 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