diff --git a/src/_locales/dict.setup-page.ts b/src/_locales/dict.setup-page.ts index 080ac0399..ae5d2ccfd 100644 --- a/src/_locales/dict.setup-page.ts +++ b/src/_locales/dict.setup-page.ts @@ -1813,6 +1813,17 @@ You can also set prefixes for each command type for faster navigation. Starting zh: '同步子面板', ja: '同期サブパネル', }, + 'settings.sub_panel.youtube': { + en: 'YouTube playback controls (tabs panel)', + de: 'YouTube-Wiedergabesteuerung (Tab-Panel)', + fr: 'Contrôles de lecture YouTube (panneau d’onglets)', + hu: 'YouTube lejátszásvezérlők (lap panel)', + pl: 'Sterowanie odtwarzaniem YouTube (panel kart)', + ru: 'Элементы управления воспроизведением YouTube (панель вкладок)', + zh_CN: 'YouTube 播放控制(标签页面板)', + zh_TW: 'YouTube 播放控制(分頁面板)', + ja: 'YouTube 再生コントロール(タブパネル)', + }, 'settings.nav_bar_enabled': { en: 'Enabled elements', de: 'Aktivierte Elemente', diff --git a/src/_locales/dict.sidebar.ts b/src/_locales/dict.sidebar.ts index 5f6491d68..e0779b2a2 100644 --- a/src/_locales/dict.sidebar.ts +++ b/src/_locales/dict.sidebar.ts @@ -15,6 +15,48 @@ export const sidebarTranslations: Translations = { zh_TW: '搜尋...', ja: '検索...', }, + 'bar.youtube.prev': { + en: 'Previous video', + de: 'Vorheriges Video', + fr: 'Vidéo précédente', + ru: 'Предыдущее видео', + ja: '前の動画', + }, + 'bar.youtube.next': { + en: 'Next video', + de: 'Nächstes Video', + fr: 'Vidéo suivante', + ru: 'Следующее видео', + ja: '次の動画', + }, + 'bar.youtube.play': { + en: 'Play', + de: 'Wiedergeben', + fr: 'Lecture', + ru: 'Воспроизвести', + ja: '再生', + }, + 'bar.youtube.pause': { + en: 'Pause', + de: 'Pause', + fr: 'Pause', + ru: 'Пауза', + ja: '一時停止', + }, + 'bar.youtube.mute': { + en: 'Mute tab', + de: 'Tab stummschalten', + fr: 'Couper le son de l’onglet', + ru: 'Отключить звук вкладки', + ja: 'タブをミュート', + }, + 'bar.youtube.unmute': { + en: 'Unmute tab', + de: 'Tab-Stummschaltung aufheben', + fr: 'Réactiver le son de l’onglet', + ru: 'Включить звук вкладки', + ja: 'ミュート解除', + }, // --- // -- Confirm dialogs diff --git a/src/assets/chevron-left.svg b/src/assets/chevron-left.svg new file mode 100644 index 000000000..975a55a43 --- /dev/null +++ b/src/assets/chevron-left.svg @@ -0,0 +1 @@ + diff --git a/src/assets/chevron-right.svg b/src/assets/chevron-right.svg new file mode 100644 index 000000000..ca1272f41 --- /dev/null +++ b/src/assets/chevron-right.svg @@ -0,0 +1 @@ + diff --git a/src/defaults/settings.ts b/src/defaults/settings.ts index b1c76f950..c08303d56 100644 --- a/src/defaults/settings.ts +++ b/src/defaults/settings.ts @@ -48,6 +48,7 @@ export const DEFAULT_SETTINGS: SettingsState = { subPanelBookmarks: true, subPanelHistory: true, subPanelSync: false, + subPanelYouTube: false, // Group page groupLayout: 'grid', diff --git a/src/page.setup/components/settings.navbar.vue b/src/page.setup/components/settings.navbar.vue index 076aaa6c7..10781cb7b 100644 --- a/src/page.setup/components/settings.navbar.vue +++ b/src/page.setup/components/settings.navbar.vue @@ -85,6 +85,12 @@ section( dbg="subPanelSync" :default="DEFAULT_SETTINGS.subPanelSync" @update:value="Settings.saveDebounced(150)") + ToggleField( + label="settings.sub_panel.youtube" + v-model:value="Settings.state.subPanelYouTube" + dbg="subPanelYouTube" + :default="DEFAULT_SETTINGS.subPanelYouTube" + @update:value="Settings.saveDebounced(150)") InfoField( label="settings.nav_bar_enabled" diff --git a/src/services/youtube.fg.ts b/src/services/youtube.fg.ts new file mode 100644 index 000000000..943d144a2 --- /dev/null +++ b/src/services/youtube.fg.ts @@ -0,0 +1,206 @@ +import type { Tab, Reactivator } from 'src/types' +import { NOID } from 'src/defaults' +import * as Tabs from 'src/services/tabs.fg' +import * as Windows from 'src/services/windows.fg' +import * as Logs from 'src/services/logs' +import * as Permissions from 'src/services/permissions.fg' + +export interface YouTubeReactiveState { + probeTick: number +} + +export let reactive: YouTubeReactiveState = { probeTick: 0 } + +export function reactivate(r: Reactivator): void { + reactive = r(reactive) +} + +let nativeAudibleYtTabId: ID = NOID +let probeTimeout: number | undefined +let probeInterval: number | undefined +let listenersBound = false + +function tabUrl(t: Tab): string { + return t.url || t.reactive.url || '' +} + +function isYouTubePlaybackUrl(url: string | undefined): boolean { + if (!url || url.startsWith('about:')) return false + try { + const u = new URL(url) + const h = u.hostname.toLowerCase() + if (h === 'youtu.be') return true + if (h === 'music.youtube.com') return u.pathname.startsWith('/watch') + if (h === 'm.youtube.com' || h.endsWith('.youtube.com') || h === 'youtube.com') { + const p = u.pathname + return ( + p.startsWith('/watch') || + p.startsWith('/shorts/') || + p.startsWith('/live/') || + p.startsWith('/embed/') + ) + } + } catch { + /* ignore */ + } + return ( + url.includes('youtu.be/') || + url.includes('youtube.com/watch') || + url.includes('youtube.com/shorts/') || + url.includes('youtube.com/live/') || + url.includes('youtube.com/embed/') || + url.includes('music.youtube.com/watch') + ) +} + +async function refreshNativeAudibleYouTubeTabId(): Promise { + if (!Windows.id) return + try { + const audibleTabs = await browser.tabs.query({ audible: true, windowId: Windows.id }) + let next: ID = NOID + for (const nt of audibleTabs) { + if (nt.id === undefined || !nt.url) continue + if (isYouTubePlaybackUrl(nt.url)) { + next = nt.id + break + } + } + if (next !== nativeAudibleYtTabId) { + nativeAudibleYtTabId = next + reactive.probeTick++ + } + } catch (err) { + Logs.err('YouTube: audible probe failed', err) + } +} + +function scheduleNativeAudibleProbe(): void { + clearTimeout(probeTimeout) + probeTimeout = setTimeout(() => { + probeTimeout = undefined + void refreshNativeAudibleYouTubeTabId() + }, 80) as unknown as number +} + +/** Sync audible YouTube tab id from Firefox (Sidebery tab updates can lag). */ +export function setupYouTubeBarListeners(): void { + if (listenersBound) return + listenersBound = true + + const onUpdated = (_tabId: ID, change: browser.tabs.ChangeInfo): void => { + if ( + change.audible !== undefined || + change.url !== undefined || + change.status !== undefined + ) { + scheduleNativeAudibleProbe() + } + } + browser.tabs.onUpdated.addListener(onUpdated) + browser.tabs.onActivated.addListener(scheduleNativeAudibleProbe) + void refreshNativeAudibleYouTubeTabId() + + probeInterval = setInterval(() => { + void refreshNativeAudibleYouTubeTabId() + }, 3000) as unknown as number +} + +/** Resolve target: native audible match, then Sidebery state, then active-tab fallback. */ +export function getTargetTab(): Tab | undefined { + for (const t of Tabs.list) { + void t.reactive.mediaAudible + void t.reactive.mediaPaused + void t.reactive.mediaMuted + void t.reactive.url + void t.reactive.active + } + void reactive.probeTick + + const urlOk = (t: Tab) => !t.discarded && isYouTubePlaybackUrl(tabUrl(t)) + + if (nativeAudibleYtTabId !== NOID) { + const t = Tabs.byId[nativeAudibleYtTabId] + if (t && urlOk(t)) return t + } + + const audible = Tabs.list.find( + t => urlOk(t) && (t.reactive.mediaAudible || t.audible === true) + ) + if (audible) return audible + + const paused = Tabs.list.find(t => urlOk(t) && (t.reactive.mediaPaused || t.mediaPaused)) + if (paused) return paused + + const act = Tabs.byId[Tabs.activeId] + if (act && urlOk(act)) return act + + return undefined +} + +export function isTargetMuted(tab: Tab): boolean { + return tab.reactive.mediaMuted || tab.mutedInfo?.muted === true +} + +export async function play(): Promise { + const tab = getTargetTab() + if (!tab) return + await Tabs.playTabMedia(tab.id) +} + +export async function pause(): Promise { + const tab = getTargetTab() + if (!tab) return + await Tabs.pauseTabMedia(tab.id) +} + +export function mute(): void { + const tab = getTargetTab() + if (!tab) return + browser.tabs.update(tab.id, { muted: true }).catch(err => { + Logs.err('YouTube: mute tab failed', err) + }) +} + +export function unmute(): void { + const tab = getTargetTab() + if (!tab) return + browser.tabs.update(tab.id, { muted: false }).catch(err => { + Logs.err('YouTube: unmute tab failed', err) + }) +} + +async function ensureScriptingPermission(): Promise { + if (!Permissions.reactive.webData) { + const result = await Permissions.request('') + if (!result) return false + } + return true +} + +export async function prevVideo(): Promise { + if (!(await ensureScriptingPermission())) return + const tab = getTargetTab() + if (!tab) return + browser.tabs + .executeScript(tab.id, { + code: "void document.querySelector('.ytp-prev-button')?.click()", + runAt: 'document_idle', + }) + .catch(err => { + Logs.err('YouTube: prev executeScript failed', err) + }) +} + +export async function nextVideo(): Promise { + if (!(await ensureScriptingPermission())) return + const tab = getTargetTab() + if (!tab) return + browser.tabs + .executeScript(tab.id, { + code: "void document.querySelector('.ytp-next-button')?.click()", + runAt: 'document_idle', + }) + .catch(err => { + Logs.err('YouTube: next executeScript failed', err) + }) +} diff --git a/src/sidebar/components/bar.youtube-controls.vue b/src/sidebar/components/bar.youtube-controls.vue new file mode 100644 index 000000000..4ad9dd4fd --- /dev/null +++ b/src/sidebar/components/bar.youtube-controls.vue @@ -0,0 +1,72 @@ + + + + diff --git a/src/sidebar/components/panel.tabs.vue b/src/sidebar/components/panel.tabs.vue index d5f795188..a0b54c5b8 100644 --- a/src/sidebar/components/panel.tabs.vue +++ b/src/sidebar/components/panel.tabs.vue @@ -34,7 +34,7 @@ v-if="Settings.state.showNewTabBtns && Settings.state.newTabBarPosition === 'bottom'" :panel="panel") - .bottom-bar-space(v-if="bottomBarSpaceNeeded") + .bottom-bar-space(v-if="bottomBarSpaceNeeded" :data-rows="bottomDockRows") PanelPlaceholder( :isMsg="Search.reactive.active && panel.reactive.filteredLen === 0" @@ -42,7 +42,7 @@