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
73 changes: 0 additions & 73 deletions LICENSES/Apache-2.0.txt

This file was deleted.

6 changes: 0 additions & 6 deletions REUSE.toml
Original file line number Diff line number Diff line change
Expand Up @@ -46,9 +46,3 @@ path = ["stryker.config.json"]
precedence = "aggregate"
SPDX-FileCopyrightText = "2026 Nextcloud GmbH and Nextcloud contributors"
SPDX-License-Identifier = "AGPL-3.0-or-later"

[[annotations]]
path = ["lib/components/videojs/vendor/**"]
precedence = "aggregate"
SPDX-FileCopyrightText = "2025-present Video.js contributors"
SPDX-License-Identifier = "Apache-2.0"
78 changes: 39 additions & 39 deletions __tests__/component/media.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -39,44 +39,46 @@ vi.mock('@nextcloud/router', async (importOriginal) => ({
generateUrl: (url: string) => url,
}))

// The skins are Video.js markup, which jsdom cannot play: a stand-in keeps
// what the viewer relies on, the media in a container that takes the
// player's size, and a bar of controls the pointer can be over.
vi.mock('../../lib/components/videojs/VideoSkin.vue', async () => mockSkin('VideoSkinStub'))
vi.mock('../../lib/components/videojs/AudioSkin.vue', async () => mockSkin('AudioSkinStub'))
// Video.js itself is not loaded: jsdom cannot play media, and the tests
// stand in for its players and skins below
vi.mock('@videojs/html/video/player', () => ({}))
vi.mock('@videojs/html/video/skin', () => ({}))
vi.mock('@videojs/html/audio/player', () => ({}))
vi.mock('@videojs/html/audio/skin', () => ({}))
vi.mock('@videojs/html/i18n', () => ({ registerI18n: vi.fn() }))

// A skin draws its controls in an open shadow root, the one part of it the
// viewer reaches into: the controls bar and the speed menu
for (const tag of ['video-skin', 'audio-skin']) {
if (!customElements.get(tag)) {
customElements.define(tag, class extends HTMLElement {
constructor() {
super()
this.attachShadow({ mode: 'open' }).innerHTML = '<slot></slot><media-controls><button></button></media-controls><media-playback-rate-radio-group></media-playback-rate-radio-group>'
}
})
}
}

