Files
desktop/src/browser/base/zen-components/ZenMediaController.mjs
2025-04-13 16:35:41 +07:00

676 lines
24 KiB
JavaScript

{
const lazy = {};
XPCOMUtils.defineLazyPreferenceGetter(
lazy,
'RESPECT_PIP_DISABLED',
'media.videocontrols.picture-in-picture.respect-disablePictureInPicture',
true
);
class ZenMediaController {
_currentMediaController = null;
_currentBrowser = null;
_mediaUpdateInterval = null;
mediaTitle = null;
mediaArtist = null;
mediaControlBar = null;
mediaProgressBar = null;
mediaCurrentTime = null;
mediaDuration = null;
mediaFocusButton = null;
mediaProgressBarContainer = null;
supportedKeys = ['playpause', 'previoustrack', 'nexttrack'];
mediaControllersMap = new Map();
_tabTimeout = null;
_controllerSwitchTimeout = null;
#isSeeking = false;
init() {
if (!Services.prefs.getBoolPref('zen.mediacontrols.enabled', true)) return;
this.mediaTitle = document.querySelector('#zen-media-title');
this.mediaArtist = document.querySelector('#zen-media-artist');
this.mediaControlBar = document.querySelector('#zen-media-controls-toolbar');
this.mediaProgressBar = document.querySelector('#zen-media-progress-bar');
this.mediaCurrentTime = document.querySelector('#zen-media-current-time');
this.mediaDuration = document.querySelector('#zen-media-duration');
this.mediaFocusButton = document.querySelector('#zen-media-focus-button');
this.mediaProgressBarContainer = document.querySelector('#zen-media-progress-hbox');
this.onPositionstateChange = this._onPositionstateChange.bind(this);
this.onPlaybackstateChange = this._onPlaybackstateChange.bind(this);
this.onSupportedKeysChange = this._onSupportedKeysChange.bind(this);
this.onMetadataChange = this._onMetadataChange.bind(this);
this.onDeactivated = this._onDeactivated.bind(this);
this.onPipModeChange = this._onPictureInPictureModeChange.bind(this);
this.#initEventListeners();
}
#initEventListeners() {
this.mediaControlBar.addEventListener('mousedown', (event) => {
if (event.target.closest(':is(toolbarbutton,#zen-media-progress-hbox)')) return;
else this.onMediaFocus();
});
this.mediaControlBar.addEventListener('command', (event) => {
const button = event.target.closest('toolbarbutton');
if (!button) return;
switch (button.id) {
case 'zen-media-pip-button':
this.onMediaPip();
break;
case 'zen-media-close-button':
this.onControllerClose();
break;
case 'zen-media-focus-button':
this.onMediaFocus();
break;
case 'zen-media-mute-button':
this.onMediaMute();
break;
case 'zen-media-previoustrack-button':
this.onMediaPlayPrev();
break;
case 'zen-media-nexttrack-button':
this.onMediaPlayNext();
break;
case 'zen-media-playpause-button':
this.onMediaToggle();
break;
case 'zen-media-mute-mic-button':
this.onMicrophoneMuteToggle();
break;
case 'zen-media-mute-camera-button':
this.onCameraMuteToggle();
break;
}
});
this.mediaProgressBar.addEventListener('input', this.onMediaSeekDrag.bind(this));
this.mediaProgressBar.addEventListener('change', this.onMediaSeekComplete.bind(this));
window.addEventListener('TabSelect', (event) => {
if (this.isSharing) return;
const linkedBrowser = event.target.linkedBrowser;
this.switchController();
if (this._currentBrowser) {
if (linkedBrowser.browserId === this._currentBrowser.browserId) {
if (this._tabTimeout) {
clearTimeout(this._tabTimeout);
this._tabTimeout = null;
}
this.hideMediaControls();
} else {
this._tabTimeout = setTimeout(() => {
if (!this.mediaControlBar.hasAttribute('pip')) this.showMediaControls();
else this._tabTimeout = null;
}, 500);
}
}
});
const onTabDiscardedOrClosed = this.onTabDiscardedOrClosed.bind(this);
window.addEventListener('TabClose', onTabDiscardedOrClosed);
window.addEventListener('DOMAudioPlaybackStarted', (event) => {
setTimeout(() => {
if (
this._currentMediaController?.isPlaying &&
this.mediaControlBar.hasAttribute('hidden') &&
!this.mediaControlBar.hasAttribute('pip')
) {
const { selectedBrowser } = gBrowser;
if (selectedBrowser.browserId !== this._currentBrowser.browserId) {
this.showMediaControls();
}
}
}, 1000);
this.activateMediaControls(event.target.browsingContext.mediaController, event.target);
});
window.addEventListener('DOMAudioPlaybackStopped', () => this.updateMuteState());
window.webrtcUI.on('peer-request-allowed', this._onMediaShareStart.bind(this));
}
onTabDiscardedOrClosed(event) {
const { linkedBrowser } = event.target;
if (linkedBrowser?.browserId === this._currentBrowser?.browserId) {
this.deinitMediaSharingControls(linkedBrowser);
this.hideMediaControls();
}
if (linkedBrowser?.browsingContext.mediaController) {
this.deinitMediaController(
linkedBrowser.browsingContext.mediaController,
true,
linkedBrowser.browserId === this._currentBrowser?.browserId,
true
);
}
}
async deinitMediaController(mediaController, shouldForget = true, shouldOverride = true, shouldHide = true) {
if (shouldForget && mediaController) {
mediaController.removeEventListener('pictureinpicturemodechange', this.onPipModeChange);
mediaController.removeEventListener('positionstatechange', this.onPositionstateChange);
mediaController.removeEventListener('playbackstatechange', this.onPlaybackstateChange);
mediaController.removeEventListener('supportedkeyschange', this.onSupportedKeysChange);
mediaController.removeEventListener('metadatachange', this.onMetadataChange);
mediaController.removeEventListener('deactivated', this.onDeactivated);
this.mediaControllersMap.delete(mediaController.id);
}
if (shouldOverride) {
this._currentMediaController = null;
this._currentBrowser = null;
if (this._mediaUpdateInterval) {
clearInterval(this._mediaUpdateInterval);
this._mediaUpdateInterval = null;
}
if (shouldHide) await this.hideMediaControls();
this.mediaControlBar.removeAttribute('muted');
this.mediaControlBar.classList.remove('playing');
}
}
get isSharing() {
return this.mediaControlBar.hasAttribute('media-sharing');
}
set isSharing(value) {
if (this._currentBrowser && !value) {
const webRTC = this._currentBrowser.browsingContext.currentWindowGlobal.getActor('WebRTC');
webRTC.sendAsyncMessage('webrtc:UnmuteMicrophone');
webRTC.sendAsyncMessage('webrtc:UnmuteCamera');
}
if (!value) {
this.mediaControlBar.removeAttribute('mic-muted');
this.mediaControlBar.removeAttribute('camera-muted');
} else {
this.mediaControlBar.setAttribute('media-position-hidden', '');
this.mediaControlBar.setAttribute('media-sharing', '');
}
}
hideMediaControls() {
if (this.mediaControlBar.hasAttribute('hidden')) return;
return gZenUIManager.motion
.animate(
this.mediaControlBar,
{
opacity: [1, 0],
y: [0, 10],
},
{
duration: 0.1,
}
)
.then(() => {
this.mediaControlBar.setAttribute('hidden', 'true');
this.mediaControlBar.removeAttribute('media-sharing');
gZenUIManager.updateTabsToolbar();
gZenUIManager.restoreScrollbarState();
});
}
showMediaControls() {
if (!this.mediaControlBar.hasAttribute('hidden')) return;
if (!this.isSharing) {
if (!this._currentMediaController) return;
if (this._currentMediaController.isBeingUsedInPIPModeOrFullscreen) return this.hideMediaControls();
this.updatePipButton();
}
const mediaInfoElements = [this.mediaTitle, this.mediaArtist];
for (const element of mediaInfoElements) {
element.removeAttribute('overflow'); // So we can properly recalculate the overflow
}
this.mediaControlBar.removeAttribute('hidden');
window.requestAnimationFrame(() => {
this.mediaControlBar.style.height =
this.mediaControlBar.querySelector('toolbaritem').getBoundingClientRect().height + 'px';
this.mediaControlBar.style.opacity = 0;
gZenUIManager.updateTabsToolbar();
gZenUIManager.restoreScrollbarState();
gZenUIManager.motion.animate(
this.mediaControlBar,
{
opacity: [0, 1],
y: [10, 0],
},
{}
);
this.addLabelOverflows(mediaInfoElements);
});
}
addLabelOverflows(elements) {
for (const element of elements) {
const parent = element.parentElement;
if (element.scrollWidth > parent.clientWidth) {
element.setAttribute('overflow', '');
} else {
element.removeAttribute('overflow');
}
}
}
setupMediaController(mediaController, browser) {
this._currentMediaController = mediaController;
this._currentBrowser = browser;
this.updatePipButton();
}
setupMediaControlUI(metadata, positionState) {
this.updatePipButton();
if (!this.mediaControlBar.classList.contains('playing') && this._currentMediaController.isPlaying) {
this.mediaControlBar.classList.add('playing');
}
const iconURL = this._currentBrowser.mIconURL || `page-icon:${this._currentBrowser.currentURI.spec}`;
this.mediaFocusButton.style.listStyleImage = `url(${iconURL})`;
this.mediaTitle.textContent = metadata.title || '';
this.mediaArtist.textContent = metadata.artist || '';
gZenUIManager.updateTabsToolbar();
gZenUIManager.restoreScrollbarState();
this._currentPosition = positionState.position;
this._currentDuration = positionState.duration;
this._currentPlaybackRate = positionState.playbackRate;
this.updateMediaPosition();
for (const key of this.supportedKeys) {
const button = this.mediaControlBar.querySelector(`#zen-media-${key}-button`);
button.disabled = !this._currentMediaController.supportedKeys.includes(key);
}
}
activateMediaControls(mediaController, browser) {
this.updateMuteState();
this.switchController();
if (!mediaController.isActive || this._currentBrowser?.browserId === browser.browserId) return;
const metadata = mediaController.getMetadata();
const positionState = mediaController.getPositionState();
this.mediaControllersMap.set(mediaController.id, {
controller: mediaController,
browser,
position: positionState.position,
duration: positionState.duration,
playbackRate: positionState.playbackRate,
lastUpdated: Date.now(),
});
if (!this._currentBrowser && !this.isSharing) {
this.setupMediaController(mediaController, browser);
this.setupMediaControlUI(metadata, positionState);
}
mediaController.addEventListener('pictureinpicturemodechange', this.onPipModeChange);
mediaController.addEventListener('positionstatechange', this.onPositionstateChange);
mediaController.addEventListener('playbackstatechange', this.onPlaybackstateChange);
mediaController.addEventListener('supportedkeyschange', this.onSupportedKeysChange);
mediaController.addEventListener('metadatachange', this.onMetadataChange);
mediaController.addEventListener('deactivated', this.onDeactivated);
}
activateMediaDeviceControls(browser) {
if (browser?.browsingContext.currentWindowGlobal.hasActivePeerConnections()) {
this.mediaControlBar.removeAttribute('can-pip');
this._currentBrowser = browser;
const tab = window.gBrowser.getTabForBrowser(browser);
const iconURL = browser.mIconURL || `page-icon:${browser.currentURI.spec}`;
this.isSharing = true;
this.mediaFocusButton.style.listStyleImage = `url(${iconURL})`;
this.mediaTitle.textContent = tab.label;
this.mediaArtist.textContent = '';
this.showMediaControls();
tab.addEventListener('TabAttrModified', this._onTabAttrModified.bind(this));
}
}
deinitMediaSharingControls(browser) {
const tab = window.gBrowser.getTabForBrowser(browser);
if (tab) tab.removeEventListener('TabAttrModified', this._onTabAttrModified.bind(this));
this.isSharing = false;
this._currentBrowser = null;
}
_onTabAttrModified(event) {
const { changed } = event.detail;
const { linkedBrowser } = event.target;
if (changed.includes('sharing') && !linkedBrowser.browsingContext.currentWindowGlobal.hasActivePeerConnections()) {
if (this._currentBrowser?.browserId === linkedBrowser.browserId) {
event.target.removeEventListener('TabAttrModified', this._onTabAttrModified.bind(this));
this.deinitMediaSharingControls(linkedBrowser);
this.hideMediaControls();
this.switchController(true);
}
}
}
_onMediaShareStart(event) {
const { innerWindowID } = event;
for (const browser of window.gBrowser.browsers) {
if (browser.innerWindowID === innerWindowID) {
const webRTC = browser.browsingContext.currentWindowGlobal.getActor('WebRTC');
webRTC.sendAsyncMessage('webrtc:UnmuteMicrophone');
webRTC.sendAsyncMessage('webrtc:UnmuteCamera');
if (this._currentBrowser) this.deinitMediaSharingControls(this._currentBrowser);
if (this._currentMediaController) {
this._currentMediaController.pause();
this.deinitMediaController(this._currentMediaController, true, true).then(() =>
this.activateMediaDeviceControls(browser)
);
} else this.activateMediaDeviceControls(browser);
break;
}
}
}
_onDeactivated(event) {
this.deinitMediaController(event.target, true, event.target.id === this._currentMediaController.id, true);
this.switchController();
}
_onPlaybackstateChange() {
if (this._currentMediaController?.isPlaying) {
this.mediaControlBar.classList.add('playing');
} else {
this.switchController();
this.mediaControlBar.classList.remove('playing');
}
}
_onSupportedKeysChange(event) {
if (event.target.id !== this._currentMediaController?.id) return;
for (const key of this.supportedKeys) {
const button = this.mediaControlBar.querySelector(`#zen-media-${key}-button`);
button.disabled = !event.target.supportedKeys.includes(key);
}
}
_onPositionstateChange(event) {
const mediaController = this.mediaControllersMap.get(event.target.id);
this.mediaControllersMap.set(event.target.id, {
...mediaController,
position: event.position,
duration: event.duration,
playbackRate: event.playbackRate,
lastUpdated: Date.now(),
});
if (event.target.id !== this._currentMediaController?.id) return;
this._currentPosition = event.position;
this._currentDuration = event.duration;
this._currentPlaybackRate = event.playbackRate;
this.updateMediaPosition();
}
switchController(force = false) {
let timeout = 3000;
if (this.isSharing) return;
if (this.#isSeeking) return;
if (this._controllerSwitchTimeout) {
clearTimeout(this._controllerSwitchTimeout);
this._controllerSwitchTimeout = null;
}
if (this.mediaControllersMap.size === 1) timeout = 0;
this._controllerSwitchTimeout = setTimeout(() => {
if (!this._currentMediaController?.isPlaying || force) {
const nextController = Array.from(this.mediaControllersMap.values())
.filter(
(ctrl) =>
ctrl.controller.isPlaying &&
gBrowser.selectedBrowser.browserId !== ctrl.browser.browserId &&
ctrl.controller.id !== this._currentMediaController?.id
)
.sort((a, b) => b.lastUpdated - a.lastUpdated)
.shift();
if (nextController) {
this.deinitMediaController(this._currentMediaController, false, true).then(() => {
this.setupMediaController(nextController.controller, nextController.browser);
const elapsedTime = Math.floor((Date.now() - nextController.lastUpdated) / 1000);
this.setupMediaControlUI(nextController.controller.getMetadata(), {
position: nextController.position + (nextController.controller.isPlaying ? elapsedTime : 0),
duration: nextController.duration,
playbackRate: nextController.playbackRate,
});
this.showMediaControls();
});
}
}
this._controllerSwitchTimeout = null;
}, timeout);
}
updateMediaPosition() {
if (this._mediaUpdateInterval) {
clearInterval(this._mediaUpdateInterval);
this._mediaUpdateInterval = null;
}
if (this._currentDuration >= 900_000) return this.mediaControlBar.setAttribute('media-position-hidden', 'true');
else this.mediaControlBar.removeAttribute('media-position-hidden');
if (!this._currentDuration) return;
this.mediaCurrentTime.textContent = this.formatSecondsToTime(this._currentPosition);
this.mediaDuration.textContent = this.formatSecondsToTime(this._currentDuration);
this.mediaProgressBar.value = (this._currentPosition / this._currentDuration) * 100;
this._mediaUpdateInterval = setInterval(() => {
if (this._currentMediaController?.isPlaying) {
this._currentPosition += 1 * this._currentPlaybackRate;
if (this._currentPosition > this._currentDuration) {
this._currentPosition = this._currentDuration;
}
this.mediaCurrentTime.textContent = this.formatSecondsToTime(this._currentPosition);
this.mediaProgressBar.value = (this._currentPosition / this._currentDuration) * 100;
} else {
clearInterval(this._mediaUpdateInterval);
this._mediaUpdateInterval = null;
}
}, 1000);
}
formatSecondsToTime(seconds) {
if (!seconds || isNaN(seconds)) return '0:00';
const totalSeconds = Math.max(0, Math.ceil(seconds));
const hours = Math.floor(totalSeconds / 3600);
const minutes = Math.floor((totalSeconds % 3600) / 60).toString();
const secs = (totalSeconds % 60).toString();
if (hours > 0) {
return `${hours}:${minutes.padStart(2, '0')}:${secs.padStart(2, '0')}`;
}
return `${minutes}:${secs.padStart(2, '0')}`;
}
_onMetadataChange(event) {
if (event.target.id !== this._currentMediaController?.id) return;
this.updatePipButton();
const metadata = event.target.getMetadata();
this.mediaTitle.textContent = metadata.title || '';
this.mediaArtist.textContent = metadata.artist || '';
const mediaInfoElements = [this.mediaTitle, this.mediaArtist];
for (const element of mediaInfoElements) {
element.removeAttribute('overflow');
}
this.addLabelOverflows(mediaInfoElements);
}
_onPictureInPictureModeChange(event) {
if (event.target.id !== this._currentMediaController?.id) return;
if (event.target.isBeingUsedInPIPModeOrFullscreen) {
this.hideMediaControls();
this.mediaControlBar.setAttribute('pip', '');
} else {
const { selectedBrowser } = gBrowser;
if (selectedBrowser.browserId !== this._currentBrowser.browserId) {
this.showMediaControls();
}
this.mediaControlBar.removeAttribute('pip');
}
}
onMediaPlayPrev() {
if (this._currentMediaController?.supportedKeys.includes('previoustrack')) {
this._currentMediaController.prevTrack();
}
}
onMediaPlayNext() {
if (this._currentMediaController?.supportedKeys.includes('nexttrack')) {
this._currentMediaController.nextTrack();
}
}
onMediaSeekDrag(event) {
this.#isSeeking = true;
this._currentMediaController?.pause();
const newTime = (event.target.value / 100) * this._currentDuration;
this.mediaCurrentTime.textContent = this.formatSecondsToTime(newTime);
}
onMediaSeekComplete(event) {
const newPosition = (event.target.value / 100) * this._currentDuration;
if (this._currentMediaController?.supportedKeys.includes('seekto')) {
this._currentMediaController.seekTo(newPosition);
this._currentMediaController.play();
}
this.#isSeeking = false;
}
onMediaFocus() {
if (!this._currentBrowser) return;
if (this._currentMediaController) this._currentMediaController.focus();
else if (this._currentBrowser) {
const tab = window.gBrowser.getTabForBrowser(this._currentBrowser);
if (tab) window.ZenWorkspaces.switchTabIfNeeded(tab);
}
}
onMediaMute() {
if (!this.mediaControlBar.hasAttribute('muted')) {
this._currentBrowser.mute();
this.mediaControlBar.setAttribute('muted', '');
} else {
this._currentBrowser.unmute();
this.mediaControlBar.removeAttribute('muted');
}
}
onMediaToggle() {
if (this.mediaControlBar.classList.contains('playing')) {
this._currentMediaController?.pause();
} else {
this._currentMediaController?.play();
}
}
onControllerClose() {
if (this._currentMediaController) {
this._currentMediaController.pause();
this.deinitMediaController(this._currentMediaController);
} else if (this.isSharing) this.deinitMediaSharingControls(this._currentBrowser);
this.hideMediaControls();
this.switchController(true);
}
onMediaPip() {
this._currentBrowser.browsingContext.currentWindowGlobal
.getActor('PictureInPictureLauncher')
.sendAsyncMessage('PictureInPicture:KeyToggle');
}
onMicrophoneMuteToggle() {
if (this._currentBrowser) {
const shouldMute = this.mediaControlBar.hasAttribute('mic-muted') ? 'webrtc:UnmuteMicrophone' : 'webrtc:MuteMicrophone';
this._currentBrowser.browsingContext.currentWindowGlobal.getActor('WebRTC').sendAsyncMessage(shouldMute);
this.mediaControlBar.toggleAttribute('mic-muted');
}
}
onCameraMuteToggle() {
if (this._currentBrowser) {
const shouldMute = this.mediaControlBar.hasAttribute('camera-muted') ? 'webrtc:UnmuteCamera' : 'webrtc:MuteCamera';
this._currentBrowser.browsingContext.currentWindowGlobal.getActor('WebRTC').sendAsyncMessage(shouldMute);
this.mediaControlBar.toggleAttribute('camera-muted');
}
}
updateMuteState() {
if (!this._currentBrowser) return;
this.mediaControlBar.toggleAttribute('muted', this._currentBrowser._audioMuted);
}
updatePipButton() {
if (!this._currentBrowser) return;
if (this.isSharing) return;
const { totalPipCount, totalPipDisabled } = PictureInPicture.getEligiblePipVideoCount(this._currentBrowser);
const canPip = totalPipCount === 1 || (totalPipDisabled > 0 && lazy.RESPECT_PIP_DISABLED);
this.mediaControlBar.toggleAttribute('can-pip', canPip);
}
}
window.gZenMediaController = new ZenMediaController();
}