Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions src/_locales/dict.setup-page.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
42 changes: 42 additions & 0 deletions src/_locales/dict.sidebar.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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',

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using YouTube's own translations would be much better for correctness and consistency.

Picking this one just as an example. YouTube's translation is "Désactiver le son". The addition of de l'onglet is a separate decision, which might be worth discussing as well.

Missing languages should be easy to add too, but I'm not sure what the Chinese ones correspond to on YouTube's language list which has Simplified, Traditional, and Hong Kong

ru: 'Отключить звук вкладки',
ja: 'タブをミュート',
},
'bar.youtube.unmute': {
en: 'Unmute tab',
de: 'Tab-Stummschaltung aufheben',
fr: 'Réactiver le son de l’onglet',
ru: 'Включить звук вкладки',
ja: 'ミュート解除',
},

// ---
// -- Confirm dialogs
Expand Down
1 change: 1 addition & 0 deletions src/assets/chevron-left.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions src/assets/chevron-right.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions src/defaults/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ export const DEFAULT_SETTINGS: SettingsState = {
subPanelBookmarks: true,
subPanelHistory: true,
subPanelSync: false,
subPanelYouTube: false,

// Group page
groupLayout: 'grid',
Expand Down
6 changes: 6 additions & 0 deletions src/page.setup/components/settings.navbar.vue
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
206 changes: 206 additions & 0 deletions src/services/youtube.fg.ts
Original file line number Diff line number Diff line change
@@ -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<YouTubeReactiveState>): 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<void> {
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<void> {
const tab = getTargetTab()
if (!tab) return
await Tabs.playTabMedia(tab.id)
}

export async function pause(): Promise<void> {
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<boolean> {
if (!Permissions.reactive.webData) {
const result = await Permissions.request('<all_urls>')
if (!result) return false
}
return true
}

export async function prevVideo(): Promise<void> {
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<void> {
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)
})
}
72 changes: 72 additions & 0 deletions src/sidebar/components/bar.youtube-controls.vue
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
<template lang="pug">
.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")
</template>

<script lang="ts" setup>
import { computed } from 'vue'
import { translate } from 'src/dict'
import * as Tabs from 'src/services/tabs.fg'
import * as YouTube from 'src/services/youtube.fg'

const tab = computed(() => YouTube.getTargetTab())

const isPaused = computed(() => {
const t = tab.value
if (!t) return true
if (t.reactive.mediaPaused || t.mediaPaused) return true
if (t.reactive.mediaAudible || t.audible) return false
if (t.id === Tabs.activeId || t.reactive.active) return false
return true
})

const muted = computed(() => {
const t = tab.value
if (!t) return false
return YouTube.isTargetMuted(t)
})

function onPlayPause(): void {
if (!tab.value) return
if (isPaused.value) void YouTube.play()
else void YouTube.pause()
}

function onMuteToggle(): void {
const t = tab.value
if (!t) return
if (YouTube.isTargetMuted(t)) YouTube.unmute()
else YouTube.mute()
}

function onPrev(): void {
void YouTube.prevVideo()
}

function onNext(): void {
void YouTube.nextVideo()
}
</script>

Loading