From efda51bb46ccfdb1e9918f3d576cad9e4ede566c Mon Sep 17 00:00:00 2001 From: Jakob Meier Date: Thu, 14 Mar 2024 20:06:08 +0100 Subject: [PATCH] basic gstreamer based player still missing a lot of features but it contains the bare minimum to make the upcoming commits possible --- README.md | 9 +- melon/player/__init__.py | 40 ++--- melon/servers/nebula/__init__.py | 9 +- melon/servers/peertube/__init__.py | 19 +- melon/utils.py | 11 ++ melon/widgets/iconbutton.py | 8 +- melon/widgets/player.py | 270 +++++++++++++++++++++++++++++ po/POTFILES.in | 1 + po/de.po | 39 +++-- po/fa.po | 36 ++-- po/melon.pot | 38 ++-- 11 files changed, 389 insertions(+), 91 deletions(-) create mode 100644 melon/widgets/player.py diff --git a/README.md b/README.md index aa78218..cbe4877 100644 --- a/README.md +++ b/README.md @@ -26,14 +26,6 @@ to make data migration easier. \ The following import methods are currently supported: - Import YouTube videos, channels and playlist as `invidious` content from a `newpipe.db` [NewPipe](https://newpipe.net) export -When watching a video, -the server plugin will give *Melon* a stream URL -(or multiple URLs to support video quality control) -which will then be loaded using a *WebView*. \ -Normally this website should be on the service instance itself, -however the following plugins currently rely on external services: -- `nebula`: uses [HLSPlayer.org](https://www.hlsplayer.org/) for video playback - ## 📸 Screenshots ![Screenshot of melon home screen, showing the What's new tab](./screenshots/home.png) @@ -63,6 +55,7 @@ you can also get the APKBUILD from my [personal APKBUILD-repo](https://git.hut.c - `py3-unidecode` - `gtk4.0` - `gettext` +- `gst-plugins-rs` - `libadwaita` - `webkit2gtk-6.0` diff --git a/melon/player/__init__.py b/melon/player/__init__.py index e939f16..2b71686 100644 --- a/melon/player/__init__.py +++ b/melon/player/__init__.py @@ -2,13 +2,15 @@ import gi gi.require_version("WebKit", "6.0") gi.require_version('Gtk', '4.0') gi.require_version('Adw', '1') -from gi.repository import Gtk, Adw, WebKit, GLib +gi.require_version('Gst', '1.0') +from gi.repository import Gtk, Adw, WebKit, GLib, Gst from unidecode import unidecode from gettext import gettext as _ import threading from melon.servers.utils import get_server_instance, get_servers_list from melon.servers import SearchMode +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 @@ -49,24 +51,9 @@ class PlayerScreen(Adw.NavigationPage): GLib.Variant("as", [self.video.server, self.video.id])) self.about.add(btn_bookmark) - def display_webview(self): - self.view = WebKit.WebView() - self.view.set_hexpand(True) - self.view.set_vexpand(True) - default_stream = self.streams[0].quality - self.select_stream(default_stream) - self.box.append(self.view) - # stream selector - pref = Preference( - "quality", - _("Quality"), - _("Video quality"), - PreferenceType.DROPDOWN, - [ unidecode(stream.quality) for stream in self.streams ], - default_stream) - row = PreferenceRow(pref) - row.set_callback(self.select_stream) - self.quality_select = row.get_widget() + def display_player(self): + player = VideoPlayer(self.streams) + self.box.append(player) channel = None def display_channel(self): @@ -92,7 +79,7 @@ class PlayerScreen(Adw.NavigationPage): # Load the media element in a webview self.streams = self.instance.get_video_streams(self.video_id) if self.streams: - GLib.idle_add(self.display_webview) + GLib.idle_add(self.display_player) GLib.idle_add(self.display_info) # get channel details @@ -134,14 +121,15 @@ class PlayerScreen(Adw.NavigationPage): self.wrapper.set_child(self.scrollview) self.box = Gtk.Box(orientation = Gtk.Orientation.VERTICAL) + # add a padding to the box + # so the preference groups do not touch the edge on mobile + padding = 12 + self.box.set_margin_start(padding) + self.box.set_margin_end(padding) + self.box.set_margin_top(padding) + self.box.set_margin_bottom(padding) # start background thread self.thread = threading.Thread(target=self.background, args=[video_id]) self.thread.daemon = True self.thread.start() - - def select_stream(self, quality): - for stream in self.streams: - if stream.quality == quality: - self.view.load_uri(stream.url) - break diff --git a/melon/servers/nebula/__init__.py b/melon/servers/nebula/__init__.py index 1219a88..3c341cf 100644 --- a/melon/servers/nebula/__init__.py +++ b/melon/servers/nebula/__init__.py @@ -337,12 +337,7 @@ class Nebula(Server): return [] manifest_link = r_manifest.json()["manifest"] - # nebula uses m3u8 files for playback, - # however melon uses webviews for media playback - # and currently doesn't support providing custom streams - # so we have to rely on external players - # NOTE: quality can only be managed by the stream service itself - # NOTE: a possible alternative might be to include a local server in the plugin return [ - Stream(self.settings["m3u8-player"].value.replace("%s",manifest_link), "auto") + # gstreamer has m3u8 support, so we no longer need an external service + Stream(manifest_link, "auto") ] diff --git a/melon/servers/peertube/__init__.py b/melon/servers/peertube/__init__.py index c02e82e..67ee3a4 100644 --- a/melon/servers/peertube/__init__.py +++ b/melon/servers/peertube/__init__.py @@ -366,10 +366,21 @@ class Peertube(Server): def get_video_streams(self, vid:str): instance, video_id = vid.split("::") - return [ - # the embed player does the quality selection - Stream(f"{instance}/videos/embed/{video_id}", "auto") - ] + url = f"{instance}/api/v1/videos/{video_id}" + r = requests.get(url) + if r.ok: + data = r.json() + if "streamingPlaylists" in data and data["streamingPlaylists"]: + streams = [] + for playlist in data["streamingPlaylists"]: + # NOTE: peertube also has a list of files/resolutions available + # although I'm not sure if they can be used, + # when I tested them they didn't include duration information + url = playlist["playlistUrl"] + stream = Stream(url, "auto") + streams.append(stream) + return streams + return [] def get_import_providers(self): return [ 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/iconbutton.py b/melon/widgets/iconbutton.py index 77a5db4..6964aed 100644 --- a/melon/widgets/iconbutton.py +++ b/melon/widgets/iconbutton.py @@ -8,10 +8,12 @@ class IconButton(Gtk.Button): def __init__(self, name, icon, tooltip="", *args, **kwargs): super().__init__(*args, **kwargs) self.inner = Adw.ButtonContent() + self.inner.set_can_shrink(True) + self.set_can_shrink(True) + self.set_child(self.inner) + self.update(name, icon, tooltip) + def update(self, name, icon, tooltip=""): self.inner.set_label(name) self.inner.set_icon_name(icon) if tooltip != "": self.set_tooltip_text(tooltip) - self.inner.set_can_shrink(True) - self.set_can_shrink(True) - self.set_child(self.inner) diff --git a/melon/widgets/player.py b/melon/widgets/player.py new file mode 100644 index 0000000..4cf4df4 --- /dev/null +++ b/melon/widgets/player.py @@ -0,0 +1,270 @@ +import gi +gi.require_version("WebKit", "6.0") +gi.require_version('Gtk', '4.0') +gi.require_version('Adw', '1') +gi.require_version('Gst', '1.0') +from gi.repository import Gtk, Adw, WebKit, GLib, Gst +from unidecode import unidecode +from gettext import gettext as _ + +from melon.servers import Stream +from melon.widgets.iconbutton import IconButton +from melon.widgets.preferencerow import PreferenceRow, PreferenceType, Preference +from melon.utils import pass_me + +class VideoPlayer(Gtk.Overlay): + # TODO: consider adding volume + brightness control + # place volume + brightness here so they are static + # and the values are hopefully remembered for the app lifetime + def __init__(self, streams: list[Stream], *args, **kwargs): + super().__init__(*args, **kwargs) + self.streams = streams + self.stream = None + self.position = None + self.duration = None + self.paused = True + + overlay = Gtk.Overlay() + self.set_child(overlay) + + if not streams: + label = Gtk.Label() + label.set_label(_("No streams available")) + self.set_child(label) + return + + Gst.init([]) + gtksink = Gst.ElementFactory.make("gtk4paintablesink", "sink") + # Get the paintable from the sink + paintable = gtksink.props.paintable + + self.source = Gst.ElementFactory.make("playbin3") + + # Use GL if available + if paintable.props.gl_context: + glsink = Gst.ElementFactory.make("glsinkbin", "sink") + glsink.props.sink = gtksink + sink = glsink + else: + sink = gtksink + + self.source.set_property("video-sink", sink) + + self.picture = Gtk.Picture.new() + overlay.set_child(self.picture) + # this makes the player full-width + self.picture.set_can_shrink(True) + self.picture.set_keep_aspect_ratio(True) + # Set the paintable on the picture + self.picture.set_paintable(paintable) + + self.controls = Gtk.Box() + overlay.add_overlay(self.controls) + # used for flat button styles + self.controls.add_css_class("toolbar") + # add padding to box + self.controls.add_css_class("osd") + # distance control box from edges + margin = 4 + self.controls.set_margin_start(margin) + self.controls.set_margin_end(margin) + self.controls.set_margin_top(margin) + self.controls.set_margin_bottom(margin) + # add player control box to bottom center + self.controls.set_halign(Gtk.Align.CENTER) + self.controls.set_valign(Gtk.Align.END) + + # add play/pause indicator + self.playpause_display = IconButton("", "media-playback-start-symbolic", tooltip=_("Play")) + self.playpause_display.connect("clicked", self._toggle_playpause) + self.controls.append(self.playpause_display) + + # add playhead indicator + # [pos] ---o-- [dur] + self.position_display = Gtk.Label() + self.controls.append(self.position_display) + self.progress_display = Gtk.Scale.new(Gtk.Orientation.HORIZONTAL, None) + # highlight section left of playhead + self.progress_display.set_has_origin(True) + # TODO: fix progress bar width + self.controls.append(self.progress_display) + self.duration_display = Gtk.Label() + self.controls.append(self.duration_display) + self._update_playhead() + + # add quality selector + # TODO: consider merging this into a 2d menu + # which also controls playback speed + self.quality_menu = Gtk.MenuButton() + self.quality_menu.set_tooltip_text(_("Video quality")) + self.quality_menu.set_icon_name("network-cellular-signal-good-symbolic") + self.controls.append(self.quality_menu) + self.quality_popover = Gtk.Popover() + # show popover above toolbar + self.quality_menu.set_direction(Gtk.ArrowType.UP) + self.quality_menu.set_popover(self.quality_popover) + # select_stream also call update_quality_list + # unreachable if len(streams) == 0 + # which is why we don't need an additinal check + self.select_stream(self.streams[0]) + # hide controls by default + # (after a timeout of 5seconds) + self._schedule_hide_controls(5) + + # register gesture controllers + # listen to mouse enter/leave + # to show/hide the controls bar + self.hover_ctr = Gtk.EventControllerMotion() + self.add_controller(self.hover_ctr) + self.hover_ctr.connect("enter", self._mouse_enter) + self.hover_ctr.connect("leave", self._mouse_leave) + # listen to click (for mobile support) + # functions as a toggle + self.click_ctr = Gtk.GestureClick() + self.picture.add_controller(self.click_ctr) + self.click_ctr.connect("pressed", self._onclick) + + self._start_loop() + + def _show_controls(self): + self.controls.set_opacity(1) + def _hide_controls(self, force=False): + if self.hover_ctr.get_property("contains-pointer") and not force: + # mouse is still hovering on player + # do not hide - try again later + return True + self.controls.set_opacity(0) + def _schedule_hide_controls(self, timeout=2): + GLib.timeout_add(timeout*1000, self._hide_controls) + + def _mouse_enter(self, w, x, y): + self._show_controls() + def _mouse_leave(self, w): + self._schedule_hide_controls() + def _onclick(self, w, n, x, y): + if n == 1: + # single click toggles focus + # because floats are a thing we can't use == + # however given that we switch between 0 and 1 + # we can use 0.9 or 0.1 as breakpoints + if self.controls.get_opacity() >= 0.9: + self._hide_controls(force=True) + else: + self._show_controls() + + def _update_quality_list(self): + # TODO consider making this scrollable + ls = Gtk.Box.new(Gtk.Orientation.VERTICAL, 0) + # TODO: figure out if we can extract the qualities from m3u8 playlists + # maybe using playbin3? + for stream in self.streams: + selected = stream == self.stream + row = Adw.ActionRow() + # TODO: consider giving streams a name + # and use the name as title + # and the quality as subtitle + row.set_title(stream.quality) + row.set_activatable(True) + row.connect("activated", pass_me(lambda _, s: self.select_stream_wrapper(s), stream)) + # TODO: add suffix that indicates if this resolution is selected + ls.append(row) + self.quality_popover.set_child(ls) + + def _select_stream_wrapper(self, stream): + self.quality_menu.popdown() + self.select_stream(stream) + + def _start_loop(self): + # run once every second + GLib.timeout_add(1000, self._loop) + def _loop(self): + dur = self.source.query_duration(Gst.Format.TIME) + pos = self.source.query_position(Gst.Format.TIME) + if dur[0]: + # convert nanoseconds to senconds + dur = dur[1]/(10**9) + else: + dur = None + if pos[0]: + # convert nanoseconds to senconds + pos = pos[1]/(10**9) + 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) + elif not pos is None: + self.set_position(pos) + elif not dur is None: + self.set_duration(dur) + + # stop the loop if paused? + return True + + def set_position_and_duration(self, pos, dur): + self.position = pos + self.duration = dur + self._update_playhead() + def set_position(self, pos): + self.position = pos + self._update_playhead() + def set_duration(self, dur): + self.duration = dur + self._update_playhead() + + def _update_playhead(self): + pos_text = "00:00" + dur_text = "--:--" + if not self.position is None: + pos_text = format_seconds(self.position) + if not self.duration is None: + dur_text = format_seconds(self.duration) + self.position_display.set_label(pos_text) + self.duration_display.set_label(dur_text) + # TODO fix progressbar + # BUG: the continuous update cancels change events + 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) + else: + self.progress_display.set_value(0) + + def play(self): + self.paused = False + self.source.set_state(Gst.State.PLAYING) + # show pause button + # because if playing the onclick action is to pause + self.playpause_display.update("", "media-playback-pause-symbolic", tooltip=_("Pause")) + def pause(self): + self.paused = True + self.source.set_state(Gst.State.PAUSED) + # show play button + # because if paused the onclick action is to start playing + self.playpause_display.update("", "media-playback-start-symbolic", tooltip=_("Play")) + + def _toggle_playpause(self, _): + if self.paused: + self.play() + else: + self.pause() + + def select_stream(self, stream): + # TODO: confirm that this is acctually working + self.stream = stream + self.source.set_property("uri", stream.url) + self.play() + self._update_quality_list() + +def format_seconds(secs:float) -> str: + secs = int(secs) + seconds = secs % 60 + mins_hours = secs // 60 + minutes = mins_hours % 60 + hours = mins_hours // 60 + h = "" + if hours != 0: + h = f"{hours:02}:" + m = f"{minutes:02}" + s = f"{seconds:02}" + return f"{h}{m}:{s}" diff --git a/po/POTFILES.in b/po/POTFILES.in index 35566fe..fa04de0 100644 --- a/po/POTFILES.in +++ b/po/POTFILES.in @@ -19,5 +19,6 @@ melon/servers/invidious/__init__.py melon/servers/nebula/__init__.py melon/servers/peertube/__init__.py melon/settings/__init__.py +melon/widgets/player.py melon/widgets/preferencerow.py melon/window.py diff --git a/po/de.po b/po/de.po index 114e0b2..2d65d21 100644 --- a/po/de.po +++ b/po/de.po @@ -7,7 +7,7 @@ msgid "" msgstr "" "Project-Id-Version: Melon 0.1.2\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2024-03-09 08:25+0100\n" +"POT-Creation-Date: 2024-03-14 20:08+0100\n" "PO-Revision-Date: 2024-03-01 11:23+0000\n" "Last-Translator: Anonymous \n" "Language-Team: German \n" "Language-Team: Persian \n" "Language-Team: LANGUAGE \n" @@ -125,7 +125,7 @@ msgstr "" msgid "Channel" msgstr "" -#: ../melon/browse/playlist.py:47 ../melon/player/__init__.py:43 +#: ../melon/browse/playlist.py:47 ../melon/player/__init__.py:45 msgid "Bookmark" msgstr "" @@ -297,27 +297,19 @@ msgstr "" msgid "There are no available importer methods" msgstr "" -#: ../melon/player/__init__.py:33 +#: ../melon/player/__init__.py:35 msgid "Description" msgstr "" -#: ../melon/player/__init__.py:44 +#: ../melon/player/__init__.py:46 msgid "Add this video to a playlist" msgstr "" -#: ../melon/player/__init__.py:62 -msgid "Quality" -msgstr "" - -#: ../melon/player/__init__.py:63 -msgid "Video quality" -msgstr "" - -#: ../melon/player/__init__.py:78 +#: ../melon/player/__init__.py:65 msgid "Video could not be loaded" msgstr "" -#: ../melon/player/__init__.py:110 +#: ../melon/player/__init__.py:97 msgid "Player" msgstr "" @@ -619,6 +611,22 @@ msgid "" "Disabled servers won't show up in the browser or on the local/home screen" msgstr "" +#: ../melon/widgets/player.py:32 +msgid "No streams available" +msgstr "" + +#: ../melon/widgets/player.py:78 ../melon/widgets/player.py:244 +msgid "Play" +msgstr "" + +#: ../melon/widgets/player.py:99 +msgid "Video quality" +msgstr "" + +#: ../melon/widgets/player.py:238 +msgid "Pause" +msgstr "" + #: ../melon/widgets/preferencerow.py:115 msgid "Add" msgstr "" -- 2.38.5