Skip to content
Draft
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
17 changes: 17 additions & 0 deletions src/components/Audios.vue
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import '@skjnldsv/vue-plyr/dist/vue-plyr.css'

import logger from '../services/logger.js'
import { preloadMedia } from '../services/mediaPreloader'
import { getPlyrTranslations, localizeSpeedLabels } from '../utils/plyrTranslations'

const VuePlyr = () => import(/* webpackChunkName: 'plyr' */'@skjnldsv/vue-plyr')

Expand All @@ -52,6 +53,7 @@ export default {
data() {
return {
fallback: false,
speedListenerBound: false,
}
},

Expand All @@ -66,6 +68,7 @@ export default {
blankVideo: '/blank.aac',
controls: ['play-large', 'play', 'progress', 'current-time', 'mute', 'volume', 'settings'],
loadSprite: false,
i18n: getPlyrTranslations(this.t),
}
},
},
Expand Down Expand Up @@ -109,6 +112,15 @@ export default {
control.addEventListener('mouseenter', this.disableSwipe)
control.addEventListener('mouseleave', this.enableSwipe)
})

// The Plyr menu is only in the DOM once the controls are mounted (see
// above), so wire up the speed-label localization here rather than in
// mounted(). Register the rate-change listener once.
if (!this.speedListenerBound && this.$refs.plyr?.player) {
this.$refs.plyr.player.on('ratechange', this.localizeSpeed)
this.speedListenerBound = true
}
this.localizeSpeed()
},

