20 Commits
0.2.0 ... 0.2.1

Author SHA1 Message Date
Rafał Dzięgiel
0ab0b66825 0.2.1 2021-04-19 13:06:40 +02:00
Rafał Dzięgiel
d901eb4712 Update README.md 2021-04-19 10:31:30 +02:00
Rafał Dzięgiel
fe03719b38 Show tooltip with full playlist item text on hover
Some titles might be more than few words and will not fit in current playlist popover. Instead of stretching it, show full playlist item filename (or path) on hover in a tooltip.
2021-04-18 18:44:16 +02:00
Rafał Dzięgiel
f0ea7ae798 Remove set_seek_mode check
We now use a custom GstPlayer fork that has it added
2021-04-18 15:28:55 +02:00
Rafał Dzięgiel
380236b8ba Cleanup: do not extend player class twice
We only use the base class once, no need to have it separately then. Merge into single file.
2021-04-18 15:25:02 +02:00
Rafał Dzięgiel
e721130a63 YT: live videos with duration are not live anymore 2021-04-18 14:13:30 +02:00
Rafał Dzięgiel
eaf090d2e2 YT: be a little more quiet about some errors
Some errors are to be expected for some videos. Quietly use fallback methods for them without printing those errors.
2021-04-18 14:04:53 +02:00
Rafał Dzięgiel
87115f43d7 YT: store adaptive option value in itag opts
So its easier to access and obtained only once
2021-04-17 20:35:15 +02:00
Rafał Dzięgiel
33a5ec18fa Change prefs adaptive streaming text
This option sets the preferred streaming mode. When unavailable, other might still be used as a fallback.
2021-04-17 18:06:34 +02:00
Rafał Dzięgiel
ab8cafa0b8 YT: support non-adaptive live streaming 2021-04-17 18:03:33 +02:00
Rafał Dzięgiel
62b6de6db2 YT: support live HLS videos 2021-04-17 16:14:21 +02:00
Rafał Dzięgiel
643c2029d0 Fix wrong indentation size
All the other code uses 4 spaces indent
2021-04-17 13:12:58 +02:00
Rafał Dzięgiel
9799783ee5 Use Gst.(M)SECOND constants instead of numbers
It makes code easier to read
2021-04-17 13:08:12 +02:00
Rafał Dzięgiel
457cbde25e Remove unused return value
This function already appends to passed array. No need to return it.
2021-04-16 11:03:44 +02:00
Rafał Dzięgiel
2fd94fdc70 Add some YouTube related preferences 2021-04-16 10:37:17 +02:00
Rafał Dzięgiel
3a998fb91e YT: auto select best matching resolution for used monitor 2021-04-16 09:53:21 +02:00
Rafał Dzięgiel
b02f54a3a6 Do not show mobile controls transition on launch
Start app with the correct controls layout instead of showing the "hide elapsed time"
transition when started on mobile width. It is annoying.

We cannot detect surface width during app widgets assembly, so update the controls
revealers state on first surface update after window is mapped and only if running
on mobile width. Otherwise do not do anything like before which will result in
showing fully revealed controls (default).
2021-04-15 15:27:28 +02:00
Rafał Dzięgiel
ca7b44092e API: do not lock when changing scaled size values
Those values are private and should be accessed only from GTK thread, so locking widget should not be necessary here.
2021-04-15 11:58:12 +02:00
Rafał Dzięgiel
adbcfecb5e API: unset needs_info_update when stopped 2021-04-15 11:30:55 +02:00
Rafał Dzięgiel
a717e481e8 Fix missing top left menu buttons. Fixes #66
On some non-default system configurations the "menu" layout item might be replaced with one named "icon". Handle "icon" the same as "menu" when organizing headerbar buttons.
2021-04-14 17:48:57 +02:00
20 changed files with 564 additions and 331 deletions

View File

