~comcloudway/melon

b48cb55898a375b1c691b2e809e0b8cc0d7209ad — Jakob Meier 6 months ago efda51b
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)
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 +39 -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,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()

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 +21 -2
@@ 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()

M melon/servers/__init__.py => melon/servers/__init__.py +7 -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,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 []

M melon/servers/invidious/__init__.py => melon/servers/invidious/__init__.py +5 -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"

M melon/widgets/player.py => melon/widgets/player.py +28 -0
@@ 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)


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]