beforeDestroy() {
Expand All @@ -120,6 +132,11 @@ export default {
},

methods: {
localizeSpeed() {
// Defer so we run after Plyr's own label/badge update for this event.
this.$nextTick(() => localizeSpeedLabels(this.$el, this.t))
},

donePlaying() {
this.$refs.audio.autoplay = false
this.$refs.audio.load()
Expand Down
17 changes: 17 additions & 0 deletions src/components/Videos.vue
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ import logger from '../services/logger.js'
import { findLivePhotoPeerFromName } from '../utils/livePhotoUtils'
import { getPreviewIfAny } from '../utils/previewUtils'
import { preloadMedia } from '../services/mediaPreloader.js'
import { getPlyrTranslations, localizeSpeedLabels } from '../utils/plyrTranslations'

const VuePlyr = () => import(/* webpackChunkName: 'plyr' */'@skjnldsv/vue-plyr')

Expand All @@ -65,6 +66,7 @@ export default {
return {
isFullscreenButtonVisible: false,
fallback: false,
speedListenerBound: false,
}
},

Expand All @@ -88,6 +90,7 @@ export default {
blankVideo,
controls: ['play-large', 'play', 'progress', 'current-time', 'mute', 'volume', 'captions', 'settings', 'fullscreen'],
loadSprite: false,
i18n: getPlyrTranslations(this.t),
fullscreen: {
iosNative: true,
},
Expand Down Expand Up @@ -136,6 +139,15 @@ export default {
control.addEventListener('mouseenter', this.disableSwipe)
control.addEventListener('mouseleave', this.enableSwipe)
})

// The Plyr menu is only in the DOM once the controls are mounted (see
// above), so wire up the speed-label localization here rather than in
// mounted(). Register the rate-change listener once.
if (!this.speedListenerBound && this.$refs.plyr?.player) {
this.$refs.plyr.player.on('ratechange', this.localizeSpeed)
this.speedListenerBound = true
}
this.localizeSpeed()
},

beforeDestroy() {
Expand All @@ -147,6 +159,11 @@ export default {
},

methods: {
localizeSpeed() {
// Defer so we run after Plyr's own label/badge update for this event.
this.$nextTick(() => localizeSpeedLabels(this.$el, this.t))
},

hideHeaderAndFooter() {
// work arround to get the state of the fullscreen button, aria-selected attribute is not reliable
this.isFullscreenButtonVisible = !this.isFullscreenButtonVisible
Expand Down
103 changes: 103 additions & 0 deletions src/utils/plyrTranslations.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
/**
* SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

import { getCanonicalLocale } from '@nextcloud/l10n'

type TranslateFn = (app: string, text: string, ...args: unknown[]) => string

/**
* Build the Plyr `i18n` option so the player controls and menus
* (e.g. the settings/speed dropdown) are translated.
*
* Plyr ships English defaults only; passing this map makes it use the
* Nextcloud translations instead.
*
* @param t the Nextcloud translation function (`this.t` in components)
*/
export function getPlyrTranslations(t: TranslateFn): Record<string, string> {
return {
restart: t('viewer', 'Restart'),
rewind: t('viewer', 'Rewind {seektime}s'),
play: t('viewer', 'Play'),
pause: t('viewer', 'Pause'),
fastForward: t('viewer', 'Forward {seektime}s'),
seek: t('viewer', 'Seek'),
seekLabel: t('viewer', '{currentTime} of {duration}'),
played: t('viewer', 'Played'),
buffered: t('viewer', 'Buffered'),
currentTime: t('viewer', 'Current time'),
duration: t('viewer', 'Duration'),
volume: t('viewer', 'Volume'),
mute: t('viewer', 'Mute'),
unmute: t('viewer', 'Unmute'),
enableCaptions: t('viewer', 'Enable captions'),
disableCaptions: t('viewer', 'Disable captions'),
download: t('viewer', 'Download'),
enterFullscreen: t('viewer', 'Enter fullscreen'),
exitFullscreen: t('viewer', 'Exit fullscreen'),
frameTitle: t('viewer', 'Player for {title}'),
captions: t('viewer', 'Captions'),
settings: t('viewer', 'Settings'),
pip: t('viewer', 'PIP'),
menuBack: t('viewer', 'Go back to previous menu'),
speed: t('viewer', 'Speed'),
normal: t('viewer', 'Normal'),
quality: t('viewer', 'Quality'),
loop: t('viewer', 'Loop'),
start: t('viewer', 'Start'),
end: t('viewer', 'End'),
all: t('viewer', 'All'),
reset: t('viewer', 'Reset'),
disabled: t('viewer', 'Disabled'),
enabled: t('viewer', 'Enabled'),
advertisement: t('viewer', 'Ad'),
}
}

/**
* Plyr renders speed values via a plain template literal (`` `${speed}×` ``),
* which always uses a `.` decimal separator regardless of locale — so German
* shows `1.5×` instead of `1,5×`. Plyr exposes no hook for this, so we
* re-format the rendered labels using the user's locale.
*
* The speed panel's id always ends in `-speed`. Each entry is a
* `button[role="menuitemradio"]` carrying the numeric speed in its `value`
* attribute (the source of truth). The home-pane `.plyr__menu__value` badge
* shows the current speed as a bare `<number>×` string, which we match by
* pattern so we never touch the quality/captions badges.
*
* @param root the Plyr root element to localize labels within
* @param t the Nextcloud translation function (for the "Normal" label)
*/
export function localizeSpeedLabels(root: ParentNode, t: TranslateFn): void {
const formatter = new Intl.NumberFormat(getCanonicalLocale())
const speedLabel = (value: number): string =>
value === 1 ? t('viewer', 'Normal') : `${formatter.format(value)}×`

// Speed submenu radio items, scoped to the speed panel so we don't touch
// quality (e.g. "1080") or captions entries.
const items = root.querySelectorAll<HTMLButtonElement>(
'.plyr__menu__container [id$="-speed"] [role="menuitemradio"]',
)
items.forEach((item) => {
const value = Number.parseFloat(item.value)
if (Number.isNaN(value)) {
return
}
const label = item.querySelector('span')
if (label) {
label.textContent = speedLabel(value)
}
})

// Home-pane badge showing the current speed (e.g. "Speed: 1,5×"). Match the
// bare "<number>×" form so quality/captions badges are left untouched.
root.querySelectorAll<HTMLElement>('.plyr__menu__value').forEach((badge) => {
const match = /^(\d+(?:\.\d+)?)×$/.exec((badge.textContent ?? '').trim())
if (match) {
badge.textContent = `${formatter.format(Number.parseFloat(match[1]))}×`
}
})
}
Loading