~comcloudway/melon

efda51bb46ccfdb1e9918f3d576cad9e4ede566c — Jakob Meier 6 months ago ff155a2
basic gstreamer based player

still missing a lot of features
but it contains the bare minimum to make the upcoming commits possible
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 ""