/* * Vivaldi Media Player * Version: 2026.07.22 * Author: sudenim and Tam710562 * Description: Adds a draggable mini player to a page when the media source is on a background tab * * GNU General Public License v3.0 */ (() => { console.log("Vivaldi Media Player loaded"); // CONSTANTS // -------------------------------------------------- const MESSAGE_TYPE = "vmp-media-controls"; const HIDE_DELAY_MS = 10000; const DBLCLICK_DELAY_MS = 250; // STATE // -------------------------------------------------- let lastAudioTime = 0; let lockedSource = null; let currentState = null; let dismissedTabs = new Set(); let sliderFill = null; let sliderThumb = null; let sliderDragging = false; let playClickTimer = null; // SVG ICONS // -------------------------------------------------- const volumeIcons = { high: ``, medium: ``, off: `` }; const playbackStateIcons = { play: ``, pause: `` }; // UI LAYOUT // -------------------------------------------------- const root = document.createElement("div"); root.id = "vmp"; root.innerHTML = `
Vivaldi Media Player
`; (document.body || document.documentElement).appendChild(root); // VIVALDI HIGHLIGHT COLOUR // -------------------------------------------------- function applyThemeColor() { const browser = document.querySelector('#browser'); if (!browser) { setTimeout(applyThemeColor, 100); return; } const highlight = getComputedStyle(browser).getPropertyValue('--colorHighlightBg').trim(); if (!highlight) { setTimeout(applyThemeColor, 100); return; } document.documentElement.style.setProperty('--accentColor', highlight); } setTimeout(() => { applyThemeColor(); new MutationObserver(applyThemeColor).observe(document.documentElement, { childList: true, subtree: true, attributes: true, attributeFilter: ['style', 'class'] }); }, 500); // ELEMENTS // -------------------------------------------------- const playBtn = root.querySelector(".vmp-play"); const titleEl = root.querySelector(".vmp-title"); const artistEl = root.querySelector(".vmp-artist"); const volInput = root.querySelector(".vmp-vol"); const volumeBtn = root.querySelector(".vmp-volume-btn"); // DRAG LOGIC // -------------------------------------------------- let dragX = null, dragY = null; root.addEventListener('pointerdown', e => { if (e.button !== 0) return; if (e.target.closest('button') || e.target.closest('.vmp-slider-wrap') || e.target.closest('.vmp-custom-slider')) return; dragX = e.clientX - root.getBoundingClientRect().left; dragY = e.clientY - root.getBoundingClientRect().top; root.style.transition = 'none'; e.preventDefault(); }); document.addEventListener('pointermove', e => { if (dragX === null) return; const x = Math.max(0, Math.min(e.clientX - dragX, window.innerWidth - root.offsetWidth)); const y = Math.max(0, Math.min(e.clientY - dragY, window.innerHeight - root.offsetHeight)); root.style.left = x + 'px'; root.style.top = y + 'px'; root.style.bottom = 'auto'; root.style.right = 'auto'; }); document.addEventListener('pointerup', e => { if (dragX !== null) { const rect = root.getBoundingClientRect(); if (rect.right + 120 > window.innerWidth) { root.style.right = (window.innerWidth - rect.right) + 'px'; root.style.left = 'auto'; } checkFlip(); } dragX = null; dragY = null; root.style.transition = ''; }); // Expand from right edge when near viewport edge function checkFlip() { const rect = root.getBoundingClientRect(); const spaceRight = window.innerWidth - rect.right; if (spaceRight < 160) { root.style.right = spaceRight + 'px'; root.style.left = 'auto'; } else { const currentRight = parseFloat(root.style.right); if (!isNaN(currentRight)) { root.style.left = (window.innerWidth - currentRight - root.offsetWidth) + 'px'; } root.style.right = 'auto'; } } // CUSTOM VOLUME SLIDER // -------------------------------------------------- requestAnimationFrame(() => { const sliderWrap = root.querySelector('.vmp-slider-wrap'); if (!sliderWrap) return; volInput.style.display = 'none'; sliderWrap.insertAdjacentHTML('beforeend', `
`); const slider = sliderWrap.querySelector('.vmp-custom-slider'); const thumb = slider.querySelector('.vmp-slider-thumb'); sliderFill = slider.querySelector('.vmp-slider-fill'); sliderThumb = thumb; updateSliderVisual(parseFloat(volInput.value) || 1); // Debounce sendMessage let sendTimer = null; function setVolumeFromMouse(clientX) { const rect = slider.getBoundingClientRect(); const pos = Math.max(0, Math.min(clientX - rect.left, rect.width)); const volume = pos / rect.width; volInput.value = volume; updateSliderVisual(volume); updateVolumeIcon(volume); clearTimeout(sendTimer); sendTimer = setTimeout(() => applyVolume(volume), 50); } thumb.addEventListener('pointerdown', e => { sliderDragging = true; e.preventDefault(); }); function onSliderMouseUp() { if (sliderDragging) { sliderDragging = false; clearTimeout(sendTimer); applyVolume(parseFloat(volInput.value)); } } function onSliderMouseMove(e) { if (sliderDragging) setVolumeFromMouse(e.clientX); } document.addEventListener('pointerup', onSliderMouseUp); document.addEventListener('pointermove', onSliderMouseMove); slider.addEventListener('click', e => setVolumeFromMouse(e.clientX)); volInput.addEventListener('change', e => { if (!sliderDragging) updateSliderVisual(parseFloat(e.target.value)); }); }, 0); // HELPERS // -------------------------------------------------- function updateSliderVisual(value) { if (!sliderFill || !sliderThumb) return; const pct = Math.max(0, Math.min(value, 1)) * 100; sliderFill.style.width = pct + '%'; sliderThumb.style.left = pct + '%'; } function updateVolumeIcon(volume) { const v = parseFloat(volume); if (v <= 0) volumeBtn.innerHTML = volumeIcons.off; else if (v < 0.5) volumeBtn.innerHTML = volumeIcons.medium; else volumeBtn.innerHTML = volumeIcons.high; } function applyVolume(newVol) { if (!currentState) return; volInput.value = newVol; updateVolumeIcon(newVol); updateSliderVisual(newVol); chrome.tabs.sendMessage( currentState.tabId, { type: MESSAGE_TYPE, action: "volume", value: newVol }, { frameId: currentState.frameId }, () => { if (chrome.runtime.lastError) { /* tab may be gone */ } } ); } function sendControl(action) { if (!currentState) return; chrome.tabs.sendMessage( currentState.tabId, { type: MESSAGE_TYPE, action }, { frameId: currentState.frameId }, () => { if (chrome.runtime.lastError) { /* tab may be gone */ } } ); } let lastTitle = null, lastArtist = null, lastPaused = null, lastVolume = null; // UPDATE UI // -------------------------------------------------- function updateUI(data) { if (!data) return; currentState = data; const titleText = data.title || "No media detected"; const artist = data.artist || data.hostname || ''; const paused = data.paused; const volume = data.volume ?? 1; if (titleText !== lastTitle) { lastTitle = titleText; let span = titleEl.querySelector("span"); if (!span) { span = document.createElement("span"); titleEl.innerHTML = ""; titleEl.appendChild(span); } span.textContent = titleText; span.setAttribute("data-text", titleText); titleEl.classList.toggle("scroll", titleText.length > 30); if (titleText.length > 30) { const duration = Math.max(8, titleText.length * 0.4); titleEl.style.setProperty('--scroll-duration', duration + 's'); } } if (artist !== lastArtist) { lastArtist = artist; artistEl.textContent = artist; } if (paused !== lastPaused) { lastPaused = paused; playBtn.innerHTML = paused ? playbackStateIcons.pause : playbackStateIcons.play; } if (!sliderDragging && volume !== lastVolume) { lastVolume = volume; volInput.value = volume; updateVolumeIcon(volume); updateSliderVisual(volume); } } // CONTROLS // -------------------------------------------------- // Single click = play/pause, double click = next track. playBtn.addEventListener('click', () => { if (playClickTimer) { return; } playClickTimer = setTimeout(() => { playClickTimer = null; if (!currentState) return; sendControl(currentState.paused ? "play" : "pause"); }, DBLCLICK_DELAY_MS); }); playBtn.addEventListener('dblclick', e => { e.stopPropagation(); clearTimeout(playClickTimer); playClickTimer = null; sendControl("next"); }); volumeBtn.addEventListener('click', () => { if (!currentState) return; applyVolume(parseFloat(volInput.value) === 0 ? 1 : 0); }); // MAIN INJECTION // -------------------------------------------------- function injectMain(messageType) { if (window.__vmpMainInjected) return; window.__vmpMainInjected = true; let currentMedia = null; let volEmitTimer = null; const tracked = new WeakSet(); const mediaSessionActionHandlers = {}; const origAddEventListener = EventTarget.prototype.addEventListener; const origVideoPlay = HTMLVideoElement.prototype.play; const origAudioPlay = HTMLAudioElement.prototype.play; HTMLVideoElement.prototype.play = function () { trackElement(this); return origVideoPlay.apply(this, arguments); }; HTMLAudioElement.prototype.play = function () { trackElement(this); return origAudioPlay.apply(this, arguments); }; HTMLVideoElement.prototype.addEventListener = function () { trackElement(this); return origAddEventListener.apply(this, arguments); }; HTMLAudioElement.prototype.addEventListener = function () { trackElement(this); return origAddEventListener.apply(this, arguments); }; // 'nexttrack' handler if (window.navigator.mediaSession && 'setActionHandler' in navigator.mediaSession) { const origSetActionHandler = navigator.mediaSession.setActionHandler.bind(navigator.mediaSession); navigator.mediaSession.setActionHandler = function (action, handler) { mediaSessionActionHandlers[action] = handler; return origSetActionHandler(action, handler); }; } function getTitle() { return navigator.mediaSession.metadata?.title || document.title || null; } function getArtist() { return navigator.mediaSession.metadata?.artist || null; } function isActive(el) { return !el.paused && !el.ended && el.readyState >= 2; } function hasMediaPlaying() { return Array.from(document.querySelectorAll('video, audio')).some(el => isActive(el)); } function getDataControl(el) { return { type: messageType, title: getTitle(), artist: getArtist(), currentTime: el.currentTime, duration: el.duration, paused: el.paused, volume: el.muted ? 0 : el.volume, muted: el.muted, canNext: typeof mediaSessionActionHandlers.nexttrack === 'function', }; } function emit(el) { window.postMessage({ type: messageType, data: getDataControl(el) }, '*'); } let lastEmitTime = 0; function mediaActivityHandler(event) { if (event.target.paused) return; currentMedia = event.target; const now = Date.now(); if (now - lastEmitTime < 1000) return; lastEmitTime = now; emit(currentMedia); } function pauseHandler(event) { if (!hasMediaPlaying()) { currentMedia = event.target; emit(currentMedia); } } function volumechangeHandler(event) { if (currentMedia !== event.target) return; clearTimeout(volEmitTimer); volEmitTimer = setTimeout(() => emit(currentMedia), 100); } function endedHandler() { if (!hasMediaPlaying()) { currentMedia = null; window.postMessage({ type: messageType, data: { ended: true } }, '*'); } } function trackElement(el) { if (tracked.has(el)) return; tracked.add(el); origAddEventListener.call(el, 'play', mediaActivityHandler); origAddEventListener.call(el, 'playing', mediaActivityHandler); origAddEventListener.call(el, 'timeupdate', mediaActivityHandler); origAddEventListener.call(el, 'pause', pauseHandler); origAddEventListener.call(el, 'volumechange', volumechangeHandler); origAddEventListener.call(el, 'ended', endedHandler); origAddEventListener.call(el, 'error', endedHandler); } // One-time scan for elements already in the DOM at inject time document.querySelectorAll('video, audio').forEach(trackElement); const observer = new MutationObserver(mutations => { for (const m of mutations) { for (const node of m.addedNodes) { if (node.nodeType !== 1) continue; if (node.tagName === 'VIDEO' || node.tagName === 'AUDIO') { trackElement(node); } else { node.querySelectorAll('video, audio').forEach(trackElement); } } } }); observer.observe(document, { childList: true, subtree: true }); window.addEventListener('message', event => { if (!event.data || event.data.type !== messageType + '-cmd') return; const { action, value } = event.data; if (action === 'next') { if (typeof mediaSessionActionHandlers.nexttrack === 'function') { mediaSessionActionHandlers.nexttrack(); } return; } if (action === 'previous') { if (typeof mediaSessionActionHandlers.previoustrack === 'function') { mediaSessionActionHandlers.previoustrack(); } return; } if (!currentMedia) return; if (action === 'play') currentMedia.play(); else if (action === 'pause') currentMedia.pause(); else if (action === 'volume') { currentMedia.volume = Math.max(0, Math.min(1, parseFloat(value))); currentMedia.muted = false; } }); } // BRIDGE // -------------------------------------------------- function injectBridge(messageType) { if (window.__vmpBridgeInjected) return; window.__vmpBridgeInjected = true; // Page → extension window.addEventListener('message', event => { if (!event.data || event.data.type !== messageType) return; chrome.runtime.sendMessage(event.data); }); // Extension → page chrome.runtime.onMessage.addListener(msg => { if (!msg || msg.type !== messageType) return; window.postMessage({ type: messageType + '-cmd', action: msg.action, value: msg.value }, '*'); }); } // TAB INJECTION // -------------------------------------------------- function injectIntoTab(tabId, frameIds) { const target = frameIds ? { tabId, frameIds } : { tabId, allFrames: true }; chrome.scripting.executeScript({ target, func: injectMain, args: [MESSAGE_TYPE], world: 'MAIN' }, () => { if (chrome.runtime.lastError) { /* tab may not be scriptable */ } }); chrome.scripting.executeScript({ target, func: injectBridge, args: [MESSAGE_TYPE] }, () => { if (chrome.runtime.lastError) { /* tab may not be scriptable */ } }); } function injectIntoAllTabs() { chrome.tabs.query({}, tabs => { for (const tab of tabs) { if (tab.id) injectIntoTab(tab.id); } }); } injectIntoAllTabs(); chrome.webNavigation.onCommitted.addListener(details => { injectIntoTab(details.tabId, [details.frameId]); }); // MESSAGE HANDLER // -------------------------------------------------- chrome.runtime.onMessage.addListener((msg, sender) => { if (!msg || msg.type !== MESSAGE_TYPE) return; if (!msg.data || !sender.tab) return; const tabId = sender.tab.id; const frameId = sender.frameId; chrome.tabs.get(tabId, tab => { if (chrome.runtime.lastError || !tab) return; const isAudioOn = tab.audible && !(tab.mutedInfo && tab.mutedInfo.muted); // Ignore tabs with no audio indicator if (!isAudioOn) return; const applyUpdate = () => { lockedSource = { tabId, frameId }; msg.data.tabId = tabId; msg.data.frameId = frameId; msg.data.hostname = sender.tab?.url ? new URL(sender.tab.url).hostname : ''; updateUI(msg.data); }; // Keep lock on current source unless it has lost its audio indicator if (lockedSource && lockedSource.tabId !== tabId) { chrome.tabs.get(lockedSource.tabId, lockedTab => { const lockedActive = !chrome.runtime.lastError && lockedTab && lockedTab.audible && !(lockedTab.mutedInfo && lockedTab.mutedInfo.muted); if (lockedActive) return; applyUpdate(); }); return; } applyUpdate(); }); }); // PLAYER VISIBILITY // -------------------------------------------------- let visibilityInterval = null; function updateMiniPlayerVisibility() { chrome.tabs.query({ currentWindow: true }, tabs => { const activeTab = tabs.find(t => t.active); const playingTab = tabs.find(t => t.audible && !(t.mutedInfo && t.mutedInfo.muted)); const isBackgroundTab = !!(playingTab && activeTab && playingTab.id !== activeTab.id); if (isBackgroundTab) { lastAudioTime = Date.now(); } else if (playingTab && activeTab && playingTab.id === activeTab.id) { lastAudioTime = 0; } const activeTabId = activeTab?.id; const isDismissed = activeTabId && dismissedTabs.has(activeTabId); const isSourceTab = activeTabId && lockedSource?.tabId === activeTabId; const keepVisible = (Date.now() - lastAudioTime) < HIDE_DELAY_MS; const shouldShow = (isBackgroundTab && !isDismissed && !isSourceTab) || (!isBackgroundTab && keepVisible); root.classList.toggle("visible", shouldShow); }); } function scheduleVisibilityCheck() { if (visibilityInterval) return; visibilityInterval = setInterval(() => { updateMiniPlayerVisibility(); if (Date.now() - lastAudioTime >= HIDE_DELAY_MS) { clearInterval(visibilityInterval); visibilityInterval = null; } }, 500); } chrome.tabs.onUpdated.addListener((_tabId, changeInfo) => { if ('audible' in changeInfo) { updateMiniPlayerVisibility(); scheduleVisibilityCheck(); } }); chrome.tabs.onActivated.addListener(() => { updateMiniPlayerVisibility(); scheduleVisibilityCheck(); }); // FULLSCREEN / WINDOW RESIZE // -------------------------------------------------- window.addEventListener('resize', () => { updateMiniPlayerVisibility(); scheduleVisibilityCheck(); }); updateMiniPlayerVisibility(); // Dismiss on double-click root.addEventListener('dblclick', e => { if (e.target.closest('button')) return; root.classList.add('vmp-close'); root.addEventListener('animationend', () => { root.classList.remove('vmp-close'); root.classList.remove('visible'); chrome.tabs.query({ active: true, currentWindow: true }, tabs => { if (tabs[0]?.id) dismissedTabs.add(tabs[0].id); }); }, { once: true }); }); setTimeout(checkFlip, 100); })();