M README.md => README.md +1 -8
@@ 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`
M melon/player/__init__.py => melon/player/__init__.py +14 -26
@@ 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
M melon/servers/nebula/__init__.py => melon/servers/nebula/__init__.py +2 -7
@@ 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")
]
M melon/servers/peertube/__init__.py => melon/servers/peertube/__init__.py +15 -4
@@ 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 [
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/iconbutton.py => melon/widgets/iconbutton.py +5 -3
@@ 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)
A melon/widgets/player.py => melon/widgets/player.py +270 -0
@@ 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}"
M po/POTFILES.in => po/POTFILES.in +1 -0
@@ 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
M po/de.po => po/de.po +25 -14
@@ 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 <noreply@weblate.org>\n"
"Language-Team: German <https://translate.codeberg.org/projects/melon/melon-"
@@ 132,7 132,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 "Merken"
@@ 310,27 310,19 @@ msgstr "Die folgenden Import-Methoden wurden gefunden"
msgid "There are no available importer methods"
msgstr "Es gibt keine verfügbaren Import-Methoden"
-#: ../melon/player/__init__.py:33
+#: ../melon/player/__init__.py:35
msgid "Description"
msgstr "Beschreibung"
-#: ../melon/player/__init__.py:44
+#: ../melon/player/__init__.py:46
msgid "Add this video to a playlist"
msgstr "Füge dieses Video einer Wiedergabeliste hinzu"
-#: ../melon/player/__init__.py:62
-msgid "Quality"
-msgstr "Auflösung"
-
-#: ../melon/player/__init__.py:63
-msgid "Video quality"
-msgstr "Auflösung"
-
-#: ../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 ""
@@ 659,6 651,22 @@ msgstr ""
"Deaktivierte Dienste werden beim Durchsuchen und auf dem Startbildschirm "
"nicht angezeigt"
+#: ../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 "Auflösung"
+
+#: ../melon/widgets/player.py:238
+msgid "Pause"
+msgstr ""
+
#: ../melon/widgets/preferencerow.py:115
msgid "Add"
msgstr "Hinzufügen"
@@ 711,5 719,8 @@ msgstr "Eintrag von der Liste entfernen"
msgid "Stream videos on the go"
msgstr "Videos unterwegs streamen"
+#~ msgid "Quality"
+#~ msgstr "Auflösung"
+
#~ msgid "Playlist Name"
#~ msgstr "Name der Wiedergabeliste"
M po/fa.po => po/fa.po +22 -14
@@ 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-05 05:13+0000\n"
"Last-Translator: sohrabbehdani <behdanisohrab@gmail.com>\n"
"Language-Team: Persian <https://translate.codeberg.org/projects/melon/melon-"
@@ 126,7 126,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 "نشانک گذاری"
@@ 298,27 298,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 ""
@@ 623,6 615,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 "افزودن"
M po/melon.pot => po/melon.pot +23 -15
@@ 6,9 6,9 @@
#, fuzzy
msgid ""
msgstr ""
-"Project-Id-Version: Melon 0.1.2\n"
+"Project-Id-Version: Melon 0.1.3\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: YEAR-MO-DA HO:MI+ZONE\n"
"Last-Translator: FULL NAME <EMAIL@ADDRESS>\n"
"Language-Team: LANGUAGE <LL@li.org>\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 ""