@@ -62,8 +62,7 @@ It can be enabled from inside player preferences dialog inside `Advanced -> GStr
Since the whole app is rendered using your GPU, users of VERY weak GPUs might want to disable the "render window shadows" option to have more GPU power available for non-fullscreen video rendering.
## Other Questions?
Feel free to ask me any questions.<br>
Use either GitHub [discussions](https://github.com/Rafostar/clapper/discussions) or come and talk on Matrix: **#clapper-player:matrix.org**
Feel free to ask me any questions. Come and talk on Matrix: [#clapper-player:matrix.org](https://matrix.to/#/#clapper-player:matrix.org)
## Special Thanks
Many thanks to [sp1ritCS](https://github.com/sp1ritCS) for creating and maintaining package build files.

View File

@@ -102,7 +102,11 @@
<!-- YouTube -->
<key name="yt-adaptive-enabled" type="b">
<default>false</default>
<summary>Enable to use adaptive streaming</summary>
<summary>Enable to use adaptive streaming for YouTube</summary>
</key>
<key name="yt-quality-type" type="s">
<default>"hfr"</default>
<summary>Max YouTube video quality type</summary>
</key>
<!-- Other -->

View File

@@ -52,6 +52,24 @@
</screenshot>
</screenshots>
<releases>
<release version="0.2.1" date="2021-04-19">
<description>
<p>Player:</p>
<ul>
<li>Fix missing top left menu buttons on some system configurations</li>
<li>Fix potential video sink deadlock</li>
<li>Do not show mobile controls transition on launch</li>
<li>Show tooltip with full playlist item text on hover</li>
</ul>
<p>YouTube:</p>
<ul>
<li>Auto select best matching resolution for used monitor</li>
<li>Added some YouTube related preferences</li>
<li>Added support for live HLS videos</li>
<li>Added support for non-adaptive live HLS streaming</li>
</ul>
</description>
</release>
<release version="0.2.0" date="2021-04-13">
<description>
<p>New features:</p>

View File

@@ -880,9 +880,9 @@ static void
emit_media_info_updated (GstClapper * self)
{
MediaInfoUpdatedSignalData *data = g_new (MediaInfoUpdatedSignalData, 1);
self->needs_info_update = FALSE;
data->clapper = g_object_ref (self);
g_mutex_lock (&self->lock);
self->needs_info_update = FALSE;
data->info = gst_clapper_media_info_copy (self->media_info);
g_mutex_unlock (&self->lock);
@@ -946,9 +946,12 @@ change_state (GstClapper * self, GstClapperState state)
gst_clapper_state_get_name (state));
self->app_state = state;
if (state == GST_CLAPPER_STATE_STOPPED && self->rate != 1.0) {
self->rate = 1.0;
emit_rate_notify (self);
if (state == GST_CLAPPER_STATE_STOPPED) {
self->needs_info_update = FALSE;
if (self->rate != 1.0) {
self->rate = 1.0;
emit_rate_notify (self);
}
}
if (g_signal_handler_find (self, G_SIGNAL_MATCH_ID,
@@ -2343,9 +2346,10 @@ stream_notify_cb (GstStreamCollection * collection, GstStream * stream,
g_mutex_lock (&self->lock);
info =
gst_clapper_stream_info_find_from_stream_id (self->media_info, stream_id);
if (info)
if (info) {
gst_clapper_stream_info_update_from_stream (self, info, stream);
emit_update = (self->needs_info_update && GST_IS_CLAPPER_VIDEO_INFO (info));
emit_update = (self->needs_info_update && GST_IS_CLAPPER_VIDEO_INFO (info));
}
g_mutex_unlock (&self->lock);
if (emit_update)

View File

@@ -151,13 +151,9 @@ gtk_clapper_gl_widget_size_allocate (GtkWidget * widget,
GtkClapperGLWidget *clapper_widget = GTK_CLAPPER_GL_WIDGET (widget);
gint scale_factor = gtk_widget_get_scale_factor (widget);
GTK_CLAPPER_GL_WIDGET_LOCK (clapper_widget);
clapper_widget->scaled_width = width * scale_factor;
clapper_widget->scaled_height = height * scale_factor;
GTK_CLAPPER_GL_WIDGET_UNLOCK (clapper_widget);
gtk_gl_area_queue_render (GTK_GL_AREA (widget));
}
@@ -566,7 +562,7 @@ gtk_clapper_gl_widget_render (GtkGLArea * widget, GdkGLContext * context)
GtkClapperGLWidgetPrivate *priv = clapper_widget->priv;
const GstGLFuncs *gl;
GTK_CLAPPER_GL_WIDGET_LOCK (widget);
GTK_CLAPPER_GL_WIDGET_LOCK (clapper_widget);
/* Draw black with GDK context when priv is not available yet.
GTK calls render with GDK context already active. */
@@ -672,7 +668,7 @@ done:
if (priv->other_context)
gst_gl_context_activate (priv->other_context, FALSE);
GTK_CLAPPER_GL_WIDGET_UNLOCK (widget);
GTK_CLAPPER_GL_WIDGET_UNLOCK (clapper_widget);
return FALSE;
}

View File

@@ -1,5 +1,5 @@
project('com.github.rafostar.Clapper', 'c', 'cpp',
version: '0.2.0',
version: '0.2.1',
meson_version: '>= 0.50.0',
license: 'GPL3',
default_options: [

View File

@@ -2,7 +2,7 @@ Format: 3.0 (quilt)
Source: clapper
Binary: clapper
Architecture: any
Version: 0.2.0
Version: 0.2.1
Maintainer: Rafostar <rafostar.github@gmail.com>
Build-Depends: debhelper (>= 10),
meson (>= 0.50),

View File

@@ -1,5 +1,5 @@
clapper (0.2.0) unstable; urgency=low
clapper (0.2.1) unstable; urgency=low
* New version
-- Rafostar <rafostar.github@gmail.com> Tue, 13 Apr 2021 09:39:00 +0100
-- Rafostar <rafostar.github@gmail.com> Mon, 19 Apr 2021 09:39:00 +0100

View File

@@ -26,7 +26,7 @@
%global glib2_version 2.56.0
Name: clapper
Version: 0.2.0
Version: 0.2.1
Release: 1%{?dist}
Summary: Simple and modern GNOME media player
@@ -126,6 +126,9 @@ desktop-file-validate %{buildroot}%{_datadir}/applications/*.desktop
%{_libdir}/%{appname}/
%changelog
* Mon Apr 19 2021 Rafostar <rafostar.github@gmail.com> - 0.2.1-1
- New version
* Tue Apr 13 2021 Rafostar <rafostar.github@gmail.com> - 0.2.0-1
- New version

4
src/controls.js vendored
View File

@@ -18,6 +18,8 @@ class ClapperControls extends Gtk.Box
can_focus: false,
});
this.minFullViewWidth = 560;
this.currentPosition = 0;
this.currentDuration = 0;
this.isPositionDragging = false;
@@ -473,7 +475,7 @@ class ClapperControls extends Gtk.Box
_onPlayerResize(width, height)
{
const isMobile = (width < 560);
const isMobile = (width < this.minFullViewWidth);
if(this.isMobile === isMobile)
return;

View File

@@ -228,6 +228,10 @@ class ClapperPrefsDialog extends Gtk.Dialog
{
title: 'Network',
widget: Prefs.NetworkPage,
},
{
title: 'YouTube',
widget: Prefs.YouTubePage,
}
]
},

View File

@@ -151,7 +151,7 @@ class ClapperHeaderBarBase extends Gtk.Box
for(let name of layoutArr) {
/* Menu might be named "appmenu" */
if(!menuAdded && (!name || name === 'appmenu'))
if(!menuAdded && (!name || name === 'appmenu' || name === 'icon'))
name = 'menu';
const widget = this[`${name}Widget`];

View File

@@ -1,24 +1,50 @@
const { Gdk, Gio, GObject, Gst, GstClapper, Gtk } = imports.gi;
const { Gdk, Gio, GLib, GObject, Gst, GstClapper, Gtk } = imports.gi;
const ByteArray = imports.byteArray;
const Debug = imports.src.debug;
const Misc = imports.src.misc;
const YouTube = imports.src.youtube;
const { PlayerBase } = imports.src.playerBase;
const { PlaylistWidget } = imports.src.playlist;
const { WebApp } = imports.src.webApp;
const { debug } = Debug;
const { settings } = Misc;
let WebServer;
var Player = GObject.registerClass(
class ClapperPlayer extends PlayerBase
class ClapperPlayer extends GstClapper.Clapper
{
_init()
{
super._init();
const gtk4plugin = new GstClapper.ClapperGtk4Plugin();
const glsinkbin = Gst.ElementFactory.make('glsinkbin', null);
glsinkbin.sink = gtk4plugin.video_sink;
const dispatcher = new GstClapper.ClapperGMainContextSignalDispatcher();
const renderer = new GstClapper.ClapperVideoOverlayVideoRenderer({
video_sink: glsinkbin
});
super._init({
signal_dispatcher: dispatcher,
video_renderer: renderer
});
this.widget = gtk4plugin.video_sink.widget;
this.widget.add_css_class('videowidget');
this.state = GstClapper.ClapperState.STOPPED;
this.visualization_enabled = false;
this.webserver = null;
this.webapp = null;
this.playlistWidget = new PlaylistWidget();
this.seek_done = true;
this.needsFastSeekRestore = false;
this.customVideoTitle = null;
this.windowMapped = false;
this.canAutoFullscreen = false;
this.playOnFullscreen = false;
this.quitOnStop = false;
@@ -32,15 +58,93 @@ class ClapperPlayer extends PlayerBase
keyController.connect('key-released', this._onWidgetKeyReleased.bind(this));
this.widget.add_controller(keyController);
this.set_all_plugins_ranks();
this.set_initial_config();
this.set_and_bind_settings();
this.connect('state-changed', this._onStateChanged.bind(this));
this.connect('uri-loaded', this._onUriLoaded.bind(this));
this.connect('end-of-stream', this._onStreamEnded.bind(this));
this.connect('warning', this._onPlayerWarning.bind(this));
this.connect('error', this._onPlayerError.bind(this));
settings.connect('changed', this._onSettingsKeyChanged.bind(this));
this._realizeSignal = this.widget.connect('realize', this._onWidgetRealize.bind(this));
}
set_and_bind_settings()
{
const settingsToSet = [
'seeking-mode',
'audio-offset',
'subtitle-offset',
'play-flags',
'webserver-enabled'
];
for(let key of settingsToSet)
this._onSettingsKeyChanged(settings, key);
const flag = Gio.SettingsBindFlags.GET;
settings.bind('subtitle-font', this.pipeline, 'subtitle_font_desc', flag);
}
set_initial_config()
{
this.set_mute(false);
/* FIXME: change into option in preferences */
const pipeline = this.get_pipeline();
pipeline.ring_buffer_max_size = 8 * 1024 * 1024;
}
set_all_plugins_ranks()
{
let data = [];
/* Set empty plugin list if someone messed it externally */
try {
data = JSON.parse(settings.get_string('plugin-ranking'));
if(!Array.isArray(data))
throw new Error('plugin ranking data is not an array!');
}
catch(err) {
debug(err);
settings.set_string('plugin-ranking', "[]");
}
for(let plugin of data) {
if(!plugin.apply || !plugin.name)
continue;
this.set_plugin_rank(plugin.name, plugin.rank);
}
}
set_plugin_rank(name, rank)
{
const gstRegistry = Gst.Registry.get();
const feature = gstRegistry.lookup_feature(name);
if(!feature)
return debug(`plugin unavailable: ${name}`);
const oldRank = feature.get_rank();
if(rank === oldRank)
return;
feature.set_rank(rank);
debug(`changed rank: ${oldRank} -> ${rank} for ${name}`);
}
draw_black(isEnabled)
{
this.widget.ignore_textures = isEnabled;
if(this.state !== GstClapper.ClapperState.PLAYING)
this.widget.queue_render();
}
set_uri(uri)
{
this.customVideoTitle = null;
@@ -54,7 +158,11 @@ class ClapperPlayer extends PlayerBase
if(!this.ytClient)
this.ytClient = new YouTube.YouTubeClient();
this.ytClient.getPlaybackDataAsync(videoId)
const { root } = this.widget;
const surface = root.get_surface();
const monitor = root.display.get_monitor_at_surface(surface);
this.ytClient.getPlaybackDataAsync(videoId, monitor)
.then(data => {
this.customVideoTitle = data.title;
super.set_uri(data.uri);
@@ -121,10 +229,9 @@ class ClapperPlayer extends PlayerBase
this.playlistWidget.addItem(uri);
}
const firstTrack = this.playlistWidget.get_row_at_index(0);
if(!firstTrack) return;
firstTrack.activate();
/* If not mapped yet, first track will play after map */
if(this.windowMapped)
this._playFirstTrack();
}
set_subtitles(source)
@@ -179,7 +286,7 @@ class ClapperPlayer extends PlayerBase
seek_seconds(seconds)
{
this.seek(seconds * 1000000000);
this.seek(seconds * Gst.SECOND);
}
seek_chapter(seconds)
@@ -189,12 +296,9 @@ class ClapperPlayer extends PlayerBase
return;
}
/* FIXME: Remove this check when GstPlay(er) have set_seek_mode function */
if(this.set_seek_mode) {
this.set_seek_mode(GstClapper.ClapperSeekMode.DEFAULT);
this.seekingMode = 'normal';
this.needsFastSeekRestore = true;
}
this.set_seek_mode(GstClapper.ClapperSeekMode.DEFAULT);
this.seekingMode = 'normal';
this.needsFastSeekRestore = true;
this.seek_seconds(seconds);
}
@@ -251,6 +355,14 @@ class ClapperPlayer extends PlayerBase
this[action]();
}
emitWs(action, value)
{
if(!this.webserver)
return;
this.webserver.sendMessage({ action, value });
}
receiveWs(action, value)
{
switch(action) {
@@ -275,7 +387,7 @@ class ClapperPlayer extends PlayerBase
clapperWidget.toggleFullscreen();
break;
default:
super.receiveWs(action, value);
debug(`unhandled WebSocket action: ${action}`);
break;
}
break;
@@ -291,6 +403,14 @@ class ClapperPlayer extends PlayerBase
: Gst.filename_to_uri(source);
}
_playFirstTrack()
{
const firstTrack = this.playlistWidget.get_row_at_index(0);
if(!firstTrack) return;
firstTrack.activate();
}
_performCloseCleanup(window)
{
window.disconnect(this.closeRequestSignal);
@@ -312,8 +432,8 @@ class ClapperPlayer extends PlayerBase
let resumeInfo = {};
if(playlistItem.isLocalFile && settings.get_boolean('resume-enabled')) {
const resumeTime = Math.floor(this.position / 1000000000);
const resumeDuration = this.duration / 1000000000;
const resumeTime = Math.floor(this.position / Gst.SECOND);
const resumeDuration = this.duration / Gst.SECOND;
/* Do not save resume info when video is very short,
* just started or almost finished */
@@ -522,6 +642,12 @@ class ClapperPlayer extends PlayerBase
}
}
_onWindowMap(window)
{
this.windowMapped = true;
this._playFirstTrack();
}
_onCloseRequest(window)
{
this._performCloseCleanup(window);
@@ -532,4 +658,109 @@ class ClapperPlayer extends PlayerBase
this.quitOnStop = true;
this.stop();
}
_onSettingsKeyChanged(settings, key)
{
let root, value, action;
switch(key) {
case 'seeking-mode':
this.seekingMode = settings.get_string('seeking-mode');
switch(this.seekingMode) {
case 'fast':
this.set_seek_mode(GstClapper.ClapperSeekMode.FAST);
break;
case 'accurate':
this.set_seek_mode(GstClapper.ClapperSeekMode.ACCURATE);
break;
default:
this.set_seek_mode(GstClapper.ClapperSeekMode.DEFAULT);
break;
}
break;
case 'render-shadows':
root = this.widget.get_root();
if(!root) break;
const gpuClass = 'gpufriendly';
const renderShadows = settings.get_boolean(key);
const hasShadows = !root.has_css_class(gpuClass);
if(renderShadows === hasShadows)
break;
action = (renderShadows) ? 'remove' : 'add';
root[action + '_css_class'](gpuClass);
break;
case 'audio-offset':
value = Math.round(settings.get_double(key) * -Gst.MSECOND);
this.set_audio_video_offset(value);
debug(`set audio-video offset: ${value}`);
break;
case 'subtitle-offset':
value = Math.round(settings.get_double(key) * -Gst.MSECOND);
this.set_subtitle_video_offset(value);
debug(`set subtitle-video offset: ${value}`);
break;
case 'dark-theme':
root = this.widget.get_root();
if(!root) break;
root.application._onThemeChanged(Gtk.Settings.get_default());
break;
case 'play-flags':
const initialFlags = this.pipeline.flags;
const settingsFlags = settings.get_int(key);
if(initialFlags === settingsFlags)
break;
this.pipeline.flags = settingsFlags;
debug(`changed play flags: ${initialFlags} -> ${settingsFlags}`);
break;
case 'webserver-enabled':
case 'webapp-enabled':
const webserverEnabled = settings.get_boolean('webserver-enabled');
if(webserverEnabled) {
if(!WebServer) {
/* Probably most users will not use this,
* so conditional import for faster startup */
WebServer = imports.src.webServer.WebServer;
}
if(!this.webserver) {
this.webserver = new WebServer(settings.get_int('webserver-port'));
this.webserver.passMsgData = this.receiveWs.bind(this);
}
this.webserver.startListening();
const webappEnabled = settings.get_boolean('webapp-enabled');
if(!this.webapp && !webappEnabled)
break;
if(webappEnabled) {
if(!this.webapp)
this.webapp = new WebApp();
this.webapp.startDaemonApp(settings.get_int('webapp-port'));
}
}
else if(this.webserver) {
/* remote app will close when connection is lost
* which will cause the daemon to close too */
this.webserver.stopListening();
}
break;
case 'webserver-port':
if(!this.webserver)
break;
this.webserver.setListeningPort(settings.get_int(key));
break;
default:
break;
}
}
});

View File

@@ -1,237 +0,0 @@
const { Gio, GLib, GObject, Gst, GstClapper, Gtk } = imports.gi;
const Debug = imports.src.debug;
const Misc = imports.src.misc;
const { PlaylistWidget } = imports.src.playlist;
const { WebApp } = imports.src.webApp;
const { debug } = Debug;
const { settings } = Misc;
let WebServer;
var PlayerBase = GObject.registerClass(
class ClapperPlayerBase extends GstClapper.Clapper
{
_init()
{
const gtk4plugin = new GstClapper.ClapperGtk4Plugin();
const glsinkbin = Gst.ElementFactory.make('glsinkbin', null);
glsinkbin.sink = gtk4plugin.video_sink;
const dispatcher = new GstClapper.ClapperGMainContextSignalDispatcher();
const renderer = new GstClapper.ClapperVideoOverlayVideoRenderer({
video_sink: glsinkbin
});
super._init({
signal_dispatcher: dispatcher,
video_renderer: renderer
});
this.widget = gtk4plugin.video_sink.widget;
this.widget.add_css_class('videowidget');
this.state = GstClapper.ClapperState.STOPPED;
this.visualization_enabled = false;
this.webserver = null;
this.webapp = null;
this.playlistWidget = new PlaylistWidget();
this.set_all_plugins_ranks();
this.set_initial_config();
this.set_and_bind_settings();
settings.connect('changed', this._onSettingsKeyChanged.bind(this));
}
set_and_bind_settings()
{
const settingsToSet = [
'seeking-mode',
'audio-offset',
'subtitle-offset',
'play-flags',
'webserver-enabled'
];
for(let key of settingsToSet)
this._onSettingsKeyChanged(settings, key);
const flag = Gio.SettingsBindFlags.GET;
settings.bind('subtitle-font', this.pipeline, 'subtitle_font_desc', flag);
}
set_initial_config()
{
this.set_mute(false);
/* FIXME: change into option in preferences */
const pipeline = this.get_pipeline();
pipeline.ring_buffer_max_size = 8 * 1024 * 1024;
}
set_all_plugins_ranks()
{
let data = [];
/* Set empty plugin list if someone messed it externally */
try {
data = JSON.parse(settings.get_string('plugin-ranking'));
if(!Array.isArray(data))
throw new Error('plugin ranking data is not an array!');
}
catch(err) {
debug(err);
settings.set_string('plugin-ranking', "[]");
}
for(let plugin of data) {
if(!plugin.apply || !plugin.name)
continue;
this.set_plugin_rank(plugin.name, plugin.rank);
}
}
set_plugin_rank(name, rank)
{
const gstRegistry = Gst.Registry.get();
const feature = gstRegistry.lookup_feature(name);
if(!feature)
return debug(`plugin unavailable: ${name}`);
const oldRank = feature.get_rank();
if(rank === oldRank)
return;
feature.set_rank(rank);
debug(`changed rank: ${oldRank} -> ${rank} for ${name}`);
}
draw_black(isEnabled)
{
this.widget.ignore_textures = isEnabled;
if(this.state !== GstClapper.ClapperState.PLAYING)
this.widget.queue_render();
}
emitWs(action, value)
{
if(!this.webserver)
return;
this.webserver.sendMessage({ action, value });
}
receiveWs(action, value)
{
debug(`unhandled WebSocket action: ${action}`);
}
_onSettingsKeyChanged(settings, key)
{
let root, value, action;
switch(key) {
case 'seeking-mode':
this.seekingMode = settings.get_string('seeking-mode');
switch(this.seekingMode) {
case 'fast':
this.set_seek_mode(GstClapper.ClapperSeekMode.FAST);
break;
case 'accurate':
this.set_seek_mode(GstClapper.ClapperSeekMode.ACCURATE);
break;
default:
this.set_seek_mode(GstClapper.ClapperSeekMode.DEFAULT);
break;
}
break;
case 'render-shadows':
root = this.widget.get_root();
if(!root) break;
const gpuClass = 'gpufriendly';
const renderShadows = settings.get_boolean(key);
const hasShadows = !root.has_css_class(gpuClass);
if(renderShadows === hasShadows)
break;
action = (renderShadows) ? 'remove' : 'add';
root[action + '_css_class'](gpuClass);
break;
case 'audio-offset':
value = Math.round(settings.get_double(key) * -1000000);
this.set_audio_video_offset(value);
debug(`set audio-video offset: ${value}`);
break;
case 'subtitle-offset':
value = Math.round(settings.get_double(key) * -1000000);
this.set_subtitle_video_offset(value);
debug(`set subtitle-video offset: ${value}`);
break;
case 'dark-theme':
root = this.widget.get_root();
if(!root) break;
root.application._onThemeChanged(Gtk.Settings.get_default());
break;
case 'play-flags':
const initialFlags = this.pipeline.flags;
const settingsFlags = settings.get_int(key);
if(initialFlags === settingsFlags)
break;
this.pipeline.flags = settingsFlags;
debug(`changed play flags: ${initialFlags} -> ${settingsFlags}`);
break;
case 'webserver-enabled':
case 'webapp-enabled':
const webserverEnabled = settings.get_boolean('webserver-enabled');
if(webserverEnabled) {
if(!WebServer) {
/* Probably most users will not use this,
* so conditional import for faster startup */
WebServer = imports.src.webServer.WebServer;
}
if(!this.webserver) {
this.webserver = new WebServer(settings.get_int('webserver-port'));
this.webserver.passMsgData = this.receiveWs.bind(this);
}
this.webserver.startListening();
const webappEnabled = settings.get_boolean('webapp-enabled');
if(!this.webapp && !webappEnabled)
break;
if(webappEnabled) {
if(!this.webapp)
this.webapp = new WebApp();
this.webapp.startDaemonApp(settings.get_int('webapp-port'));
}
}
else if(this.webserver) {
/* remote app will close when connection is lost
* which will cause the daemon to close too */
this.webserver.stopListening();
}
break;
case 'webserver-port':
if(!this.webserver)
break;
this.webserver.setListeningPort(settings.get_int(key));
break;
default:
break;
}
}
});

View File

@@ -119,6 +119,7 @@ class ClapperPlaylistItem extends Gtk.ListBoxRow
this.isLocalFile = true;
}
this.filename = filename || uri;
this.set_tooltip_text(this.filename);
const box = new Gtk.Box({
orientation: Gtk.Orientation.HORIZONTAL,

View File

@@ -132,6 +132,22 @@ class ClapperNetworkPage extends PrefsBase.Grid
}
});
var YouTubePage = GObject.registerClass(
class ClapperYouTubePage extends PrefsBase.Grid
{
_init()
{
super._init();
this.addTitle('YouTube');
this.addCheckButton('Prefer adaptive streaming', 'yt-adaptive-enabled');
this.addComboBoxText('Max quality', [
['normal', "Normal"],
['hfr', "HFR"],
], 'yt-quality-type');
}
});
var GStreamerPage = GObject.registerClass(
class ClapperGStreamerPage extends PrefsBase.Grid
{

View File

@@ -370,6 +370,18 @@ class ClapperButtonsRevealer extends Gtk.Revealer
this.get_child().append(widget);
}
revealInstantly(isReveal)
{
if(this.child_revealed === isReveal)
return;
const initialDuration = this.transition_duration;
this.transition_duration = 0;
this.reveal_child = isReveal;
this.transition_duration = initialDuration;
}
_setRotateClass(icon, isAdd)
{
const cssClass = 'halfrotate';
@@ -388,7 +400,8 @@ class ClapperButtonsRevealer extends Gtk.Revealer
_onRevealChild(button)
{
this._setRotateClass(button.child, true);
if(this.reveal_child !== this.child_revealed)
this._setRotateClass(button.child, true);
}
_onChildRevealed(button)

View File

@@ -371,7 +371,7 @@ class ClapperWidget extends Gtk.Grid
return;
}
const pos = Math.floor(start / 1000000) / 1000;
const pos = Math.floor(start / Gst.MSECOND) / 1000;
const tags = subentry.get_tags();
this.controls.positionScale.add_mark(pos, Gtk.PositionType.TOP, null);
@@ -467,7 +467,7 @@ class ClapperWidget extends Gtk.Grid
_onPlayerDurationChanged(player, duration)
{
const durationSeconds = duration / 1000000000;
const durationSeconds = duration / Gst.SECOND;
const durationFloor = Math.floor(durationSeconds);
/* Sometimes GstPlayer might re-emit
@@ -515,7 +515,7 @@ class ClapperWidget extends Gtk.Grid
)
return;
const positionSeconds = Math.round(position / 1000000000);
const positionSeconds = Math.round(position / Gst.SECOND);
if(positionSeconds === this.controls.currentPosition)
return;
@@ -541,6 +541,12 @@ class ClapperWidget extends Gtk.Grid
if(width === this.layoutWidth)
return;
/* Launch without showing revealers transitions on mobile width */
if(!this.layoutWidth && width < this.controls.minFullViewWidth) {
for(let revealer of this.controls.revealersArr)
revealer.revealInstantly(false);
}
this.layoutWidth = width;
if(this.isFullscreenMode)
@@ -572,6 +578,8 @@ class ClapperWidget extends Gtk.Grid
surface.connect('notify::state', this._onStateNotify.bind(this));
surface.connect('layout', this._onLayoutUpdate.bind(this));
this.player._onWindowMap(window);
}
_clearTimeout(name)

View File

@@ -3,6 +3,7 @@ const Dash = imports.src.dash;
const Debug = imports.src.debug;
const FileOps = imports.src.fileOps;
const Misc = imports.src.misc;
const YTItags = imports.src.youtubeItags;
const YTDL = imports.src.assets['node-ytdl-core'];
const debug = Debug.ytDebug;
@@ -304,7 +305,7 @@ var YouTubeClient = GObject.registerClass({
});
}
async getPlaybackDataAsync(videoId)
async getPlaybackDataAsync(videoId, monitor)
{
const info = await this.getVideoInfoPromise(videoId).catch(debug);
@@ -312,28 +313,40 @@ var YouTubeClient = GObject.registerClass({
throw new Error('no YouTube video info');
let uri = null;
const dashInfo = await this.getDashInfoAsync(info).catch(debug);
const itagOpts = {
width: monitor.geometry.width * monitor.scale_factor,
height: monitor.geometry.height * monitor.scale_factor,
codec: 'h264',
type: settings.get_string('yt-quality-type'),
adaptive: settings.get_boolean('yt-adaptive-enabled'),
};
if(dashInfo) {
debug('parsed video info to dash info');
const dash = Dash.generateDash(dashInfo);
uri = await this.getHLSUriAsync(info, itagOpts);
if(dash) {
debug('got dash data');
if(!uri) {
const dashInfo = await this.getDashInfoAsync(info, itagOpts).catch(debug);
const dashFile = await FileOps.saveFilePromise(
'tmp', null, 'clapper.mpd', dash
).catch(debug);
if(dashInfo) {
debug('parsed video info to dash info');
const dash = Dash.generateDash(dashInfo);
if(dashFile)
uri = dashFile.get_uri();
if(dash) {
debug('got dash data');
debug('got dash file');
const dashFile = await FileOps.saveFilePromise(
'tmp', null, 'clapper.mpd', dash
).catch(debug);
if(dashFile)
uri = dashFile.get_uri();
debug('got dash file');
}
}
}
if(!uri)
uri = this.getBestCombinedUri(info);
uri = this.getBestCombinedUri(info, itagOpts);
if(!uri)
throw new Error('no YouTube video URI');
@@ -349,7 +362,61 @@ var YouTubeClient = GObject.registerClass({
return { uri, title };
}
async getDashInfoAsync(info)
async getHLSUriAsync(info, itagOpts)
{
const isLive = (
info.videoDetails.isLiveContent
&& (!info.videoDetails.lengthSeconds
|| Number(info.videoDetails.lengthSeconds) <= 0)
);
debug(`video is live: ${isLive}`);
/* YouTube only uses HLS for live content */
if(!isLive)
return null;
const hlsUri = info.streamingData.hlsManifestUrl;
if(!hlsUri) {
/* HLS may be unavailable on finished live streams */
debug('no HLS manifest URL');
return null;
}
if(!itagOpts.adaptive) {
const result = await this._downloadDataPromise(hlsUri).catch(debug);
if(!result || !result.data) {
debug(new Error('HLS manifest download failed'));
return hlsUri;
}
const hlsArr = result.data.split('\n');
const hlsStreams = [];
let index = hlsArr.length;
while(index--) {
const url = hlsArr[index];
if(!Gst.Uri.is_valid(url))
continue;
const itagIndex = url.indexOf('/itag/') + 6;
const itag = url.substring(itagIndex, itagIndex + 2);
hlsStreams.push({ itag, url });
}
debug(`obtaining HLS itags for resolution: ${itagOpts.width}x${itagOpts.height}`);
const hlsItags = YTItags.getHLSItags(itagOpts);
debug(`HLS itags: ${JSON.stringify(hlsItags)}`);
const hlsStream = this.getBestStreamFromItags(hlsStreams, hlsItags);
if(hlsStream)
return hlsStream.url;
}
return hlsUri;
}
async getDashInfoAsync(info, itagOpts)
{
if(
!info.streamingData
@@ -358,22 +425,9 @@ var YouTubeClient = GObject.registerClass({
)
return null;
/* TODO: Options in prefs to set preferred video formats and adaptive streaming */
const isAdaptiveEnabled = settings.get_boolean('yt-adaptive-enabled');
const allowedFormats = {
video: [
133,
134,
135,
136,
137,
298,
299,
],
audio: [
140,
]
};
debug(`obtaining DASH itags for resolution: ${itagOpts.width}x${itagOpts.height}`);
const dashItags = YTItags.getDashItags(itagOpts);
debug(`DASH itags: ${JSON.stringify(dashItags)}`);
const filteredStreams = {
video: [],
@@ -382,11 +436,11 @@ var YouTubeClient = GObject.registerClass({
for(let fmt of ['video', 'audio']) {
debug(`filtering ${fmt} streams`);
let index = allowedFormats[fmt].length;
let index = dashItags[fmt].length;
while(index--) {
const itag = allowedFormats[fmt][index];
const foundStream = info.streamingData.adaptiveFormats.find(stream => (stream.itag == itag));
const itag = dashItags[fmt][index];
const foundStream = info.streamingData.adaptiveFormats.find(stream => stream.itag == itag);
if(foundStream) {
/* Parse and convert mimeType string into object */
foundStream.mimeInfo = this._getMimeInfo(foundStream.mimeType);
@@ -401,7 +455,7 @@ var YouTubeClient = GObject.registerClass({
filteredStreams[fmt].unshift(foundStream);
debug(`added ${fmt} itag: ${foundStream.itag}`);
if(!isAdaptiveEnabled)
if(!itagOpts.adaptive)
break;
}
}
@@ -417,8 +471,16 @@ var YouTubeClient = GObject.registerClass({
for(let stream of fmtArr) {
debug(`initial URL: ${stream.url}`);
const result = await this._downloadDataPromise(stream.url, 'HEAD').catch(debug);
if(!result) return null;
/* Errors in some cases are to be expected here,
* so be quiet about them and use fallback methods */
const result = await this._downloadDataPromise(
stream.url, 'HEAD'
).catch(err => debug(err.message));
if(!result || !result.uri) {
debug('redirect could not be resolved');
return null;
}
stream.url = Misc.encodeHTML(result.uri)
.replace('?', '/')
@@ -440,16 +502,21 @@ var YouTubeClient = GObject.registerClass({
};
}
getBestCombinedUri(info)
getBestCombinedUri(info, itagOpts)
{
debug('obtaining best combined URL');
debug(`obtaining best combined URL for resolution: ${itagOpts.width}x${itagOpts.height}`);
const streams = info.streamingData.formats;
if(!info.streamingData.formats.length)
if(!streams.length)
return null;
const combinedStream = info.streamingData.formats[
info.streamingData.formats.length - 1
];
const combinedItags = YTItags.getCombinedItags(itagOpts);
let combinedStream = this.getBestStreamFromItags(streams, combinedItags);
if(!combinedStream) {
debug('trying any combined stream as last resort');
combinedStream = streams[streams.length - 1];
}
if(!combinedStream || !combinedStream.url)
return null;
@@ -457,6 +524,23 @@ var YouTubeClient = GObject.registerClass({
return combinedStream.url;
}
getBestStreamFromItags(streams, itags)
{
let index = itags.length;
while(index--) {
const itag = itags[index];
const stream = streams.find(stream => stream.itag == itag);
if(stream) {
debug(`found preferred stream itag: ${stream.itag}`);
return stream;
}
}
debug('could not find preferred stream for itags');
return null;
}
compareLastVideoId(videoId)
{
if(!this.lastInfo)
@@ -506,7 +590,7 @@ var YouTubeClient = GObject.registerClass({
return resolve(result);
}
debug(new Error(`response code: ${statusCode}`));
debug(`response code: ${statusCode}`);
/* Internal Soup codes mean download aborted
* or some other error that cannot be handled
@@ -694,14 +778,21 @@ var YouTubeClient = GObject.registerClass({
_getIsCipher(data)
{
/* Check only first best combined,
* AFAIK there are no videos without it */
if(data.formats[0].url)
const stream = (data.formats.length)
? data.formats[0]
: data.adaptiveFormats[0];
if(!stream) {
debug(new Error('no streams'));
return false;
}
if(stream.url)
return false;
if(
data.formats[0].signatureCipher
|| data.formats[0].cipher
stream.signatureCipher
|| stream.cipher
)
return true;

80
src/youtubeItags.js Normal file
View File

@@ -0,0 +1,80 @@
const Itags = {
video: {
h264: {
normal: {
240: 133,
360: 134,
480: 135,
720: 136,
1080: 137,
},
hfr: {
720: 298,
1080: 299,
},
},
},
audio: {
aac: [140],
opus: [249, 250, 251],
},
combined: {
360: 18,
720: 22,
},
hls: {
240: 92,
360: 93,
480: 94,
720: 95,
1080: 96,
}
};
function _appendItagArray(arr, opts, formats)
{
const keys = Object.keys(formats);
for(let fmt of keys) {
arr.push(formats[fmt]);
if(
fmt >= opts.height
|| Math.floor(fmt * 16 / 9) >= opts.width
)
break;
}
return arr;
}
function getDashItags(opts)
{
const allowed = {
video: [],
audio: (opts.codec === 'h264')
? Itags.audio.aac
: Itags.audio.opus
};
const types = Object.keys(Itags.video[opts.codec]);
for(let type of types) {
const formats = Itags.video[opts.codec][type];
_appendItagArray(allowed.video, opts, formats);
if(type === opts.type)
break;
}
return allowed;
}
function getCombinedItags(opts)
{
return _appendItagArray([], opts, Itags.combined);
}
function getHLSItags(opts)
{
return _appendItagArray([], opts, Itags.hls);
}