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)); } });