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 @@
+
+.YouTubeControlsBar
+ .YouTubeControlsBar-inner
+ .tool-btn(
+ :data-disabled="!tab"
+ @click="onPrev"
+ :title="translate('bar.youtube.prev')")
+ svg: use(href="#icon_chevron_left")
+ .tool-btn(
+ :data-disabled="!tab"
+ @click="onPlayPause"
+ :title="tab && isPaused ? translate('bar.youtube.play') : translate('bar.youtube.pause')")
+ svg(v-if="tab && isPaused"): use(href="#icon_play")
+ svg(v-else): use(href="#icon_pause")
+ .tool-btn(
+ :data-disabled="!tab"
+ @click="onNext"
+ :title="translate('bar.youtube.next')")
+ svg: use(href="#icon_chevron_right")
+ .tool-btn(
+ :data-disabled="!tab"
+ @click="onMuteToggle"
+ :title="tab && muted ? translate('bar.youtube.unmute') : translate('bar.youtube.mute')")
+ svg(v-if="tab && muted"): use(href="#icon_mute")
+ svg(v-else): use(href="#icon_loud")
+
+
+
+
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 @@