const { GLib, GObject, Gdk, Gtk } = imports.gi; const Buttons = imports.clapper_src.buttons; const Debug = imports.clapper_src.debug; const Misc = imports.clapper_src.misc; const Revealers = imports.clapper_src.revealers; const CONTROLS_MARGIN = 2; const CONTROLS_SPACING = 0; const { debug } = Debug; const { settings } = Misc; var Controls = GObject.registerClass( class ClapperControls extends Gtk.Box { _init() { super._init({ orientation: Gtk.Orientation.HORIZONTAL, margin_start: CONTROLS_MARGIN, margin_end: CONTROLS_MARGIN, spacing: CONTROLS_SPACING, valign: Gtk.Align.END, can_focus: false, }); this.currentVolume = 0; this.currentPosition = 0; this.currentDuration = 0; this.isPositionDragging = false; this.isMobile = false; this.showHours = false; this.durationFormatted = '00:00'; this.buttonsArr = []; this.revealersArr = []; this.chapters = null; this.chapterShowId = null; this.chapterHideId = null; this._addTogglePlayButton(); const elapsedRevealer = new Revealers.ButtonsRevealer('SLIDE_RIGHT'); this.elapsedButton = this.addLabelButton('00:00/00:00', elapsedRevealer); elapsedRevealer.set_reveal_child(true); this.revealersArr.push(elapsedRevealer); this.append(elapsedRevealer); this._addPositionScale(); const revealTracksButton = new Buttons.IconToggleButton( 'go-previous-symbolic', 'go-next-symbolic' ); revealTracksButton.floatUnaffected = false; revealTracksButton.add_css_class('narrowbutton'); this.buttonsArr.push(revealTracksButton); const tracksRevealer = new Revealers.ButtonsRevealer( 'SLIDE_LEFT', revealTracksButton ); this.visualizationsButton = this.addPopoverButton( 'display-projector-symbolic', tracksRevealer ); this.visualizationsButton.set_visible(false); this.videoTracksButton = this.addPopoverButton( 'emblem-videos-symbolic', tracksRevealer ); this.videoTracksButton.set_visible(false); this.audioTracksButton = this.addPopoverButton( 'emblem-music-symbolic', tracksRevealer ); this.audioTracksButton.set_visible(false); this.subtitleTracksButton = this.addPopoverButton( 'media-view-subtitles-symbolic', tracksRevealer ); this.subtitleTracksButton.set_visible(false); this.revealTracksRevealer = new Revealers.ButtonsRevealer('SLIDE_LEFT'); this.revealTracksRevealer.append(revealTracksButton); this.revealTracksRevealer.set_visible(false); this.append(this.revealTracksRevealer); tracksRevealer.set_reveal_child(true); this.revealersArr.push(tracksRevealer); this.append(tracksRevealer); this._addVolumeButton(); this.unfullscreenButton = this.addButton( 'view-restore-symbolic' ); this.unfullscreenButton.connect('clicked', this._onUnfullscreenClicked.bind(this)); this.unfullscreenButton.set_visible(false); this.unfloatButton = this.addButton( 'preferences-desktop-remote-desktop-symbolic' ); this.unfloatButton.connect('clicked', this._onUnfloatClicked.bind(this)); this.unfloatButton.set_visible(false); const keyController = new Gtk.EventControllerKey(); keyController.connect('key-pressed', this._onControlsKeyPressed.bind(this)); keyController.connect('key-released', this._onControlsKeyReleased.bind(this)); this.add_controller(keyController); this.add_css_class('playercontrols'); this.realizeSignal = this.connect('realize', this._onRealize.bind(this)); } setFullscreenMode(isFullscreen) { /* Allow recheck on next resize */ this.isMobile = null; for(let button of this.buttonsArr) button.setFullscreenMode(isFullscreen); this.unfullscreenButton.set_visible(isFullscreen); this.set_can_focus(isFullscreen); } setFloatingMode(isFloating) { this.isMobile = null; for(let button of this.buttonsArr) button.setFloatingMode(isFloating); } setLiveMode(isLive, isSeekable) { if(isLive) this.elapsedButton.set_label('LIVE'); this.positionScale.visible = isSeekable; } updateElapsedLabel(value) { value = value || 0; const elapsed = Misc.getFormattedTime(value, this.showHours) + '/' + this.durationFormatted; this.elapsedButton.set_label(elapsed); } addButton(buttonIcon, revealer) { const button = (buttonIcon instanceof Gtk.Button) ? buttonIcon : new Buttons.IconButton(buttonIcon); if(!revealer) this.append(button); else revealer.append(button); this.buttonsArr.push(button); return button; } addLabelButton(text, revealer) { text = text || ''; const button = new Buttons.LabelButton(text); return this.addButton(button, revealer); } addPopoverButton(iconName, revealer) { const button = new Buttons.PopoverButton(iconName); return this.addButton(button, revealer); } addCheckButtons(box, array, activeId) { let group = null; let child = box.get_first_child(); let i = 0; while(child || i < array.length) { if(i >= array.length) { child.hide(); debug(`hiding unused ${child.type} checkButton nr: ${i}`); i++; child = child.get_next_sibling(); continue; } const el = array[i]; let checkButton; if(child) { checkButton = child; debug(`reusing ${el.type} checkButton nr: ${i}`); } else { debug(`creating new ${el.type} checkButton nr: ${i}`); checkButton = new Gtk.CheckButton({ group: group, }); checkButton.connect( 'toggled', this._onCheckButtonToggled.bind(this) ); box.append(checkButton); } checkButton.label = el.label; debug(`checkButton label: ${checkButton.label}`); checkButton.type = el.type; debug(`checkButton type: ${checkButton.type}`); checkButton.activeId = el.activeId; debug(`checkButton id: ${checkButton.activeId}`); if(checkButton.activeId === activeId) { checkButton.set_active(true); debug(`activated ${el.type} checkButton nr: ${i}`); } if(!group) group = checkButton; i++; if(child) child = child.get_next_sibling(); } } _handleTrackChange(checkButton) { const clapperWidget = this.get_ancestor(Gtk.Grid); /* Reenabling audio is slow (as expected), * so it is better to toggle mute instead */ if(checkButton.type === 'audio') { if(checkButton.activeId < 0) return clapperWidget.player.set_mute(true); if(clapperWidget.player.get_mute()) clapperWidget.player.set_mute(false); return clapperWidget.player[ `set_${checkButton.type}_track` ](checkButton.activeId); } if(checkButton.activeId < 0) { if(checkButton.type === 'video') clapperWidget.player.draw_black(true); return clapperWidget.player[ `set_${checkButton.type}_track_enabled` ](false); } const setTrack = `set_${checkButton.type}_track`; clapperWidget.player[setTrack](checkButton.activeId); clapperWidget.player[`${setTrack}_enabled`](true); if(checkButton.type === 'video') clapperWidget.player.draw_black(false); } _handleVisualizationChange(checkButton) { const clapperWidget = this.get_ancestor(Gtk.Grid); const isEnabled = clapperWidget.player.get_visualization_enabled(); if(!checkButton.activeId) { if(isEnabled) { clapperWidget.player.set_visualization_enabled(false); debug('disabled visualizations'); } return; } const currVis = clapperWidget.player.get_current_visualization(); if(currVis === checkButton.activeId) return; debug(`set visualization: ${checkButton.activeId}`); clapperWidget.player.set_visualization(checkButton.activeId); if(!isEnabled) { clapperWidget.player.set_visualization_enabled(true); debug('enabled visualizations'); } } _addTogglePlayButton() { this.togglePlayButton = new Buttons.IconToggleButton( 'media-playback-start-symbolic', 'media-playback-pause-symbolic' ); this.togglePlayButton.child.add_css_class('playbackicon'); this.togglePlayButton.connect( 'clicked', this._onTogglePlayClicked.bind(this) ); this.addButton(this.togglePlayButton); } _addPositionScale() { this.positionScale = new Gtk.Scale({ orientation: Gtk.Orientation.HORIZONTAL, value_pos: Gtk.PositionType.LEFT, draw_value: false, hexpand: true, valign: Gtk.Align.CENTER, can_focus: false, visible: false, }); const scrollController = new Gtk.EventControllerScroll(); scrollController.set_flags(Gtk.EventControllerScrollFlags.BOTH_AXES); scrollController.connect('scroll', this._onPositionScaleScroll.bind(this)); this.positionScale.add_controller(scrollController); this.positionScale.add_css_class('positionscale'); this.positionScale.connect( 'value-changed', this._onPositionScaleValueChanged.bind(this) ); /* GTK4 is missing pressed/released signals for GtkRange/GtkScale. * We cannot add controllers, cause it already has them, so we * workaround this by observing css classes it currently has */ this.positionScale.connect( 'notify::css-classes', this._onPositionScaleDragging.bind(this) ); this.positionAdjustment = this.positionScale.get_adjustment(); this.positionAdjustment.set_page_increment(0); this.positionAdjustment.set_step_increment(8); const box = new Gtk.Box({ orientation: Gtk.Orientation.HORIZONTAL, hexpand: true, valign: Gtk.Align.CENTER, can_focus: false, }); this.chapterPopover = new Gtk.Popover({ position: Gtk.PositionType.TOP, autohide: false, }); const chapterLabel = new Gtk.Label(); chapterLabel.add_css_class('chapterlabel'); this.chapterPopover.set_child(chapterLabel); this.chapterPopover.set_parent(box); box.append(this.positionScale); this.append(box); } _addVolumeButton() { this.volumeButton = this.addPopoverButton( 'audio-volume-muted-symbolic' ); this.volumeScale = new Gtk.Scale({ orientation: Gtk.Orientation.VERTICAL, inverted: true, value_pos: Gtk.PositionType.TOP, draw_value: false, vexpand: true, }); this.volumeScale.add_css_class('volumescale'); this.volumeAdjustment = this.volumeScale.get_adjustment(); this.volumeAdjustment.set_upper(Misc.maxVolume); this.volumeAdjustment.set_step_increment(0.05); this.volumeAdjustment.set_page_increment(0.05); for(let i of [0, 1, Misc.maxVolume]) { const text = (!i) ? '0%' : (i % 1 === 0) ? `${i}00%` : `${i * 10}0%`; this.volumeScale.add_mark(i, Gtk.PositionType.LEFT, text); } this.volumeScale.connect( 'value-changed', this._onVolumeScaleValueChanged.bind(this) ); this.volumeButton.popoverBox.append(this.volumeScale); } _updateVolumeButtonIcon(volume) { const icon = (volume <= 0) ? 'muted' : (volume <= 0.3) ? 'low' : (volume <= 0.7) ? 'medium' : (volume <= 1) ? 'high' : 'overamplified'; const iconName = `audio-volume-${icon}-symbolic`; if(this.volumeButton.icon_name === iconName) return; this.volumeButton.set_icon_name(iconName); debug(`set volume icon: ${icon}`); } _setChapterVisible(isVisible) { const type = (isVisible) ? 'Show' : 'Hide'; const anti = (isVisible) ? 'Hide' : 'Show'; if(this[`chapter${anti}Id`]) { GLib.source_remove(this[`chapter${anti}Id`]); this[`chapter${anti}Id`] = null; } if( this[`chapter${type}Id`] || this.chapterPopover.visible === isVisible ) return; debug(`changing chapter visibility to: ${isVisible}`); this[`chapter${type}Id`] = GLib.idle_add( GLib.PRIORITY_DEFAULT_IDLE, () => { if(isVisible) { const [start, end] = this.positionScale.get_slider_range(); const controlsHeight = this.parent.get_height(); const scaleHeight = this.positionScale.parent.get_height(); this.chapterPopover.set_pointing_to(new Gdk.Rectangle({ x: 2, y: -(controlsHeight - scaleHeight) / 2, width: 2 * end, height: 0, })); } this.chapterPopover.visible = isVisible; this[`chapter${type}Id`] = null; debug(`chapter visible: ${isVisible}`); return GLib.SOURCE_REMOVE; } ); } _onRealize() { this.disconnect(this.realizeSignal); this.realizeSignal = null; const { player } = this.get_ancestor(Gtk.Grid); const scrollController = new Gtk.EventControllerScroll(); scrollController.set_flags( Gtk.EventControllerScrollFlags.VERTICAL | Gtk.EventControllerScrollFlags.DISCRETE ); scrollController.connect('scroll', player._onScroll.bind(player)); this.volumeButton.add_controller(scrollController); const initialVolume = (settings.get_string('volume-initial') === 'custom') ? settings.get_int('volume-value') / 100 : Misc.getCubicValue(settings.get_double('volume-last')); this.volumeScale.set_value(initialVolume); player.widget.connect('resize', this._onPlayerResize.bind(this)); } _onPlayerResize(widget, width, height) { const isMobile = (width < 560); if(this.isMobile === isMobile) return; for(let revealer of this.revealersArr) revealer.set_reveal_child(!isMobile); this.revealTracksRevealer.set_reveal_child(isMobile); this.isMobile = isMobile; } _onUnfullscreenClicked(button) { const root = button.get_root(); root.unfullscreen(); } _onUnfloatClicked(button) { const clapperWidget = this.get_ancestor(Gtk.Grid); clapperWidget.setFloatingMode(false); } _onCheckButtonToggled(checkButton) { if(!checkButton.get_active()) return; switch(checkButton.type) { case 'video': case 'audio': case 'subtitle': this._handleTrackChange(checkButton); break; case 'visualization': this._handleVisualizationChange(checkButton); break; default: break; } } _onTogglePlayClicked() { /* Parent of controls changes, so get ancestor instead */ const { player } = this.get_ancestor(Gtk.Grid); player.toggle_play(); } _onPositionScaleScroll(controller, dx, dy) { const { player } = this.get_ancestor(Gtk.Grid); player._onScroll(controller, dx || dy, 0); } _onPositionScaleValueChanged(scale) { const scaleValue = scale.get_value(); const positionSeconds = Math.round(scaleValue); this.currentPosition = positionSeconds; this.updateElapsedLabel(positionSeconds); if(this.chapters && this.isPositionDragging) { const chapter = this.chapters[scaleValue]; const isChapter = (chapter != null); if(isChapter) this.chapterPopover.child.label = chapter; this._setChapterVisible(isChapter); } } _onVolumeScaleValueChanged(scale) { const volume = scale.get_value(); const linearVolume = Misc.getLinearValue(volume); const { player } = this.get_ancestor(Gtk.Grid); player.set_volume(linearVolume); /* FIXME: All of below should be placed in 'volume-changed' * event once we move to message bus API */ const cssClass = 'overamp'; const hasOveramp = (scale.has_css_class(cssClass)); if(volume > 1) { if(!hasOveramp) scale.add_css_class(cssClass); } else { if(hasOveramp) scale.remove_css_class(cssClass); } this._updateVolumeButtonIcon(volume); } _onPositionScaleDragging(scale) { const isPositionDragging = scale.has_css_class('dragging'); if((this.isPositionDragging = isPositionDragging)) return; const isChapterSeek = this.chapterPopover.visible; if(!isPositionDragging) this._setChapterVisible(false); const clapperWidget = this.get_ancestor(Gtk.Grid); if(!clapperWidget) return; const scaleValue = scale.get_value(); if(!isChapterSeek) { const positionSeconds = Math.round(scaleValue); clapperWidget.player.seek_seconds(positionSeconds); } else clapperWidget.player.seek_chapter(scaleValue); } /* Only happens when navigating through controls panel */ _onControlsKeyPressed(controller, keyval, keycode, state) { const { player } = this.get_ancestor(Gtk.Grid); player._setHideControlsTimeout(); } _onControlsKeyReleased(controller, keyval, keycode, state) { switch(keyval) { case Gdk.KEY_space: case Gdk.KEY_Return: case Gdk.KEY_Escape: case Gdk.KEY_Right: case Gdk.KEY_Left: break; default: const { player } = this.get_ancestor(Gtk.Grid); player._onWidgetKeyReleased(controller, keyval, keycode, state); break; } } _onCloseRequest() { for(let button of this.buttonsArr) { if(!button._onCloseRequest) continue; button._onCloseRequest(); } } });