/**
* A skin stand-in, named so a test can find it and read what it is given.
* The controls bar inside the skin a wrapper renders.
*
* @param name - The component name
* @param wrapper - The mounted Videos or Audios
*/
async function mockSkin(name: string) {
const { defineComponent, h } = await import('vue')
return {
default: defineComponent({
name,
props: { stopped: Boolean, rate: { type: Number, default: 1 } },
render() {
return h('media-container', { class: 'media-skin' }, [
this.$slots.default?.(),
h('media-controls-content', [h('button', { class: 'media-button' })]),
])
},
}),
}
function controlsOf(wrapper: VueWrapper): Element {
return wrapper.find('video-skin, audio-skin').element.shadowRoot!.querySelector('media-controls')!
}

/** What the tests drive of a Video.js player store */
interface FakeStore {
paused: boolean
isFullscreen: boolean
playbackRate: number
target: object | undefined
pause: Mock
seek: Mock
subscribe(callback: () => void): () => void
/** Change the state and tell the subscribers, as the player does */
set(patch: Partial<Pick<FakeStore, 'paused' | 'isFullscreen' | 'playbackRate' | 'target'>>): void
set(patch: Partial<Pick<FakeStore, 'paused' | 'isFullscreen' | 'target'>>): void
}

/**
Expand All @@ -87,7 +89,6 @@ function makeStore(): FakeStore {
return {
paused: true,
isFullscreen: false,
playbackRate: 1,
target: {},
pause: vi.fn(),
seek: vi.fn(async () => 0),
Expand Down Expand Up @@ -127,6 +128,7 @@ import { useMediaPlayer } from '../../lib/composables/useMediaPlayer.ts'
import { useVideojsAdapter } from '../../lib/composables/useVideojsAdapter.ts'
import { logger } from '../../lib/services/logger.ts'
import { preloadImage, preloadMedia, preloadPreview } from '../../lib/services/mediaPreloader.ts'
import { formatRate } from '../../lib/utils/playerTranslations.ts'

const preloadMediaMock = vi.mocked(preloadMedia)
const preloadPreviewMock = vi.mocked(preloadPreview)
Expand Down Expand Up @@ -529,7 +531,7 @@ describe('Videos.vue (smoke)', () => {

await wrapper.find('video').trigger('loadedmetadata')

const skin = wrapper.find('media-container').element as HTMLElement
const skin = wrapper.find('video-skin').element as HTMLElement
expect(skin.style.width).toBe(width)
expect(skin.style.height).toBe(height)
})
Expand Down Expand Up @@ -563,7 +565,7 @@ describe('a video that has played to the end', () => {
// already buffered stay for the next play
expect(store.pause).toHaveBeenCalledOnce()
expect(store.seek).toHaveBeenCalledWith(0)
expect(wrapper.findComponent({ name: 'VideoSkinStub' }).props('stopped')).toBe(true)
expect(wrapper.find('.viewer-media__poster').attributes('src')).toBe(poster.encodedSource)
expect(video.load).not.toHaveBeenCalled()
expect(wrapper.find('video').attributes('poster')).toBe(poster.encodedSource)
})
Expand All @@ -576,7 +578,7 @@ describe('a video that has played to the end', () => {
store.set({ paused: false })
await nextTick()

expect(wrapper.findComponent({ name: 'VideoSkinStub' }).props('stopped')).toBe(false)
expect(wrapper.find('.viewer-media__poster').exists()).toBe(false)
})

it('says so, rather than throw, when it has neither a player nor a media element', () => {
Expand Down Expand Up @@ -646,8 +648,8 @@ describe('a video before it can play', () => {

expect(wrapper.emitted('loaded')).toHaveLength(1)
expect(wrapper.emitted('update:playing')).toEqual([[true]])
expect(wrapper.find('media-container').attributes('style')).toContain('width: 640px')
expect(wrapper.find('media-container').attributes('style')).toContain('height: 360px')
expect(wrapper.find('video-skin').attributes('style')).toContain('width: 640px')
expect(wrapper.find('video-skin').attributes('style')).toContain('height: 360px')

// Ready, but paused: a browser that refuses to autoplay does not
// hold the slideshow for good
Expand All @@ -671,8 +673,8 @@ describe('a video before it can play', () => {
const wrapper = await mountVideo({ hasPreview: false })

expect(wrapper.emitted('loaded')).toHaveLength(1)
expect(wrapper.find('media-container').attributes('style')).toContain('width: 360px')
expect(wrapper.find('media-container').attributes('style')).toContain('height: 640px')
expect(wrapper.find('video-skin').attributes('style')).toContain('width: 360px')
expect(wrapper.find('video-skin').attributes('style')).toContain('height: 640px')
})

it('shows the player once, when its metadata comes after its preview', async () => {
Expand Down Expand Up @@ -851,7 +853,7 @@ describe('the listeners on the player controls', () => {
// The viewer resizes the handler by handing it new bounds, and the
// first of those is what the controls are bound on
await wrapper.setProps({ maxWidth: 900 })
const controls = wrapper.findAll('media-controls-content').map((control) => control.element)
const controls = [controlsOf(wrapper)]
const bind = controls.map((control) => vi.spyOn(control, 'addEventListener'))

await wrapper.setProps({ maxWidth: 800 })
Expand All @@ -867,7 +869,7 @@ describe('the listeners on the player controls', () => {
await flushPromises()
await wrapper.setProps({ maxWidth: 900 })

const controls = wrapper.findAll('media-controls-content').map((control) => control.element)
const controls = [controlsOf(wrapper)]
const unbind = controls.map((control) => vi.spyOn(control, 'removeEventListener'))

wrapper.unmount()
Expand All @@ -885,16 +887,14 @@ describe('Audios.vue (smoke)', () => {
expect(wrapper.find('audio').exists()).toBe(true)
})

// The speed button writes the rate itself, in the user's locale
it('hands the speed button the rate the player plays at', async () => {
// Video.js writes `1.5×` whatever the language
it('writes the speeds in the user language', async () => {
const file = makeFile({ basename: 'song.mp3', mime: 'audio/mpeg' })
const wrapper = mount(Audios, { props: makeProps({ file, files: [file] }) })
await flushPromises()

storeOf(wrapper).set({ playbackRate: 1.5 })
await nextTick()

expect(wrapper.findComponent({ name: 'AudioSkinStub' }).props('rate')).toBe(1.5)
const group = wrapper.find('audio-skin').element.shadowRoot!.querySelector('media-playback-rate-radio-group') as HTMLElement & { formatRate?: unknown }
expect(group.formatRate).toBe(formatRate)
})
})

Expand Down
10 changes: 3 additions & 7 deletions __tests__/playerTranslations.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,9 @@ describe('the player translations', () => {
})

// Video.js wants a BCP 47 tag, the server hands out `pt_BR`
it('are registered for the user language, as Video.js writes it', async () => {
it('are registered once imported, for the user language as Video.js writes it', async () => {
setLanguage('pt_BR')
const { playerLanguage, registerPlayerTranslations } = await importTranslations()

registerPlayerTranslations()
const { playerLanguage } = await importTranslations()

expect(playerLanguage).toBe('pt-BR')
expect(registerI18n).toHaveBeenCalledOnce()
Expand All @@ -37,9 +35,7 @@ describe('the player translations', () => {

// Placeholders are filled in by Video.js, so they must survive our t()
it('keep the placeholders Video.js fills in', async () => {
const { registerPlayerTranslations } = await importTranslations()

registerPlayerTranslations()
await importTranslations()

const translations = registerI18n.mock.calls[0]![1]
expect(translations.buttons.play).toBe('Play')
Expand Down
4 changes: 2 additions & 2 deletions build/videojsElements.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,14 +6,14 @@ import type { Api } from '@vitejs/plugin-vue'
import type { Plugin } from 'vite'

/**
* Whether a tag of the media skins is a Video.js element rather than a
* Whether a tag of the media players is a Video.js element rather than a
* Vue component to resolve. Our own components are all PascalCase, so the
* `media-` prefix cannot hide a misspelled one.
*
* @param tag - The tag as written in a template
*/
export function isVideojsElement(tag: string): boolean {
return tag.startsWith('media-') || tag === 'video-player' || tag === 'audio-player'
return tag.startsWith('media-') || /^(video|audio)-(player|skin)$/.test(tag)
}

/**
Expand Down
Loading
Loading