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]