~comcloudway/melon

348bce0421395e6c013a1748369c92daf0a0083d — Jakob Meier 6 months ago ff155a2
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)
M melon/application.py => melon/application.py +2 -1
@@ 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)

M melon/home/__init__.py => melon/home/__init__.py +6 -5
@@ 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)

M melon/home/new.py => melon/home/new.py +37 -1
@@ 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()

M melon/models/__init__.py => melon/models/__init__.py +80 -4
@@ 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])

M melon/models/callbacks.py => melon/models/callbacks.py +3 -1
@@ 10,7 10,9 @@ callbacks = {
    "channels_changed": {},
    "playlists_changed": {},
    "history_changed": {},
    "settings_changed": {}
    "settings_changed": {},
    "playback_changed": {},
    "quit": {}
}
frozen = False
def freeze():

M melon/player/__init__.py => melon/player/__init__.py +97 -4
@@ 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)

M melon/servers/__init__.py => melon/servers/__init__.py +45 -1
@@ 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)

M melon/servers/invidious/__init__.py => melon/servers/invidious/__init__.py +138 -0
@@ 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)

M melon/utils.py => melon/utils.py +11 -0
@@ 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]

M melon/widgets/preferencerow.py => melon/widgets/preferencerow.py +1 -5
@@ 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]