Files
clapper/src/widget.js
2021-02-17 12:20:44 +01:00

566 lines
18 KiB
JavaScript

const { Gdk, GLib, GObject, GstClapper, Gtk } = imports.gi;
const { Controls } = imports.src.controls;
const Debug = imports.src.debug;
const Dialogs = imports.src.dialogs;
const Misc = imports.src.misc;
const { Player } = imports.src.player;
const Revealers = imports.src.revealers;
const { debug } = Debug;
const { settings } = Misc;
var Widget = GObject.registerClass(
class ClapperWidget extends Gtk.Grid
{
_init()
{
super._init();
/* 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.layoutWidth = 0;
this.fullscreenMode = false;
this.isSeekable = false;
this.isMobileMonitor = false;
this.needsTracksUpdate = true;
this.overlay = new Gtk.Overlay();
this.revealerTop = new Revealers.RevealerTop();
this.revealerBottom = new Revealers.RevealerBottom();
this.controls = new Controls();
this.controlsBox = new Gtk.Box({
orientation: Gtk.Orientation.HORIZONTAL,
});
this.controlsBox.add_css_class('controlsbox');
this.controlsBox.append(this.controls);
this.controlsRevealer = new Revealers.ControlsRevealer();
this.controlsRevealer.set_child(this.controlsBox);
this.attach(this.overlay, 0, 0, 1, 1);
this.attach(this.controlsRevealer, 0, 1, 1, 1);
this.mapSignal = this.connect('map', this._onMap.bind(this));
this.player = new Player();
this.controls.elapsedButton.scrolledWindow.set_child(this.player.playlistWidget);
this.controls.speedAdjustment.bind_property(
'value', this.player, 'rate', GObject.BindingFlags.BIDIRECTIONAL
);
this.player.connect('position-updated', this._onPlayerPositionUpdated.bind(this));
this.player.connect('duration-changed', this._onPlayerDurationChanged.bind(this));
/* FIXME: re-enable once ported to new GstPlayer API with messages bus */
//this.player.connect('volume-changed', this._onPlayerVolumeChanged.bind(this));
this.overlay.set_child(this.player.widget);
this.overlay.add_overlay(this.revealerTop);
this.overlay.add_overlay(this.revealerBottom);
const motionController = new Gtk.EventControllerMotion();
motionController.connect('leave', this._onLeave.bind(this));
this.add_controller(motionController);
const topClickGesture = new Gtk.GestureClick();
topClickGesture.set_button(0);
topClickGesture.connect('pressed', this.player._onWidgetPressed.bind(this.player));
this.revealerTop.add_controller(topClickGesture);
const topMotionController = new Gtk.EventControllerMotion();
topMotionController.connect('motion', this.player._onWidgetMotion.bind(this.player));
this.revealerTop.add_controller(topMotionController);
const topScrollController = new Gtk.EventControllerScroll();
topScrollController.set_flags(Gtk.EventControllerScrollFlags.BOTH_AXES);
topScrollController.connect('scroll', this.player._onScroll.bind(this.player));
this.revealerTop.add_controller(topScrollController);
}
revealControls(isReveal)
{
for(let pos of ['Top', 'Bottom'])
this[`revealer${pos}`].revealChild(isReveal);
}
showControls(isShow)
{
for(let pos of ['Top', 'Bottom'])
this[`revealer${pos}`].showChild(isShow);
}
toggleFullscreen()
{
const root = this.get_root();
if(!root) return;
const un = (this.fullscreenMode) ? 'un' : '';
root[`${un}fullscreen`]();
}
setFullscreenMode(isFullscreen)
{
if(this.fullscreenMode === isFullscreen)
return;
debug('changing fullscreen mode');
this.fullscreenMode = isFullscreen;
const root = this.get_root();
const action = (isFullscreen) ? 'add' : 'remove';
root[action + '_css_class']('gpufriendlyfs');
if(!this.isMobileMonitor)
root[action + '_css_class']('tvmode');
this._changeControlsPlacement(isFullscreen);
this.controls.setFullscreenMode(isFullscreen);
this.showControls(isFullscreen);
this.revealerTop.headerBar.visible = !isFullscreen;
this.revealerTop.revealerGrid.visible = (isFullscreen && !this.isMobileMonitor);
this.player.widget.grab_focus();
if(this.player.playOnFullscreen && isFullscreen) {
this.player.playOnFullscreen = false;
this.player.play();
}
debug(`interface in fullscreen mode: ${isFullscreen}`);
}
_changeControlsPlacement(isOnTop)
{
if(isOnTop) {
this.controlsBox.remove(this.controls);
this.revealerBottom.append(this.controls);
}
else {
this.revealerBottom.remove(this.controls);
this.controlsBox.append(this.controls);
}
this.controlsBox.set_visible(!isOnTop);
}
_updateMediaInfo()
{
const mediaInfo = this.player.get_media_info();
if(!mediaInfo)
return GLib.SOURCE_REMOVE;
/* Set titlebar media title */
this.updateTitle(mediaInfo);
/* Show/hide position scale on LIVE */
const isLive = mediaInfo.is_live();
this.isSeekable = mediaInfo.is_seekable();
this.controls.setLiveMode(isLive, this.isSeekable);
if(this.player.needsTocUpdate) {
/* FIXME: Remove `get_toc` check after required GstPlay(er) ver bump */
if(!isLive && mediaInfo.get_toc)
this.updateChapters(mediaInfo.get_toc());
this.player.needsTocUpdate = false;
}
const streamList = mediaInfo.get_stream_list();
const parsedInfo = {
videoTracks: [],
audioTracks: [],
subtitleTracks: []
};
for(let info of streamList) {
let type, text, codec;
switch(info.constructor) {
case GstClapper.ClapperVideoInfo:
type = 'video';
codec = info.get_codec() || 'Undetermined';
text = codec + ', ' +
+ info.get_width() + 'x'
+ info.get_height();
let fps = info.get_framerate();
fps = Number((fps[0] / fps[1]).toFixed(2));
if(fps)
text += `@${fps}`;
break;
case GstClapper.ClapperAudioInfo:
type = 'audio';
codec = info.get_codec() || 'Undetermined';
if(codec.includes('(')) {
codec = codec.substring(
codec.indexOf('(') + 1,
codec.indexOf(')')
);
}
text = info.get_language() || 'Undetermined';
text += ', ' + codec + ', '
+ info.get_channels() + ' Channels';
break;
case GstClapper.ClapperSubtitleInfo:
type = 'subtitle';
text = info.get_language() || 'Undetermined';
break;
default:
debug(`unrecognized media info type: ${info.constructor}`);
break;
}
const tracksArr = parsedInfo[`${type}Tracks`];
if(!tracksArr.length)
{
tracksArr[0] = {
label: 'Disabled',
type: type,
activeId: -1
};
}
tracksArr.push({
label: text,
type: type,
activeId: info.get_index(),
});
}
let anyButtonShown = false;
for(let type of ['video', 'audio', 'subtitle']) {
const currStream = this.player[`get_current_${type}_track`]();
const activeId = (currStream) ? currStream.get_index() : -1;
if(currStream && type !== 'subtitle') {
const caps = currStream.get_caps();
debug(`${type} caps: ${caps.to_string()}`, 'LEVEL_INFO');
}
if(type === 'video') {
const isShowVis = (parsedInfo[`${type}Tracks`].length === 0);
this.showVisualizationsButton(isShowVis);
}
if(!parsedInfo[`${type}Tracks`].length) {
debug(`hiding popover button without contents: ${type}`);
this.controls[`${type}TracksButton`].set_visible(false);
continue;
}
this.controls.addCheckButtons(
this.controls[`${type}TracksButton`].popoverBox,
parsedInfo[`${type}Tracks`],
activeId
);
debug(`showing popover button with contents: ${type}`);
this.controls[`${type}TracksButton`].set_visible(true);
anyButtonShown = true;
}
this.controls.revealTracksRevealer.set_visible(anyButtonShown);
return GLib.SOURCE_REMOVE;
}
updateTitle(mediaInfo)
{
let title = mediaInfo.get_title();
if(!title) {
const subtitle = this.player.playlistWidget.getActiveFilename();
title = (subtitle.includes('.'))
? subtitle.split('.').slice(0, -1).join('.')
: subtitle;
}
this.root.title = title;
this.revealerTop.setMediaTitle(title);
}
updateTime()
{
if(!this.revealerTop.visible)
return null;
const currTime = GLib.DateTime.new_now_local();
const endTime = currTime.add_seconds(
this.controls.positionAdjustment.get_upper() - this.controls.currentPosition
);
const nextUpdate = this.revealerTop.setTimes(currTime, endTime);
return nextUpdate;
}
updateChapters(toc)
{
if(!toc) return;
const entries = toc.get_entries();
if(!entries) return;
for(let entry of entries) {
const subentries = entry.get_sub_entries();
if(!subentries) continue;
for(let subentry of subentries)
this._parseTocSubentry(subentry);
}
}
_parseTocSubentry(subentry)
{
const [success, start, stop] = subentry.get_start_stop_times();
if(!success) {
debug('could not obtain toc subentry start/stop times');
return;
}
const pos = Math.floor(start / 1000000) / 1000;
const tags = subentry.get_tags();
this.controls.positionScale.add_mark(pos, Gtk.PositionType.TOP, null);
this.controls.positionScale.add_mark(pos, Gtk.PositionType.BOTTOM, null);
if(!tags) {
debug('could not obtain toc subentry tags');
return;
}
const [isString, title] = tags.get_string('title');
if(!isString) {
debug('toc subentry tag does not have a title');
return;
}
if(!this.controls.chapters)
this.controls.chapters = {};
this.controls.chapters[pos] = title;
debug(`chapter at ${pos}: ${title}`);
}
showVisualizationsButton(isShow)
{
if(isShow && !this.controls.visualizationsButton.isVisList) {
debug('creating visualizations list');
const visArr = GstClapper.Clapper.visualizations_get();
if(!visArr.length)
return;
const parsedVisArr = [{
label: 'Disabled',
type: 'visualization',
activeId: null
}];
visArr.forEach(vis => {
parsedVisArr.push({
label: vis.name[0].toUpperCase() + vis.name.substring(1),
type: 'visualization',
activeId: vis.name,
});
});
this.controls.addCheckButtons(
this.controls.visualizationsButton.popoverBox,
parsedVisArr,
null
);
this.controls.visualizationsButton.isVisList = true;
debug(`total visualizations: ${visArr.length}`);
}
if(this.controls.visualizationsButton.visible === isShow)
return;
const action = (isShow) ? 'show' : 'hide';
this.controls.visualizationsButton[action]();
debug(`show visualizations button: ${isShow}`);
}
_onPlayerStateChanged(player, state)
{
switch(state) {
case GstClapper.ClapperState.BUFFERING:
debug('player state changed to: BUFFERING');
if(player.needsTocUpdate) {
this.controls._setChapterVisible(false);
this.controls.positionScale.clear_marks();
this.controls.chapters = null;
}
if(!player.playlistWidget.getActiveIsLocalFile()) {
this.needsTracksUpdate = true;
}
break;
case GstClapper.ClapperState.STOPPED:
debug('player state changed to: STOPPED');
this.controls.currentPosition = 0;
this.controls.positionScale.set_value(0);
this.controls.togglePlayButton.setPrimaryIcon();
this.needsTracksUpdate = true;
break;
case GstClapper.ClapperState.PAUSED:
debug('player state changed to: PAUSED');
this.controls.togglePlayButton.setPrimaryIcon();
break;
case GstClapper.ClapperState.PLAYING:
debug('player state changed to: PLAYING');
this.controls.togglePlayButton.setSecondaryIcon();
if(this.needsTracksUpdate) {
this.needsTracksUpdate = false;
GLib.idle_add(
GLib.PRIORITY_DEFAULT_IDLE,
this._updateMediaInfo.bind(this)
);
}
break;
default:
break;
}
const isNotStopped = (state !== GstClapper.ClapperState.STOPPED);
this.revealerTop.endTime.set_visible(isNotStopped);
}
_onPlayerDurationChanged(player, duration)
{
const durationSeconds = duration / 1000000000;
const durationFloor = Math.floor(durationSeconds);
/* Sometimes GstPlayer might re-emit
* duration changed during playback */
if(this.controls.currentDuration === durationFloor)
return;
this.controls.currentDuration = durationFloor;
this.controls.showHours = (durationFloor >= 3600);
this.controls.positionAdjustment.set_upper(durationFloor);
this.controls.durationFormatted = Misc.getFormattedTime(durationFloor);
this.controls.updateElapsedLabel();
if(settings.get_boolean('resume-enabled')) {
const resumeDatabase = JSON.parse(settings.get_string('resume-database'));
const title = player.playlistWidget.getActiveFilename();
debug(`searching database for resume info: ${title}`);
const resumeInfo = resumeDatabase.find(info => {
return (info.title === title && info.duration === durationSeconds);
});
if(resumeInfo) {
debug('found resume info: ' + JSON.stringify(resumeInfo));
new Dialogs.ResumeDialog(this.root, resumeInfo);
const shrunkDatabase = resumeDatabase.filter(info => {
return !(info.title === title && info.duration === durationSeconds);
});
settings.set_string('resume-database', JSON.stringify(shrunkDatabase));
}
else
debug('resume info not found');
}
}
_onPlayerPositionUpdated(player, position)
{
if(
!this.isSeekable
|| this.controls.isPositionDragging
|| !player.seek_done
)
return;
const positionSeconds = Math.round(position / 1000000000);
if(positionSeconds === this.controls.currentPosition)
return;
this.controls.positionScale.set_value(positionSeconds);
}
_onPlayerVolumeChanged(player)
{
const volume = player.get_volume();
/* FIXME: This check should not be needed, GstPlayer should not
* emit 'volume-changed' with the same values, but it does. */
if(volume === this.controls.currentVolume)
return;
/* Once above is fixed in GstPlayer, remove this var too */
this.controls.currentVolume = volume;
const cubicVolume = Misc.getCubicValue(volume);
this.controls._updateVolumeButtonIcon(cubicVolume);
}
_onStateNotify(toplevel)
{
const isMaximized = Boolean(
toplevel.state & Gdk.ToplevelState.MAXIMIZED
);
const isFullscreen = Boolean(
toplevel.state & Gdk.ToplevelState.FULLSCREEN
);
const headerBar = this.revealerTop.headerBar;
headerBar.setMaximized(isMaximized);
this.setFullscreenMode(isFullscreen);
}
_onLayoutUpdate(surface, width, height)
{
if(width === this.layoutWidth)
return;
this.layoutWidth = width;
this.controls._onPlayerResize(width, height);
}
_onLeave(controller)
{
if(
this.fullscreenMode
|| this.player.isWidgetDragging
)
return;
this.revealerBottom.revealChild(false);
}
_onMap()
{
this.disconnect(this.mapSignal);
const root = this.get_root();
const surface = root.get_surface();
const monitor = root.display.get_monitor_at_surface(surface);
const geometry = monitor.geometry;
const size = this.windowSize;
debug(`monitor application-pixels: ${geometry.width}x${geometry.height}`);
if(geometry.width >= size[0] && geometry.height >= size[1]) {
root.set_default_size(size[0], size[1]);
debug(`restored window size: ${size[0]}x${size[1]}`);
}
const monitorWidth = Math.max(geometry.width, geometry.height);
if(monitorWidth < 1280) {
this.isMobileMonitor = true;
debug('mobile monitor detected');
}
surface.connect('notify::state', this._onStateNotify.bind(this));
surface.connect('layout', this._onLayoutUpdate.bind(this));
}
});