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": [ {