diff --git a/bin/com.github.rafostar.Clapper.in b/bin/com.github.rafostar.Clapper.in
index b62c9a2d..78cd7495 100644
--- a/bin/com.github.rafostar.Clapper.in
+++ b/bin/com.github.rafostar.Clapper.in
@@ -9,4 +9,4 @@ Package.init({
libdir: '@libdir@',
datadir: '@datadir@',
});
-Package.run(imports.main);
+Package.run(imports.clapper_src.main);
diff --git a/bin/com.github.rafostar.ClapperRemote.in b/bin/com.github.rafostar.ClapperRemote.in
new file mode 100644
index 00000000..e567a7b9
--- /dev/null
+++ b/bin/com.github.rafostar.ClapperRemote.in
@@ -0,0 +1,12 @@
+#!@GJS@
+
+const Package = imports.package;
+
+Package.init({
+ name: '@PACKAGE_NAME@',
+ version: '@PACKAGE_VERSION@',
+ prefix: '@prefix@',
+ libdir: '@libdir@',
+ datadir: '@datadir@',
+});
+Package.run(imports.clapper_src.mainRemote);
diff --git a/bin/meson.build b/bin/meson.build
index 2cdf501d..6a3aec8e 100644
--- a/bin/meson.build
+++ b/bin/meson.build
@@ -6,11 +6,15 @@ bin_conf.set('prefix', get_option('prefix'))
bin_conf.set('libdir', join_paths(get_option('prefix'), get_option('libdir')))
bin_conf.set('datadir', join_paths(get_option('prefix'), get_option('datadir')))
-configure_file(
- input: 'com.github.rafostar.Clapper.in',
- output: 'com.github.rafostar.Clapper',
- configuration: bin_conf,
- install: true,
- install_dir: get_option('bindir'),
- install_mode: 'rwxr-xr-x'
-)
+clapper_apps = [ '', 'Remote' ]
+
+foreach id_postfix : clapper_apps
+ configure_file(
+ input: 'com.github.rafostar.Clapper' + id_postfix + '.in',
+ output: 'com.github.rafostar.Clapper' + id_postfix,
+ configuration: bin_conf,
+ install: true,
+ install_dir: get_option('bindir'),
+ install_mode: 'rwxr-xr-x'
+ )
+endforeach
diff --git a/clapper_src/app.js b/clapper_src/app.js
index c7d38900..575b3808 100644
--- a/clapper_src/app.js
+++ b/clapper_src/app.js
@@ -1,136 +1,39 @@
-const { Gio, GObject, Gtk } = imports.gi;
+const { GObject } = imports.gi;
+const { AppBase } = imports.clapper_src.appBase;
const { HeaderBar } = imports.clapper_src.headerbar;
const { Widget } = imports.clapper_src.widget;
const Debug = imports.clapper_src.debug;
-const Menu = imports.clapper_src.menu;
-const Misc = imports.clapper_src.misc;
let { debug } = Debug;
-let { settings } = Misc;
var App = GObject.registerClass(
-class ClapperApp extends Gtk.Application
+class ClapperApp extends AppBase
{
- _init(opts)
- {
- super._init({
- application_id: Misc.appId
- });
-
- let defaults = {
- playlist: [],
- };
- Object.assign(this, defaults, opts);
-
- this.doneFirstActivate = false;
- }
-
vfunc_startup()
{
super.vfunc_startup();
- let window = new Gtk.ApplicationWindow({
- application: this,
- title: Misc.appName,
- });
- window.isClapperApp = true;
- window.add_css_class('nobackground');
-
- if(!settings.get_boolean('render-shadows'))
- window.add_css_class('gpufriendly');
-
- if(
- settings.get_boolean('dark-theme')
- && settings.get_boolean('brighter-sliders')
- )
- window.add_css_class('brightscale');
-
- for(let action in Menu.actions) {
- let simpleAction = new Gio.SimpleAction({
- name: action
- });
- simpleAction.connect(
- 'activate', () => Menu.actions[action](this.active_window)
- );
- this.add_action(simpleAction);
- }
+ this.active_window.isClapperApp = true;
+ this.active_window.add_css_class('nobackground');
let clapperWidget = new Widget();
- window.set_child(clapperWidget);
+ this.active_window.set_child(clapperWidget);
+
+ let headerBar = new HeaderBar(this.active_window);
+ this.active_window.set_titlebar(headerBar);
let size = clapperWidget.windowSize;
- window.set_default_size(size[0], size[1]);
+ this.active_window.set_default_size(size[0], size[1]);
debug(`restored window size: ${size[0]}x${size[1]}`);
-
- let clapperPath = Misc.getClapperPath();
- let uiBuilder = Gtk.Builder.new_from_file(
- `${clapperPath}/ui/clapper.ui`
- );
- let models = {
- addMediaMenu: uiBuilder.get_object('addMediaMenu'),
- settingsMenu: uiBuilder.get_object('settingsMenu'),
- };
- let headerBar = new HeaderBar(window, models);
- window.set_titlebar(headerBar);
- }
-
- vfunc_activate()
- {
- super.vfunc_activate();
-
- if(!this.doneFirstActivate)
- this._onFirstActivate();
-
- this.active_window.present();
- }
-
- run(arr)
- {
- super.run(arr || []);
- }
-
- _onFirstActivate()
- {
- let gtkSettings = Gtk.Settings.get_default();
- settings.bind(
- 'dark-theme', gtkSettings,
- 'gtk-application-prefer-dark-theme',
- Gio.SettingsBindFlags.GET
- );
- this._onThemeChanged(gtkSettings);
- gtkSettings.connect('notify::gtk-theme-name', this._onThemeChanged.bind(this));
-
- this.windowShowSignal = this.active_window.connect(
- 'show', this._onWindowShow.bind(this)
- );
-
- this.doneFirstActivate = true;
}
_onWindowShow(window)
{
- window.disconnect(this.windowShowSignal);
- this.windowShowSignal = null;
+ super._onWindowShow(window);
if(this.playlist.length) {
let { player } = window.get_child();
player.set_playlist(this.playlist);
}
}
-
- _onThemeChanged(gtkSettings)
- {
- const theme = gtkSettings.gtk_theme_name;
- debug(`user selected theme: ${theme}`);
-
- if(!theme.endsWith('-dark'))
- return;
-
- /* We need to request a default theme with optional dark variant
- to make the "gtk_application_prefer_dark_theme" setting work */
- const parsedTheme = theme.substring(0, theme.lastIndexOf('-'));
-
- gtkSettings.gtk_theme_name = parsedTheme;
- debug(`set theme: ${parsedTheme}`);
- }
});
diff --git a/clapper_src/appBase.js b/clapper_src/appBase.js
new file mode 100644
index 00000000..b2224612
--- /dev/null
+++ b/clapper_src/appBase.js
@@ -0,0 +1,112 @@
+const { Gio, GObject, Gtk } = imports.gi;
+const Debug = imports.clapper_src.debug;
+const Menu = imports.clapper_src.menu;
+const Misc = imports.clapper_src.misc;
+
+let { debug } = Debug;
+let { settings } = Misc;
+
+var AppBase = GObject.registerClass(
+class ClapperAppBase extends Gtk.Application
+{
+ _init(opts)
+ {
+ opts = opts || {};
+
+ let defaults = {
+ idPostfix: '',
+ playlist: [],
+ };
+ Object.assign(this, defaults, opts);
+
+ super._init({
+ application_id: Misc.appId + this.idPostfix
+ });
+
+ this.doneFirstActivate = false;
+ }
+
+ vfunc_startup()
+ {
+ super.vfunc_startup();
+
+ let window = new Gtk.ApplicationWindow({
+ application: this,
+ title: Misc.appName,
+ });
+
+ if(!settings.get_boolean('render-shadows'))
+ window.add_css_class('gpufriendly');
+
+ if(
+ settings.get_boolean('dark-theme')
+ && settings.get_boolean('brighter-sliders')
+ )
+ window.add_css_class('brightscale');
+
+ for(let action in Menu.actions) {
+ let simpleAction = new Gio.SimpleAction({
+ name: action
+ });
+ simpleAction.connect(
+ 'activate', () => Menu.actions[action](this.active_window)
+ );
+ this.add_action(simpleAction);
+ }
+ }
+
+ vfunc_activate()
+ {
+ super.vfunc_activate();
+
+ if(!this.doneFirstActivate)
+ this._onFirstActivate();
+
+ this.active_window.present();
+ }
+
+ run(arr)
+ {
+ super.run(arr || []);
+ }
+
+ _onFirstActivate()
+ {
+ let gtkSettings = Gtk.Settings.get_default();
+ settings.bind(
+ 'dark-theme', gtkSettings,
+ 'gtk-application-prefer-dark-theme',
+ Gio.SettingsBindFlags.GET
+ );
+ this._onThemeChanged(gtkSettings);
+ gtkSettings.connect('notify::gtk-theme-name', this._onThemeChanged.bind(this));
+
+ this.windowShowSignal = this.active_window.connect(
+ 'show', this._onWindowShow.bind(this)
+ );
+
+ this.doneFirstActivate = true;
+ }
+
+ _onWindowShow(window)
+ {
+ window.disconnect(this.windowShowSignal);
+ this.windowShowSignal = null;
+ }
+
+ _onThemeChanged(gtkSettings)
+ {
+ const theme = gtkSettings.gtk_theme_name;
+ debug(`user selected theme: ${theme}`);
+
+ if(!theme.endsWith('-dark'))
+ return;
+
+ /* We need to request a default theme with optional dark variant
+ to make the "gtk_application_prefer_dark_theme" setting work */
+ const parsedTheme = theme.substring(0, theme.lastIndexOf('-'));
+
+ gtkSettings.gtk_theme_name = parsedTheme;
+ debug(`set theme: ${parsedTheme}`);
+ }
+});
diff --git a/clapper_src/appRemote.js b/clapper_src/appRemote.js
new file mode 100644
index 00000000..2bbc8877
--- /dev/null
+++ b/clapper_src/appRemote.js
@@ -0,0 +1,21 @@
+const { GObject } = imports.gi;
+const { AppBase } = imports.clapper_src.appBase;
+const { HeaderBarBase } = imports.clapper_src.headerbarBase;
+const { WidgetRemote } = imports.clapper_src.widgetRemote;
+
+var AppRemote = GObject.registerClass(
+class ClapperAppRemote extends AppBase
+{
+ vfunc_startup()
+ {
+ super.vfunc_startup();
+
+ let clapperWidget = new WidgetRemote();
+ this.active_window.set_child(clapperWidget);
+
+ let headerBar = new HeaderBarBase(this.active_window);
+ this.active_window.set_titlebar(headerBar);
+
+ this.active_window.maximize();
+ }
+});
diff --git a/clapper_src/dialogs.js b/clapper_src/dialogs.js
index 94d698b7..8e36ab9e 100644
--- a/clapper_src/dialogs.js
+++ b/clapper_src/dialogs.js
@@ -133,7 +133,7 @@ class ClapperUriDialog extends Gtk.Dialog
openUri(uri)
{
let { player } = this.get_transient_for().get_child();
- player.set_media(uri);
+ player.set_playlist([uri]);
this.close();
}
diff --git a/clapper_src/headerbar.js b/clapper_src/headerbar.js
index 9060b80b..c300b9ba 100644
--- a/clapper_src/headerbar.js
+++ b/clapper_src/headerbar.js
@@ -1,94 +1,17 @@
-const { GObject, Gtk, Pango } = imports.gi;
+const { GObject } = imports.gi;
+const { HeaderBarBase } = imports.clapper_src.headerbarBase;
var HeaderBar = GObject.registerClass(
-class ClapperHeaderBar extends Gtk.HeaderBar
+class ClapperHeaderBar extends HeaderBarBase
{
- _init(window, models)
+ _init(window)
{
- super._init({
- can_focus: false,
- });
- this.add_css_class('noborder');
+ super._init(window);
- this.set_title_widget(this._createWidgetForWindow(window));
let clapperWidget = window.get_child();
-
- let addMediaButton = new Gtk.MenuButton({
- icon_name: 'list-add-symbolic',
- });
- let addMediaPopover = new HeaderBarPopover(models.addMediaMenu);
- addMediaButton.set_popover(addMediaPopover);
- this.pack_start(addMediaButton);
-
- let openMenuButton = new Gtk.MenuButton({
- icon_name: 'open-menu-symbolic',
- });
- let settingsPopover = new HeaderBarPopover(models.settingsMenu);
- openMenuButton.set_popover(settingsPopover);
- this.pack_end(openMenuButton);
-
- let buttonsBox = new Gtk.Box({
- orientation: Gtk.Orientation.HORIZONTAL,
- });
- buttonsBox.add_css_class('linked');
-
- let floatButton = new Gtk.Button({
- icon_name: 'preferences-desktop-remote-desktop-symbolic',
- });
- floatButton.connect('clicked', this._onFloatButtonClicked.bind(this));
clapperWidget.controls.unfloatButton.bind_property('visible', this, 'visible',
GObject.BindingFlags.INVERT_BOOLEAN
);
- buttonsBox.append(floatButton);
-
- let fullscreenButton = new Gtk.Button({
- icon_name: 'view-fullscreen-symbolic',
- });
- fullscreenButton.connect('clicked', this._onFullscreenButtonClicked.bind(this));
-
- buttonsBox.append(fullscreenButton);
- this.pack_end(buttonsBox);
- }
-
- updateHeaderBar(title, subtitle)
- {
- this.titleLabel.label = title;
- this.subtitleLabel.visible = (subtitle !== null);
-
- if(subtitle)
- this.subtitleLabel.label = subtitle;
- }
-
- _createWidgetForWindow(window)
- {
- let box = new Gtk.Box ({
- orientation: Gtk.Orientation.VERTICAL,
- valign: Gtk.Align.CENTER,
- });
-
- this.titleLabel = new Gtk.Label({
- halign: Gtk.Align.CENTER,
- single_line_mode: true,
- ellipsize: Pango.EllipsizeMode.END,
- width_chars: 5,
- });
- this.titleLabel.add_css_class('title');
- this.titleLabel.set_parent(box);
-
- window.bind_property('title', this.titleLabel, 'label',
- GObject.BindingFlags.SYNC_CREATE
- );
-
- this.subtitleLabel = new Gtk.Label({
- halign: Gtk.Align.CENTER,
- single_line_mode: true,
- ellipsize: Pango.EllipsizeMode.END,
- });
- this.subtitleLabel.add_css_class('subtitle');
- this.subtitleLabel.set_parent(box);
- this.subtitleLabel.visible = false;
-
- return box;
}
_onFloatButtonClicked()
@@ -103,24 +26,3 @@ class ClapperHeaderBar extends Gtk.HeaderBar
window.fullscreen();
}
});
-
-var HeaderBarPopover = GObject.registerClass(
-class ClapperHeaderBarPopover extends Gtk.PopoverMenu
-{
- _init(model)
- {
- super._init({
- menu_model: model,
- });
-
- this.connect('closed', this._onClosed.bind(this));
- }
-
- _onClosed()
- {
- let root = this.get_root();
- let clapperWidget = root.get_child();
-
- clapperWidget.player.widget.grab_focus();
- }
-});
diff --git a/clapper_src/headerbarBase.js b/clapper_src/headerbarBase.js
new file mode 100644
index 00000000..6eed791c
--- /dev/null
+++ b/clapper_src/headerbarBase.js
@@ -0,0 +1,135 @@
+const { GObject, Gtk, Pango } = imports.gi;
+const Misc = imports.clapper_src.misc;
+
+var HeaderBarBase = GObject.registerClass(
+class ClapperHeaderBarBase extends Gtk.HeaderBar
+{
+ _init(window)
+ {
+ super._init({
+ can_focus: false,
+ });
+
+ let clapperPath = Misc.getClapperPath();
+ let uiBuilder = Gtk.Builder.new_from_file(
+ `${clapperPath}/ui/clapper.ui`
+ );
+ let models = {
+ addMediaMenu: uiBuilder.get_object('addMediaMenu'),
+ settingsMenu: uiBuilder.get_object('settingsMenu'),
+ };
+
+ this.add_css_class('noborder');
+ this.set_title_widget(this._createWidgetForWindow(window));
+
+ let addMediaButton = new Gtk.MenuButton({
+ icon_name: 'list-add-symbolic',
+ });
+ let addMediaPopover = new HeaderBarPopover(models.addMediaMenu);
+ addMediaButton.set_popover(addMediaPopover);
+ this.pack_start(addMediaButton);
+
+ let openMenuButton = new Gtk.MenuButton({
+ icon_name: 'open-menu-symbolic',
+ });
+ let settingsPopover = new HeaderBarPopover(models.settingsMenu);
+ openMenuButton.set_popover(settingsPopover);
+ this.pack_end(openMenuButton);
+
+ let buttonsBox = new Gtk.Box({
+ orientation: Gtk.Orientation.HORIZONTAL,
+ });
+ buttonsBox.add_css_class('linked');
+
+ let floatButton = new Gtk.Button({
+ icon_name: 'preferences-desktop-remote-desktop-symbolic',
+ });
+ floatButton.connect('clicked', this._onFloatButtonClicked.bind(this));
+ buttonsBox.append(floatButton);
+
+ let fullscreenButton = new Gtk.Button({
+ icon_name: 'view-fullscreen-symbolic',
+ });
+ fullscreenButton.connect('clicked', this._onFullscreenButtonClicked.bind(this));
+
+ buttonsBox.append(fullscreenButton);
+ this.pack_end(buttonsBox);
+ }
+
+ updateHeaderBar(title, subtitle)
+ {
+ this.titleLabel.label = title;
+ this.subtitleLabel.visible = (subtitle !== null);
+
+ if(subtitle)
+ this.subtitleLabel.label = subtitle;
+ }
+
+ _createWidgetForWindow(window)
+ {
+ let box = new Gtk.Box({
+ orientation: Gtk.Orientation.VERTICAL,
+ valign: Gtk.Align.CENTER,
+ });
+
+ this.titleLabel = new Gtk.Label({
+ halign: Gtk.Align.CENTER,
+ single_line_mode: true,
+ ellipsize: Pango.EllipsizeMode.END,
+ width_chars: 5,
+ });
+ this.titleLabel.add_css_class('title');
+ this.titleLabel.set_parent(box);
+
+ window.bind_property('title', this.titleLabel, 'label',
+ GObject.BindingFlags.SYNC_CREATE
+ );
+
+ this.subtitleLabel = new Gtk.Label({
+ halign: Gtk.Align.CENTER,
+ single_line_mode: true,
+ ellipsize: Pango.EllipsizeMode.END,
+ });
+ this.subtitleLabel.add_css_class('subtitle');
+ this.subtitleLabel.set_parent(box);
+ this.subtitleLabel.visible = false;
+
+ return box;
+ }
+
+ _onFloatButtonClicked()
+ {
+ }
+
+ _onFullscreenButtonClicked()
+ {
+ }
+});
+
+var HeaderBarPopover = GObject.registerClass(
+class ClapperHeaderBarPopover extends Gtk.PopoverMenu
+{
+ _init(model)
+ {
+ super._init({
+ menu_model: model,
+ });
+
+ this.connect('closed', this._onClosed.bind(this));
+ }
+
+ _onClosed()
+ {
+ let root = this.get_root();
+ let clapperWidget = root.get_child();
+
+ if(
+ !clapperWidget
+ || !clapperWidget.player
+ || !clapperWidget.player.widget
+ )
+ return;
+
+ clapperWidget.player.widget.grab_focus();
+ }
+});
diff --git a/main.js b/clapper_src/main.js
similarity index 100%
rename from main.js
rename to clapper_src/main.js
diff --git a/clapper_src/mainRemote.js b/clapper_src/mainRemote.js
new file mode 100644
index 00000000..6321f769
--- /dev/null
+++ b/clapper_src/mainRemote.js
@@ -0,0 +1,17 @@
+imports.gi.versions.Gdk = '4.0';
+imports.gi.versions.Gtk = '4.0';
+
+const { AppRemote } = imports.clapper_src.appRemote;
+const Misc = imports.clapper_src.misc;
+
+const opts = {
+ idPostfix: 'Remote',
+};
+
+Misc.clapperPath = pkg.datadir + '/' +
+ pkg.name.substring(0, pkg.name.lastIndexOf(opts.idPostfix));
+
+function main()
+{
+ new AppRemote(opts).run();
+}
diff --git a/clapper_src/misc.js b/clapper_src/misc.js
index f03cd9b7..ff61fd92 100644
--- a/clapper_src/misc.js
+++ b/clapper_src/misc.js
@@ -1,4 +1,4 @@
-const { Gio, GstAudio, GstPlayer, Gtk } = imports.gi;
+const { Gio, GstAudio, GstPlayer, Gdk, Gtk } = imports.gi;
const Debug = imports.clapper_src.debug;
var appName = 'Clapper';
@@ -35,6 +35,19 @@ function getClapperVersion()
: '';
}
+function loadCustomCss()
+{
+ const clapperPath = getClapperPath();
+ const cssProvider = new Gtk.CssProvider();
+
+ cssProvider.load_from_path(`${clapperPath}/css/styles.css`);
+ Gtk.StyleContext.add_provider_for_display(
+ Gdk.Display.get_default(),
+ cssProvider,
+ Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION
+ );
+}
+
function inhibitForState(state, window)
{
let isInhibited = false;
diff --git a/clapper_src/player.js b/clapper_src/player.js
index ac5e0035..5d8e9816 100644
--- a/clapper_src/player.js
+++ b/clapper_src/player.js
@@ -251,6 +251,21 @@ class ClapperPlayer extends PlayerBase
this[action]();
}
+ receiveWs(action, value)
+ {
+ switch(action) {
+ case 'toggle_play':
+ case 'play':
+ case 'pause':
+ case 'set_playlist':
+ this[action](value);
+ break;
+ default:
+ super.receiveWs(action, value);
+ break;
+ }
+ }
+
_setHideCursorTimeout()
{
this._clearTimeout('hideCursor');
@@ -337,6 +352,7 @@ class ClapperPlayer extends PlayerBase
_onStateChanged(player, state)
{
this.state = state;
+ this.emitWs('state_changed', state);
if(state !== GstPlayer.PlayerState.BUFFERING) {
let root = player.widget.get_root();
@@ -364,7 +380,9 @@ class ClapperPlayer extends PlayerBase
_onStreamEnded(player)
{
- debug('stream ended');
+ debug(`end of stream: ${this._trackId}`);
+ this.emitWs('end_of_stream', this._trackId);
+
this._trackId++;
if(this._trackId < this._playlist.length)
diff --git a/clapper_src/playerBase.js b/clapper_src/playerBase.js
index d703ff33..a02ed2e1 100644
--- a/clapper_src/playerBase.js
+++ b/clapper_src/playerBase.js
@@ -1,27 +1,13 @@
const { Gio, GLib, GObject, Gst, GstPlayer, Gtk } = imports.gi;
const Debug = imports.clapper_src.debug;
const Misc = imports.clapper_src.misc;
-
-/* PlayFlags are not exported through GI */
-Gst.PlayFlags = {
- VIDEO: 1,
- AUDIO: 2,
- TEXT: 4,
- VIS: 8,
- SOFT_VOLUME: 16,
- NATIVE_AUDIO: 32,
- NATIVE_VIDEO: 64,
- DOWNLOAD: 128,
- BUFFERING: 256,
- DEINTERLACE: 512,
- SOFT_COLORBALANCE: 1024,
- FORCE_FILTERS: 2048,
- FORCE_SW_DECODERS: 4096,
-};
+const { WebApp } = imports.clapper_src.webApp;
let { debug } = Debug;
let { settings } = Misc;
+let WebServer;
+
var PlayerBase = GObject.registerClass(
class ClapperPlayerBase extends GstPlayer.Player
{
@@ -66,6 +52,9 @@ class ClapperPlayerBase extends GstPlayer.Player
this.state = GstPlayer.PlayerState.STOPPED;
this.visualization_enabled = false;
+ this.webserver = null;
+ this.webapp = null;
+
this.set_all_plugins_ranks();
this.set_initial_config();
this.set_and_bind_settings();
@@ -89,6 +78,7 @@ class ClapperPlayerBase extends GstPlayer.Player
'audio-offset',
'subtitle-offset',
'play-flags',
+ 'webserver-enabled'
];
for(let key of settingsToSet)
@@ -175,6 +165,19 @@ class ClapperPlayerBase extends GstPlayer.Player
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;
@@ -267,6 +270,48 @@ class ClapperPlayerBase extends GstPlayer.Player
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.clapper_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.startRemoteApp();
+ }
+ else
+ this.webapp.stopRemoteApp();
+ }
+ else if(this.webserver) {
+ /* remote app will close too when connection is lost */
+ this.webserver.stopListening();
+ }
+ break;
+ case 'webserver-port':
+ if(!this.webserver)
+ break;
+
+ this.webserver.setListeningPort(settings.get_int(key));
+ break;
default:
break;
}
diff --git a/clapper_src/playerRemote.js b/clapper_src/playerRemote.js
new file mode 100644
index 00000000..166dd18c
--- /dev/null
+++ b/clapper_src/playerRemote.js
@@ -0,0 +1,21 @@
+const { GObject } = imports.gi;
+const { WebClient } = imports.clapper_src.webClient;
+
+var PlayerRemote = GObject.registerClass(
+class ClapperPlayerRemote extends GObject.Object
+{
+ _init()
+ {
+ super._init();
+
+ this.webclient = new WebClient();
+ }
+
+ set_playlist(playlist)
+ {
+ this.webclient.sendMessage({
+ action: 'set_playlist',
+ value: playlist
+ });
+ }
+});
diff --git a/clapper_src/prefs.js b/clapper_src/prefs.js
index 43e4467b..5f1e09ec 100644
--- a/clapper_src/prefs.js
+++ b/clapper_src/prefs.js
@@ -4,6 +4,23 @@ const PrefsBase = imports.clapper_src.prefsBase;
let { settings } = Misc;
+/* PlayFlags are not exported through GI */
+Gst.PlayFlags = {
+ VIDEO: 1,
+ AUDIO: 2,
+ TEXT: 4,
+ VIS: 8,
+ SOFT_VOLUME: 16,
+ NATIVE_AUDIO: 32,
+ NATIVE_VIDEO: 64,
+ DOWNLOAD: 128,
+ BUFFERING: 256,
+ DEINTERLACE: 512,
+ SOFT_COLORBALANCE: 1024,
+ FORCE_FILTERS: 2048,
+ FORCE_SW_DECODERS: 4096,
+};
+
var GeneralPage = GObject.registerClass(
class ClapperGeneralPage extends PrefsBase.Grid
{
@@ -97,6 +114,13 @@ class ClapperNetworkPage extends PrefsBase.Grid
this.addTitle('Client');
this.addPlayFlagCheckButton('Progressive download buffering', Gst.PlayFlags.DOWNLOAD);
+
+ this.addTitle('Server');
+ let webServer = this.addCheckButton('Control player remotely', 'webserver-enabled');
+ let serverPort = this.addSpinButton('Listening port', 1024, 65535, 'webserver-port');
+ webServer.bind_property('active', serverPort, 'visible', GObject.BindingFlags.SYNC_CREATE);
+ //let webApp = this.addCheckButton('Run built-in web application', 'webapp-enabled');
+ //webServer.bind_property('active', webApp, 'visible', GObject.BindingFlags.SYNC_CREATE);
}
});
diff --git a/clapper_src/webApp.js b/clapper_src/webApp.js
new file mode 100644
index 00000000..e5bd1168
--- /dev/null
+++ b/clapper_src/webApp.js
@@ -0,0 +1,64 @@
+const { Gio, GObject } = imports.gi;
+const Debug = imports.clapper_src.debug;
+const Misc = imports.clapper_src.misc;
+
+let { debug } = Debug;
+
+var WebApp = GObject.registerClass(
+class ClapperWebApp extends Gio.SubprocessLauncher
+{
+ _init()
+ {
+ const flags = Gio.SubprocessFlags.STDOUT_SILENCE
+ | Gio.SubprocessFlags.STDERR_SILENCE;
+
+ super._init({ flags });
+
+ this.remoteApp = null;
+ this.isRemoteClosing = false;
+
+ this.setenv('GDK_BACKEND', 'broadway', true);
+ }
+
+ startRemoteApp()
+ {
+ if(this.remoteApp)
+ return;
+
+ this.remoteApp = this.spawnv([Misc.appId + 'Remote']);
+ this.remoteApp.wait_async(null, this._onRemoteClosed.bind(this));
+
+ debug('remote app started');
+ }
+
+ stopRemoteApp()
+ {
+ if(!this.remoteApp || this.isRemoteClosing)
+ return;
+
+ this.isRemoteClosing = true;
+ this.remoteApp.force_exit();
+
+ debug('send stop signal to remote app');
+ }
+
+ _onRemoteClosed(proc, result)
+ {
+ let hadError;
+
+ try {
+ hadError = proc.wait_finish(result);
+ }
+ catch(err) {
+ debug(err);
+ }
+
+ this.remoteApp = null;
+ this.isRemoteClosing = false;
+
+ if(hadError)
+ debug('remote app exited with error');
+
+ debug('remote app closed');
+ }
+});
diff --git a/clapper_src/webClient.js b/clapper_src/webClient.js
new file mode 100644
index 00000000..168db203
--- /dev/null
+++ b/clapper_src/webClient.js
@@ -0,0 +1,88 @@
+const { Gio, GObject, Soup } = imports.gi;
+const Debug = imports.clapper_src.debug;
+const Misc = imports.clapper_src.misc;
+const WebHelpers = imports.clapper_src.webHelpers;
+
+let { debug } = Debug;
+let { settings } = Misc;
+
+var WebClient = GObject.registerClass(
+class ClapperWebClient extends Soup.Session
+{
+ _init(port)
+ {
+ super._init({
+ timeout: 3,
+ use_thread_context: true,
+ });
+
+ this.wsConn = null;
+
+ this.connectWebsocket();
+ }
+
+ connectWebsocket()
+ {
+ if(this.wsConn)
+ return;
+
+ const port = settings.get_int('webserver-port');
+ const message = Soup.Message.new('GET', `ws://127.0.0.1:${port}/websocket`);
+ this.websocket_connect_async(message, null, null, null, this._onWsConnect.bind(this));
+
+ debug('connecting WebSocket to Clapper app');
+ }
+
+ sendMessage(data)
+ {
+ if(
+ !this.wsConn
+ || this.wsConn.state !== Soup.WebsocketState.OPEN
+ )
+ return;
+
+ this.wsConn.send_text(JSON.stringify(data));
+ }
+
+ passMsgData(action, value)
+ {
+ }
+
+ _onWsConnect(session, result)
+ {
+ let connection = null;
+
+ try {
+ connection = this.websocket_connect_finish(result);
+ }
+ catch(err) {
+ debug(err);
+ }
+
+ if(!connection)
+ return this.passMsgData('close');
+
+ connection.connect('message', this._onWsMessage.bind(this));
+ connection.connect('closed', this._onWsClosed.bind(this));
+
+ this.wsConn = connection;
+
+ debug('successfully connected WebSocket');
+ }
+
+ _onWsMessage(connection, dataType, bytes)
+ {
+ const [success, parsedMsg] = WebHelpers.parseData(dataType, bytes);
+
+ if(success)
+ this.passMsgData(parsedMsg.action, parsedMsg.value);
+ }
+
+ _onWsClosed(connection)
+ {
+ debug('closed WebSocket connection');
+ this.wsConn = null;
+
+ this.passMsgData('close');
+ }
+});
diff --git a/clapper_src/webHelpers.js b/clapper_src/webHelpers.js
new file mode 100644
index 00000000..571e6726
--- /dev/null
+++ b/clapper_src/webHelpers.js
@@ -0,0 +1,30 @@
+const { Soup } = imports.gi;
+const ByteArray = imports.byteArray;
+const Debug = imports.clapper_src.debug;
+
+let { debug } = Debug;
+
+function parseData(dataType, bytes)
+{
+ if(dataType !== Soup.WebsocketDataType.TEXT) {
+ debug('ignoring non-text WebSocket message');
+ return [false];
+ }
+
+ let parsedMsg = null;
+ const msg = bytes.get_data();
+
+ try {
+ parsedMsg = JSON.parse(ByteArray.toString(msg));
+ }
+ catch(err) {
+ debug(err);
+ }
+
+ if(!parsedMsg || !parsedMsg.action) {
+ debug('no "action" in parsed WebSocket message');
+ return [false];
+ }
+
+ return [true, parsedMsg];
+}
diff --git a/clapper_src/webServer.js b/clapper_src/webServer.js
new file mode 100644
index 00000000..8e30f408
--- /dev/null
+++ b/clapper_src/webServer.js
@@ -0,0 +1,139 @@
+const { Soup, GObject } = imports.gi;
+const Debug = imports.clapper_src.debug;
+const WebHelpers = imports.clapper_src.webHelpers;
+
+let { debug } = Debug;
+
+var WebServer = GObject.registerClass(
+class ClapperWebServer extends Soup.Server
+{
+ _init(port)
+ {
+ super._init();
+
+ this.isListening = false;
+ this.listeningPort = null;
+ this.wsConns = [];
+
+ if(port)
+ this.setListeningPort(port);
+ }
+
+ setListeningPort(port)
+ {
+ if(!port)
+ return;
+
+ const wasListening = this.isListening;
+
+ if(wasListening)
+ this.stopListening();
+
+ this.listeningPort = port;
+
+ if(wasListening)
+ this.startListening();
+ }
+
+ startListening()
+ {
+ if(this.isListening || !this.listeningPort)
+ return;
+
+ let isListening = false;
+
+ this.add_handler('/', this._onDefaultAccess.bind(this));
+ this.add_websocket_handler('/websocket', null, null, this._onWsConnection.bind(this));
+
+ try {
+ isListening = this.listen_local(this.listeningPort, Soup.ServerListenOptions.IPV4_ONLY);
+ }
+ catch(err) {
+ debug(err);
+ }
+
+ if(isListening) {
+ const uris = this.get_uris();
+ const usedPort = uris[0].get_port();
+ debug(`WebSocket server started listening on port: ${usedPort}`);
+ }
+ else {
+ debug(new Error('WebSocket server could not start listening'));
+ this._closeCleanup();
+ }
+
+ this.isListening = isListening;
+ }
+
+ stopListening()
+ {
+ if(!this.isListening)
+ return;
+
+ this._closeCleanup();
+ this.disconnect();
+
+ this.isListening = false;
+ }
+
+ sendMessage(data)
+ {
+ for(const connection of this.wsConns) {
+ if(connection.state !== Soup.WebsocketState.OPEN)
+ continue;
+
+ connection.send_text(JSON.stringify(data));
+ }
+ }
+
+ passMsgData(action, value)
+ {
+ }
+
+ _closeCleanup()
+ {
+ while(this.wsConns.length) {
+ const connection = this.wsConns.pop();
+
+ if(connection.state !== Soup.WebsocketState.OPEN)
+ continue;
+
+ connection.close(Soup.WebsocketCloseCode.NORMAL, null);
+ }
+
+ this.remove_handler('/websocket');
+ this.remove_handler('/');
+ }
+
+ _onWsConnection(server, connection)
+ {
+ debug('new WebSocket connection');
+
+ connection.connect('message', this._onWsMessage.bind(this));
+ connection.connect('closed', this._onWsClosed.bind(this));
+
+ this.wsConns.push(connection);
+ debug(`total WebSocket connections: ${this.wsConns.length}`);
+ }
+
+ _onWsMessage(connection, dataType, bytes)
+ {
+ const [success, parsedMsg] = WebHelpers.parseData(dataType, bytes);
+
+ if(success)
+ this.passMsgData(parsedMsg.action, parsedMsg.value);
+ }
+
+ _onWsClosed(connection)
+ {
+ debug('closed WebSocket connection');
+
+ this.wsConns = this.wsConns.filter(conn => conn !== connection);
+ debug(`remaining WebSocket connections: ${this.wsConns.length}`);
+ }
+
+ _onDefaultAccess(server, msg)
+ {
+ msg.status_code = 404;
+ }
+});
diff --git a/clapper_src/widget.js b/clapper_src/widget.js
index 664693dd..76ae51a1 100644
--- a/clapper_src/widget.js
+++ b/clapper_src/widget.js
@@ -16,25 +16,15 @@ var Widget = GObject.registerClass({
}
}, class ClapperWidget extends Gtk.Grid
{
- _init(opts)
+ _init()
{
Debug.gstVersionCheck();
super._init();
- let clapperPath = Misc.getClapperPath();
- let defaults = {
- cssPath: `${clapperPath}/css/styles.css`,
- };
- Object.assign(this, defaults, opts);
-
- let cssProvider = new Gtk.CssProvider();
- cssProvider.load_from_path(this.cssPath);
- Gtk.StyleContext.add_provider_for_display(
- Gdk.Display.get_default(),
- cssProvider,
- Gtk.STYLE_PROVIDER_PRIORITY_APPLICATION
- );
+ /* load CSS here to allow using this class
+ * separately as a pre-made GTK widget */
+ Misc.loadCustomCss();
this.windowSize = JSON.parse(settings.get_string('window-size'));
this.floatSize = JSON.parse(settings.get_string('float-size'));
diff --git a/clapper_src/widgetRemote.js b/clapper_src/widgetRemote.js
new file mode 100644
index 00000000..9cf6c107
--- /dev/null
+++ b/clapper_src/widgetRemote.js
@@ -0,0 +1,29 @@
+const { GObject, Gtk } = imports.gi;
+const Misc = imports.clapper_src.misc;
+const { PlayerRemote } = imports.clapper_src.playerRemote;
+
+var WidgetRemote = GObject.registerClass(
+class ClapperWidgetRemote extends Gtk.Grid
+{
+ _init(opts)
+ {
+ super._init();
+
+ Misc.loadCustomCss();
+
+ this.player = new PlayerRemote();
+ this.player.webclient.passMsgData = this.receiveWs.bind(this);
+ }
+
+ receiveWs(action, value)
+ {
+ switch(action) {
+ case 'close':
+ let root = this.get_root();
+ root.run_dispose();
+ break;
+ default:
+ break;
+ }
+ }
+});
diff --git a/data/com.github.rafostar.Clapper.gschema.xml b/data/com.github.rafostar.Clapper.gschema.xml
index c18218b1..24a346db 100644
--- a/data/com.github.rafostar.Clapper.gschema.xml
+++ b/data/com.github.rafostar.Clapper.gschema.xml
@@ -49,6 +49,20 @@
The subtitles font description
+
+
+ false
+ Enable WebSocket server for remote playback control
+
+
+ 6446
+ Listening port to use for incoming WebSocket connections
+
+
+ false
+ Run built-in broadway based web application
+
+
true
diff --git a/meson.build b/meson.build
index beee3093..52327a32 100644
--- a/meson.build
+++ b/meson.build
@@ -23,6 +23,5 @@ installdir = join_paths(get_option('prefix'), 'share', meson.project_name())
install_subdir('clapper_src', install_dir : installdir)
install_subdir('css', install_dir : installdir)
install_subdir('ui', install_dir : installdir)
-install_data('main.js', install_dir : installdir)
meson.add_install_script('build-aux/meson/postinstall.py')
diff --git a/pkgs/flatpak/lib/gtk4.json b/pkgs/flatpak/lib/gtk4.json
index 5f2a38eb..4a8da02a 100644
--- a/pkgs/flatpak/lib/gtk4.json
+++ b/pkgs/flatpak/lib/gtk4.json
@@ -3,6 +3,7 @@
"buildsystem": "meson",
"config-opts": [
"--wrap-mode=nofallback",
+ "-Dbroadway-backend=true",
"-Dwin32-backend=false",
"-Dmacos-backend=false",
"-Dmedia-ffmpeg=disabled",
@@ -10,7 +11,8 @@
"-Dprint-cloudprint=disabled",
"-Dintrospection=enabled",
"-Ddemos=false",
- "-Dbuild-examples=false"
+ "-Dbuild-examples=false",
+ "-Dbuild-tests=false"
],
"sources": [
{