Skip to content
Merged
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
9 changes: 5 additions & 4 deletions __tests__/component/media.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ vi.mock('../../lib/utils/isoMedia.ts', () => ({ probeFile }))
const axiosGet = vi.hoisted(() => vi.fn(async () => ({ data: '<svg/>' })))
vi.mock('@nextcloud/axios', () => ({ default: { get: axiosGet } }))

// imagePath is evaluated at module load of usePlyrPlayer (blank.mp4). Keep the
// imagePath is evaluated at module load of useMediaPlayer (blank.mp4). Keep the
// rest of the router real; only pin the two URL helpers so tests never depend on
// the OC bootstrap globals.
vi.mock('@nextcloud/router', async (importOriginal) => ({
Expand Down Expand Up @@ -99,7 +99,8 @@ vi.mock('@skjnldsv/vue-plyr', async () => {
import Audios from '../../lib/components/Audios.vue'
import Images from '../../lib/components/Images.vue'
import Videos from '../../lib/components/Videos.vue'
import { usePlyrPlayer } from '../../lib/composables/usePlyrPlayer.ts'
import { useMediaPlayer } from '../../lib/composables/useMediaPlayer.ts'
import { usePlyrAdapter } from '../../lib/composables/usePlyrAdapter.ts'
import { logger } from '../../lib/services/logger.ts'
import { preloadImage, preloadMedia, preloadPreview } from '../../lib/services/mediaPreloader.ts'

Expand Down Expand Up @@ -590,7 +591,7 @@ describe('a video that has played to the end', () => {
setup() {
const file = makeFile({ basename: 'clip.mp4', mime: 'video/mp4' })
// No template refs, so neither plyr nor the element ever arrive
donePlaying = usePlyrPlayer(false, makeProps({ file, files: [file] }), (() => {}) as never).donePlaying
donePlaying = useMediaPlayer(false, makeProps({ file, files: [file] }), (() => {}) as never, usePlyrAdapter(false)).donePlaying
return () => h('div')
},
})
Expand Down Expand Up @@ -837,7 +838,7 @@ describe('a player torn down early', () => {
const Host = defineComponent({
setup() {
const file = makeFile({ basename: 'clip.mp4', mime: 'video/mp4' })
usePlyrPlayer(false, makeProps({ file, files: [file] }), (() => {}) as never)
useMediaPlayer(false, makeProps({ file, files: [file] }), (() => {}) as never, usePlyrAdapter(false))
// No `plyr` ref in the template, so the player never arrives
return () => h('div')
},
Expand Down
9 changes: 5 additions & 4 deletions lib/components/Audios.vue
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
-->

<template>
<!-- eslint-disable vue/no-unused-refs -- plyr/audio refs are consumed by usePlyrPlayer via useTemplateRef -->
<!-- eslint-disable vue/no-unused-refs -- the plyr ref is consumed by usePlyrAdapter, the media one by useMediaPlayer, via useTemplateRef -->
<!-- Plyr currently replaces the parent. Wrapping to prevent this
https://github.com/redxtech/vue-plyr/issues/259 -->
<div>
Expand Down Expand Up @@ -38,7 +38,8 @@
import type { ViewerEmits, ViewerProps } from '../viewer.ts'

import VuePlyr from '@skjnldsv/vue-plyr'
import { usePlyrPlayer } from '../composables/usePlyrPlayer.ts'
import { useMediaPlayer } from '../composables/useMediaPlayer.ts'
import { usePlyrAdapter } from '../composables/usePlyrAdapter.ts'
import { t } from '../utils/l10n.ts'

defineOptions({
Expand All @@ -48,15 +49,15 @@ defineOptions({
const props = defineProps<ViewerProps>()
const emit = defineEmits<ViewerEmits>()

const { options, ...player } = usePlyrAdapter(true)
const {
onFail,
donePlaying,
doneLoading,
onPause,
onPlay,
options,
src,
} = usePlyrPlayer(true, props, emit)
} = useMediaPlayer(true, props, emit, player)

</script>

Expand Down
9 changes: 5 additions & 4 deletions lib/components/Videos.vue
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
-->

<template>
<!-- eslint-disable vue/no-unused-refs -- plyr/audio refs are consumed by usePlyrPlayer via useTemplateRef -->
<!-- eslint-disable vue/no-unused-refs -- the plyr ref is consumed by usePlyrAdapter, the media one by useMediaPlayer, via useTemplateRef -->
<!-- Plyr currently replaces the parent. Wrapping to prevent this
https://github.com/redxtech/vue-plyr/issues/259 -->
<div>
Expand Down Expand Up @@ -50,7 +50,8 @@ import type { ViewerEmits, ViewerProps } from '../viewer.ts'

import VuePlyr from '@skjnldsv/vue-plyr'
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { usePlyrPlayer } from '../composables/usePlyrPlayer.ts'
import { useMediaPlayer } from '../composables/useMediaPlayer.ts'
import { usePlyrAdapter } from '../composables/usePlyrAdapter.ts'
import { logger } from '../services/logger.ts'
import { preloadImageSize } from '../services/mediaPreloader.ts'
import { canDownload } from '../utils/canDownload.ts'
Expand All @@ -66,17 +67,17 @@ defineOptions({
const props = defineProps<ViewerProps>()
const emit = defineEmits<ViewerEmits>()

const { options, ...player } = usePlyrAdapter(false)
const {
video,
onFail,
donePlaying,
doneLoading,
onPause,
onPlay,
options,
showBeforePlayable,
src,
} = usePlyrPlayer(false, props, emit)
} = useMediaPlayer(false, props, emit, player)

const height = ref(0)
const width = ref(0)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,16 +3,14 @@
* SPDX-License-Identifier: AGPL-3.0-or-later
*/

import type Plyr from 'plyr'
import type { EmitFn } from 'vue'
import type { ViewerEmits, ViewerProps } from '../viewer.ts'

import { computed, onBeforeUnmount, onUpdated, ref, useTemplateRef, watch } from 'vue'
import { onBeforeUnmount, onUpdated, ref, useTemplateRef, watch } from 'vue'
import blankVideo from '../img/blank.mp4'
import { logger } from '../services/logger.ts'
import { preloadMedia } from '../services/mediaPreloader.ts'
import { t } from '../utils/l10n.ts'
import { localizeSpeedLabels, plyrTranslations } from '../utils/plyrTranslations.ts'
import { useViewerProps } from './useViewerProps.ts'

/** Marks the page furniture the viewer hides around a full screen player */
Expand All @@ -21,18 +19,31 @@ const HIDDEN_FULLSCREEN_CLASS = 'viewer__hidden-fullscreen'
/** `MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED`, which not every environment defines */
const MEDIA_ERR_SRC_NOT_SUPPORTED = 4

/** What the media player needs of the library drawing the controls */
export interface MediaPlayerAdapter {
/** Called once the media can play */
ready(): void
/** Go back to the start, paused, with the poster over it. False when there is no player yet */
stop(): boolean
/** Be told of every change of full screen, however it came about */
onFullscreenChange(callback: (fullscreen: boolean) => void): void
/** The controls, which the pointer can be over, or none if not drawn yet */
controls(): Element[]
/** Take the player down with the component */
destroy(): void
}

/**
* Composable to setup a Plyr player instance.
* Composable to play a media file in the viewer.
*
* @param forAudio Whether the player is used for audio files
* @param props The viewer component props (filename, source, etc.)
* @param emit The component emit function for viewer events
* @param player The library drawing the controls
*/
export function usePlyrPlayer(forAudio: boolean, props: ViewerProps, emit: EmitFn<ViewerEmits>) {
export function useMediaPlayer(forAudio: boolean, props: ViewerProps, emit: EmitFn<ViewerEmits>, player: MediaPlayerAdapter) {
const { filename, src } = useViewerProps(props)

const plyr = useTemplateRef<{ player: Plyr, $el: HTMLElement }>('plyr')
const player = computed<Plyr | undefined>(() => plyr.value?.player as Plyr | undefined)
const video = useTemplateRef<HTMLVideoElement>('video')
const audio = useTemplateRef<HTMLAudioElement>('audio')

Expand All @@ -56,35 +67,12 @@ export function usePlyrPlayer(forAudio: boolean, props: ViewerProps, emit: EmitF
holding = false
})

const options = computed(() => {
return {
autoplay: true,
// Plyr labels its own controls, in English, unless given these
i18n: plyrTranslations,
// Used to reset the video streams https://github.com/sampotts/plyr#javascript-1
blankVideo,
controls: [
'play-large',
'play',
'progress',
'current-time',
'mute',
'volume',
...forAudio ? ['settings'] : ['captions', 'settings', 'fullscreen'],
],
loadSprite: false,
fullscreen: {
iosNative: true,
},
}
})

/**
* Tell Viewer that the video is ready to be shown
*/
function doneLoading() {
playable = true
relabelSpeed()
player.ready()
show()
release()
}
Expand Down Expand Up @@ -129,31 +117,16 @@ export function usePlyrPlayer(forAudio: boolean, props: ViewerProps, emit: EmitF
}
}

/**
* Relabel the speed menu in the user's locale.
*
* It is built from numbers plyr formats itself, which its i18n does not
* reach: once the controls exist, and again after every change of speed,
* as plyr writes the chosen one back as it formats it (`1.5×` in German).
*/
function relabelSpeed() {
const root = (forAudio ? audio : video).value?.closest('.plyr')
if (root) {
localizeSpeedLabels(root)
}
}

/**
* Go back to the start once the media has played, showing its poster again.
*
* Rewound and paused rather than reloaded: plyr shows the poster over a
* player stopped at the start, and what the element already buffered is
* kept. Reloading it brought the poster back too, but every replay then
* Rewound and paused rather than reloaded: the player shows the poster
* over media stopped at the start, and what the element already buffered
* is kept. Reloading it brought the poster back too, but every replay then
* downloaded the whole file again (nextcloud/viewer#2585).
*/
function donePlaying() {
if (player.value) {
player.value.stop()
if (player.stop()) {
return
}

Expand Down Expand Up @@ -228,22 +201,11 @@ export function usePlyrPlayer(forAudio: boolean, props: ViewerProps, emit: EmitF
}
}

const onEnterFullscreen = () => setPageHidden(true)
const onExitFullscreen = () => setPageHidden(false)

// What plyr says about its own full screen, rather than a count of clicks
// on the button: the user also leaves full screen with Escape or the
// browser's own control, and a count is then one behind for good, leaving
// the header hidden on a page that is not full screen any more. And its
// changes of speed, which put back the label in plyr's own formatting.
watch(player, (instance, previous) => {
previous?.off('enterfullscreen', onEnterFullscreen)
previous?.off('exitfullscreen', onExitFullscreen)
previous?.off('ratechange', relabelSpeed)
instance?.on('enterfullscreen', onEnterFullscreen)
instance?.on('exitfullscreen', onExitFullscreen)
instance?.on('ratechange', relabelSpeed)
}, { immediate: true })
// What the player says about its own full screen, rather than a count of
// clicks on the button: the user also leaves full screen with Escape or
// the browser's own control, and a count is then one behind for good,
// leaving the header hidden on a page that is not full screen any more.
player.onFullscreenChange(setPageHidden)

// Stable handler references so listeners can be removed again and are never
// bound more than once, even though onUpdated may run many times.
Expand All @@ -254,16 +216,6 @@ export function usePlyrPlayer(forAudio: boolean, props: ViewerProps, emit: EmitF
const onPlay = () => emit('update:playing', true)
const onPause = () => emit('update:playing', false)

/**
* Get the current plyr control items, or an empty array if not ready.
*/
function getPlyrControls(): Element[] {
if (!plyr.value?.player || !plyr.value.$el) {
return []
}
return Array.from(plyr.value.$el.querySelectorAll('.plyr__controls__item'))
}

/** The controls the listeners are on, so they go on once. */
let boundControls: Element[] = []

Expand All @@ -282,26 +234,26 @@ export function usePlyrPlayer(forAudio: boolean, props: ViewerProps, emit: EmitF
// the dom until after the component (Videos) is mounted,
// using the mounted() hook will leave us with an empty array
onUpdated(() => {
const plyrControls = getPlyrControls()
if (plyrControls.length === 0) {
logger.warn('Plyr player not initialized yet')
const controls = player.controls()
if (controls.length === 0) {
logger.warn('Media player not initialized yet')
return
}

// Every prop the viewer hands over runs this, a resize a great many
// times over, and the controls are the same elements throughout:
// leave them alone unless plyr has actually rebuilt them.
if (plyrControls.length === boundControls.length && plyrControls.every((control, index) => control === boundControls[index])) {
// leave them alone unless the player has actually rebuilt them.
if (controls.length === boundControls.length && controls.every((control, index) => control === boundControls[index])) {
return
}
unbindControls()

// Prevent swiping to the next/previous item when scrubbing the timeline or changing volume.
plyrControls.forEach((control) => {
controls.forEach((control) => {
control.addEventListener('mouseenter', disableSwipe)
control.addEventListener('mouseleave', enableSwipe)
})
boundControls = plyrControls
boundControls = controls
})

onBeforeUnmount(() => {
Expand All @@ -314,10 +266,7 @@ export function usePlyrPlayer(forAudio: boolean, props: ViewerProps, emit: EmitF
// Force stop any ongoing request
logger.debug('Closing media stream', { filename: props.file.basename })
video?.value?.pause?.()
// Guarded: a component torn down before plyr got as far as a player,
// which a quick close is enough for, has nothing to stop
player.value?.stop()
player.value?.destroy()
player.destroy()
})

return {
Expand All @@ -326,7 +275,6 @@ export function usePlyrPlayer(forAudio: boolean, props: ViewerProps, emit: EmitF
onFail,
onPause,
onPlay,
options,
showBeforePlayable,
// The source the element must show: the fallback replaces it here
src,
Expand Down
Loading
Loading