mirror of
https://github.com/zhw2590582/ArtPlayer.git
synced 2026-10-13 07:46:17 -08:00
refactor(core): CORE-16 type display modes and own fullscreen lifetimes
This commit is contained in:
1 parent
1b83081e8f
commit
88aef830df
81 files changed
+33897
-1503
No files matched your search
@@ -0,0 +1,285 @@
|
||||
import { expect, test } from './fixtures.js'
|
||||
|
||||
async function setup(page, core) {
|
||||
await page.goto(`/test/player.html?core=${core}&chapter=published`)
|
||||
await page.evaluate(() => {
|
||||
window.createPlayer('/test/pattern.mp4')
|
||||
window.miniEvents = []
|
||||
window.art.on('mini', value => window.miniEvents.push(value))
|
||||
const video = window.art.template.$video
|
||||
const parent = document.createElement('div')
|
||||
parent.id = 'original-video-parent'
|
||||
const next = document.createElement('span')
|
||||
next.id = 'original-video-next'
|
||||
video.before(parent)
|
||||
parent.append(video, next)
|
||||
})
|
||||
await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
|
||||
}
|
||||
|
||||
for (const core of ['published', 'candidate']) {
|
||||
test(`${core}: mini retains first-entry and reuse DOM order and display`, async ({ page }) => {
|
||||
await setup(page, core)
|
||||
expect(await page.evaluate(() => {
|
||||
const art = window.art
|
||||
art.mini = true
|
||||
const popup = art.template.$mini
|
||||
const first = popup.firstChild === art.template.$video
|
||||
const display = popup.style.display
|
||||
art.mini = true
|
||||
const result = { first, display, last: popup.lastChild === art.template.$video, reusedDisplay: popup.style.display }
|
||||
art.destroy()
|
||||
return result
|
||||
})).toEqual({ first: true, display: '', last: true, reusedDisplay: 'flex' })
|
||||
})
|
||||
|
||||
test(`${core}: mini restores the original video parent and sibling after repeated entry`, async ({ page }) => {
|
||||
await setup(page, core)
|
||||
expect(await page.evaluate(() => {
|
||||
const art = window.art
|
||||
art.mini = true
|
||||
const popup = art.template.$mini
|
||||
art.mini = true
|
||||
art.mini = false
|
||||
const result = { restored: art.template.$video.parentElement.id === 'original-video-parent', next: art.template.$video.nextSibling?.id === 'original-video-next', reused: popup === art.template.$mini, hidden: popup.style.display, events: [...window.miniEvents] }
|
||||
art.destroy()
|
||||
return result
|
||||
})).toEqual({ restored: core === 'candidate', next: core === 'candidate', reused: true, hidden: 'none', events: [true, true, false] })
|
||||
})
|
||||
|
||||
for (const removeHtml of [false, true]) {
|
||||
test(`${core}: destroying active mini removeHtml=${removeHtml} removes the owned popup`, async ({ page }) => {
|
||||
await setup(page, core)
|
||||
expect(await page.evaluate((removeHtml) => {
|
||||
const art = window.art
|
||||
art.mini = true
|
||||
const popup = art.template.$mini
|
||||
const video = art.template.$video
|
||||
window.miniEvents.length = 0
|
||||
art.destroy(removeHtml)
|
||||
return { popupConnected: popup.isConnected, videoInPopup: popup.contains(video), events: window.miniEvents }
|
||||
}, removeHtml)).toEqual({ popupConnected: core === 'published', videoInPopup: core === 'published', events: [] })
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
test('candidate: hiding mini cancels drag and later document events cannot persist a hidden popup', async ({ page }) => {
|
||||
await setup(page, 'candidate')
|
||||
const result = await page.evaluate(() => {
|
||||
const art = window.art
|
||||
art.mini = true
|
||||
const popup = art.template.$mini
|
||||
const original = { left: art.storage.get('left'), top: art.storage.get('top') }
|
||||
popup.dispatchEvent(new MouseEvent('mousedown', { button: 0, clientX: 20, clientY: 20 }))
|
||||
art.emit('document:mousemove', new MouseEvent('mousemove', { clientX: 40, clientY: 50 }))
|
||||
art.mini = false
|
||||
art.emit('document:mouseup', new MouseEvent('mouseup'))
|
||||
const result = { original, after: { left: art.storage.get('left'), top: art.storage.get('top') }, dragging: popup.classList.contains('art-mini-dragging'), transform: popup.style.transform }
|
||||
art.destroy()
|
||||
return result
|
||||
})
|
||||
expect(result.after).toEqual(result.original)
|
||||
expect(result.dragging).toBe(false)
|
||||
expect(result.transform).toBe('')
|
||||
})
|
||||
|
||||
test('candidate: mini remains in the viewport on small screens', async ({ page }) => {
|
||||
await page.setViewportSize({ width: 280, height: 160 })
|
||||
await setup(page, 'candidate')
|
||||
await page.evaluate(() => {
|
||||
window.art.storage.set('left', 9000)
|
||||
window.art.storage.set('top', 9000)
|
||||
window.art.mini = true
|
||||
})
|
||||
const rect = await page.locator('.art-mini-popup').boundingBox()
|
||||
expect(rect.x).toBeGreaterThanOrEqual(0)
|
||||
expect(rect.y).toBeGreaterThanOrEqual(0)
|
||||
expect(rect.x + rect.width).toBeLessThanOrEqual(280)
|
||||
expect(rect.y + rect.height).toBeLessThanOrEqual(160)
|
||||
await page.evaluate(() => window.art.destroy())
|
||||
})
|
||||
|
||||
test('candidate: mini drag stores the actual position and playback buttons remain usable', async ({ page }) => {
|
||||
await setup(page, 'candidate')
|
||||
await page.evaluate(() => window.art.mini = true)
|
||||
const popup = page.locator('.art-mini-popup')
|
||||
const rect = await popup.boundingBox()
|
||||
await page.mouse.move(rect.x + 30, rect.y + 20)
|
||||
await page.mouse.down()
|
||||
await page.mouse.move(rect.x - 30, rect.y - 20)
|
||||
await page.mouse.up()
|
||||
expect(await page.evaluate(() => {
|
||||
const art = window.art
|
||||
const rect = art.template.$mini.getBoundingClientRect()
|
||||
return Math.abs(art.storage.get('left') - rect.left) < 1 && Math.abs(art.storage.get('top') - rect.top) < 1
|
||||
})).toBe(true)
|
||||
await popup.locator('.art-icon-play').click()
|
||||
await expect.poll(() => page.evaluate(() => window.art.playing && window.art.currentTime > 0)).toBe(true)
|
||||
await popup.locator('.art-icon-pause').click()
|
||||
await expect.poll(() => page.evaluate(() => window.art.template.$video.paused)).toBe(true)
|
||||
await popup.locator('.art-mini-close').click()
|
||||
await expect(popup).toBeHidden()
|
||||
expect(await page.evaluate(() => window.art.template.$video.parentElement.id)).toBe('original-video-parent')
|
||||
await page.evaluate(() => window.art.destroy())
|
||||
})
|
||||
|
||||
test('candidate: mini exit failure keeps the active video and can restore on retry', async ({ page }) => {
|
||||
await setup(page, 'candidate')
|
||||
expect(await page.evaluate(() => {
|
||||
const art = window.art
|
||||
const parent = art.template.$video.parentNode
|
||||
art.mini = true
|
||||
const insert = parent.insertBefore
|
||||
const failure = new Error('controlled mini restore failure')
|
||||
parent.insertBefore = () => {
|
||||
throw failure
|
||||
}
|
||||
let rejected = false
|
||||
try {
|
||||
art.mini = false
|
||||
}
|
||||
catch (error) { rejected = error === failure }
|
||||
const active = art.mini && art.template.$mini.contains(art.template.$video)
|
||||
parent.insertBefore = insert
|
||||
art.mini = false
|
||||
const result = { rejected, active, restored: art.template.$video.parentNode === parent }
|
||||
art.destroy()
|
||||
return result
|
||||
})).toEqual({ rejected: true, active: true, restored: true })
|
||||
})
|
||||
|
||||
test('candidate: mini reentry during restoration retains the new placement snapshot', async ({ page }) => {
|
||||
await setup(page, 'candidate')
|
||||
expect(await page.evaluate(() => {
|
||||
const art = window.art
|
||||
const parent = art.template.$video.parentNode
|
||||
const insert = parent.insertBefore
|
||||
art.mini = true
|
||||
parent.insertBefore = function (...args) {
|
||||
const result = insert.apply(this, args)
|
||||
parent.insertBefore = insert
|
||||
art.mini = true
|
||||
return result
|
||||
}
|
||||
art.mini = false
|
||||
const active = art.mini
|
||||
art.mini = false
|
||||
const result = { active, restored: art.template.$video.parentNode === parent, events: [...window.miniEvents] }
|
||||
art.destroy()
|
||||
return result
|
||||
})).toEqual({ active: true, restored: true, events: [true, true, false] })
|
||||
})
|
||||
|
||||
for (const action of ['hide', 'destroy', 'fail']) {
|
||||
test(`candidate: mini construction ${action} releases incomplete UI and restores the video`, async ({ page }) => {
|
||||
await setup(page, 'candidate')
|
||||
expect(await page.evaluate((action) => {
|
||||
const art = window.art
|
||||
const icons = art.icons
|
||||
const failure = new Error('controlled mini icon failure')
|
||||
art.icons = {
|
||||
get close() {
|
||||
if (action === 'hide')
|
||||
art.mini = false
|
||||
else if (action === 'destroy')
|
||||
art.destroy(false)
|
||||
else
|
||||
throw failure
|
||||
return icons.close
|
||||
},
|
||||
get play() { return icons.play },
|
||||
get pause() { return icons.pause },
|
||||
}
|
||||
let rejected = false
|
||||
try {
|
||||
art.mini = true
|
||||
}
|
||||
catch (error) {
|
||||
rejected = error === failure
|
||||
}
|
||||
const restored = art.template.$video.parentElement.id === 'original-video-parent'
|
||||
const active = art.mini
|
||||
let retry = null
|
||||
if (action !== 'destroy') {
|
||||
art.icons = icons
|
||||
art.mini = true
|
||||
retry = art.mini && document.querySelectorAll('.art-mini-popup').length === 1
|
||||
art.destroy()
|
||||
}
|
||||
return { restored, active, rejected, retry, remaining: document.querySelectorAll('.art-mini-popup').length }
|
||||
}, action)).toEqual({ restored: true, active: false, rejected: action === 'fail', retry: action === 'destroy' ? null : true, remaining: 0 })
|
||||
})
|
||||
}
|
||||
|
||||
test('candidate: mini preserves a caller-owned popup on destruction', async ({ page }) => {
|
||||
await setup(page, 'candidate')
|
||||
expect(await page.evaluate(() => {
|
||||
const art = window.art
|
||||
const popup = document.createElement('div')
|
||||
popup.style.cssText = 'display: grid; width: 320px; height: 180px;'
|
||||
document.body.append(popup)
|
||||
art.template.$mini = popup
|
||||
art.mini = true
|
||||
art.destroy(false)
|
||||
const result = { connected: popup.isConnected, display: popup.style.display, restored: art.template.$video.parentElement.id === 'original-video-parent', empty: popup.childNodes.length === 0 }
|
||||
popup.remove()
|
||||
return result
|
||||
})).toEqual({ connected: true, display: 'grid', restored: true, empty: true })
|
||||
})
|
||||
|
||||
test('candidate: reentrant mini creation allocates only one owned popup', async ({ page }) => {
|
||||
await setup(page, 'candidate')
|
||||
expect(await page.evaluate(() => {
|
||||
const art = window.art
|
||||
const icons = art.icons
|
||||
let reentered = false
|
||||
art.icons = {
|
||||
get close() {
|
||||
if (!reentered) {
|
||||
reentered = true
|
||||
art.mini = true
|
||||
}
|
||||
return icons.close
|
||||
},
|
||||
get play() { return icons.play },
|
||||
get pause() { return icons.pause },
|
||||
}
|
||||
art.mini = true
|
||||
const result = { popups: document.querySelectorAll('.art-mini-popup').length, ownVideo: art.template.$mini.contains(art.template.$video), events: [...window.miniEvents] }
|
||||
art.destroy()
|
||||
return result
|
||||
})).toEqual({ popups: 1, ownVideo: true, events: [true] })
|
||||
})
|
||||
|
||||
test('candidate: mini switches to body web fullscreen and restores the original video placement', async ({ page }) => {
|
||||
await setup(page, 'candidate')
|
||||
expect(await page.evaluate(() => {
|
||||
const art = window.art
|
||||
window.Artplayer.FULLSCREEN_WEB_IN_BODY = true
|
||||
art.mini = true
|
||||
art.fullscreenWeb = true
|
||||
const switched = !art.mini && art.fullscreenWeb && art.template.$mini.style.display === 'none'
|
||||
art.fullscreenWeb = false
|
||||
const restored = art.template.$video.parentElement.id === 'original-video-parent'
|
||||
art.mini = true
|
||||
art.mini = false
|
||||
const events = [...window.miniEvents]
|
||||
art.destroy()
|
||||
return { switched, restored, events }
|
||||
})).toEqual({ switched: true, restored: true, events: [true, false, true, false] })
|
||||
})
|
||||
|
||||
test('candidate: destroying a mini drag removes its document subscriptions', async ({ page }) => {
|
||||
await setup(page, 'candidate')
|
||||
expect(await page.evaluate(() => {
|
||||
const art = window.art
|
||||
art.mini = true
|
||||
art.template.$mini.dispatchEvent(new MouseEvent('mousedown', { button: 0, clientX: 10, clientY: 10 }))
|
||||
art.destroy(false)
|
||||
let writes = 0
|
||||
art.storage.set = () => writes++
|
||||
art.emit('document:mousemove', new MouseEvent('mousemove', { clientX: 100, clientY: 100 }))
|
||||
art.emit('document:mouseup', new MouseEvent('mouseup'))
|
||||
return writes
|
||||
})).toBe(0)
|
||||
})
|
||||
@@ -0,0 +1,251 @@
|
||||
import { expect, test } from './fixtures.js'
|
||||
|
||||
async function setup(page, core) {
|
||||
await page.goto(`/test/player.html?core=${core}&chapter=published`)
|
||||
await page.evaluate(() => {
|
||||
const maps = [
|
||||
['requestFullscreen', 'exitFullscreen', 'fullscreenElement', 'fullscreenchange'],
|
||||
['webkitRequestFullscreen', 'webkitExitFullscreen', 'webkitFullscreenElement', 'webkitfullscreenchange'],
|
||||
['webkitRequestFullScreen', 'webkitCancelFullScreen', 'webkitCurrentFullScreenElement', 'webkitfullscreenchange'],
|
||||
['mozRequestFullScreen', 'mozCancelFullScreen', 'mozFullScreenElement', 'mozfullscreenchange'],
|
||||
['msRequestFullscreen', 'msExitFullscreen', 'msFullscreenElement', 'MSFullscreenChange'],
|
||||
]
|
||||
window.fullscreenAPI = maps.find(([, exit]) => exit in document)
|
||||
window.createPlayer('/test/pattern.mp4')
|
||||
window.art.template.$container.id = 'native-fullscreen-main'
|
||||
const container = document.createElement('div')
|
||||
Object.assign(container.style, { width: '320px', height: '180px' })
|
||||
document.body.append(container)
|
||||
window.other = new window.Artplayer({ container, url: '/test/pattern.mp4', muted: true })
|
||||
window.fullscreenEvents = [[], []]
|
||||
window.art.on('fullscreen', value => window.fullscreenEvents[0].push(value))
|
||||
window.other.on('fullscreen', value => window.fullscreenEvents[1].push(value))
|
||||
document.querySelector('#play').onclick = () => {
|
||||
window.art.fullscreen = true
|
||||
}
|
||||
})
|
||||
await expect.poll(() => page.evaluate(() => window.art.isReady && window.other.isReady)).toBe(true)
|
||||
expect(await page.evaluate(() => Boolean(window.fullscreenAPI))).toBe(true)
|
||||
}
|
||||
|
||||
test('candidate: void exit waits for the owned video to leave and accepts another owner as completion', async ({ page }) => {
|
||||
await setup(page, 'candidate')
|
||||
expect(await page.evaluate(async () => {
|
||||
const art = window.art
|
||||
const [, exitName, elementName, eventName] = window.fullscreenAPI
|
||||
const elementDescriptor = Object.getOwnPropertyDescriptor(document, elementName)
|
||||
const originalExit = document[exitName]
|
||||
let element = art.template.$video
|
||||
Object.defineProperty(document, elementName, { configurable: true, get: () => element })
|
||||
document.dispatchEvent(new Event(eventName))
|
||||
document[exitName] = () => undefined
|
||||
let settled = false
|
||||
const result = Object.getOwnPropertyDescriptor(art, 'fullscreen').set(false).then(() => settled = true)
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
const early = settled
|
||||
element = window.other.template.$player
|
||||
document.dispatchEvent(new Event(eventName))
|
||||
await result
|
||||
const completed = settled && !art.fullscreen && window.other.fullscreen
|
||||
element = null
|
||||
document.dispatchEvent(new Event(eventName))
|
||||
if (elementDescriptor)
|
||||
Object.defineProperty(document, elementName, elementDescriptor)
|
||||
else
|
||||
delete document[elementName]
|
||||
document[exitName] = originalExit
|
||||
art.destroy()
|
||||
window.other.destroy()
|
||||
return { early, completed }
|
||||
})).toEqual({ early: false, completed: true })
|
||||
})
|
||||
|
||||
for (const core of ['published', 'candidate']) {
|
||||
test(`${core}: native fullscreen belongs to its player and other players cannot exit it`, async ({ page }) => {
|
||||
await setup(page, core)
|
||||
await page.click('#play')
|
||||
await expect.poll(() => page.evaluate(() => document[window.fullscreenAPI[2]] === window.art.template.$player)).toBe(true)
|
||||
await expect.poll(() => page.evaluate(() => window.fullscreenEvents[0])).toEqual([true])
|
||||
expect(await page.evaluate(() => ({ own: window.art.fullscreen, other: window.other.fullscreen, className: window.other.template.$player.classList.contains('art-fullscreen'), otherEvents: window.fullscreenEvents[1] }))).toEqual({ own: true, other: core === 'published', className: core === 'published', otherEvents: core === 'published' ? [true] : [] })
|
||||
await page.evaluate(async () => {
|
||||
await Object.getOwnPropertyDescriptor(window.other, 'fullscreen').set.call(window.other, false)
|
||||
})
|
||||
await expect.poll(() => page.evaluate(() => Boolean(document[window.fullscreenAPI[2]]))).toBe(core === 'candidate')
|
||||
await page.evaluate(async () => {
|
||||
if (document[window.fullscreenAPI[2]])
|
||||
await document[window.fullscreenAPI[1]]()
|
||||
window.art.destroy()
|
||||
window.other.destroy()
|
||||
})
|
||||
})
|
||||
|
||||
test(`${core}: destroyed players stop receiving later native fullscreen events`, async ({ page }) => {
|
||||
await setup(page, core)
|
||||
await page.evaluate(() => window.other.destroy(false))
|
||||
await page.click('#play')
|
||||
await expect.poll(() => page.evaluate(() => window.fullscreenEvents[0])).toEqual([true])
|
||||
expect(await page.evaluate(() => ({ events: window.fullscreenEvents[1], className: window.other.template.$player.classList.contains('art-fullscreen') }))).toEqual({ events: core === 'published' ? [true] : [], className: core === 'published' })
|
||||
await page.evaluate(async () => {
|
||||
await document[window.fullscreenAPI[1]]()
|
||||
window.art.destroy()
|
||||
})
|
||||
})
|
||||
|
||||
test(`${core}: rejected native fullscreen request removes its temporary change listener`, async ({ page }) => {
|
||||
await setup(page, core)
|
||||
expect(await page.evaluate(async () => {
|
||||
const art = window.art
|
||||
const [request, , , change] = window.fullscreenAPI
|
||||
const add = document.addEventListener
|
||||
const remove = document.removeEventListener
|
||||
const active = new Set()
|
||||
document.addEventListener = function (name, callback, options) {
|
||||
if (name === change)
|
||||
active.add(callback)
|
||||
return add.call(this, name, callback, options)
|
||||
}
|
||||
document.removeEventListener = function (name, callback, options) {
|
||||
if (name === change)
|
||||
active.delete(callback)
|
||||
return remove.call(this, name, callback, options)
|
||||
}
|
||||
const failure = new Error('controlled fullscreen rejection')
|
||||
art.template.$player[request] = () => Promise.reject(failure)
|
||||
const rejected = await Object.getOwnPropertyDescriptor(art, 'fullscreen').set.call(art, true).then(() => false, error => error === failure)
|
||||
const result = { rejected, leaked: active.size }
|
||||
document.addEventListener = add
|
||||
document.removeEventListener = remove
|
||||
art.destroy()
|
||||
window.other.destroy()
|
||||
return result
|
||||
})).toEqual({ rejected: true, leaked: core === 'published' ? 1 : 0 })
|
||||
})
|
||||
}
|
||||
|
||||
test('candidate: fullscreen assignment rejection shows notice without an unhandled rejection', async ({ page }) => {
|
||||
await setup(page, 'candidate')
|
||||
await page.evaluate(() => {
|
||||
const [request] = window.fullscreenAPI
|
||||
window.art.template.$player[request] = () => Promise.reject(new Error('controlled assignment denial'))
|
||||
window.art.fullscreen = true
|
||||
})
|
||||
await expect(page.locator('#native-fullscreen-main .art-notice')).toContainText('controlled assignment denial')
|
||||
await page.evaluate(() => {
|
||||
window.art.destroy()
|
||||
window.other.destroy()
|
||||
})
|
||||
})
|
||||
|
||||
test('candidate: reentrant entry preserves real fullscreen UI and event order', async ({ page }) => {
|
||||
await setup(page, 'candidate')
|
||||
await page.evaluate(() => {
|
||||
window.fullscreenOrder = []
|
||||
window.art.on('fullscreen', (value) => {
|
||||
if (value) {
|
||||
window.fullscreenOrder.push(['fullscreen', window.art.template.$player.classList.contains('art-fullscreen')])
|
||||
window.art.fullscreen = true
|
||||
}
|
||||
})
|
||||
window.art.on('resize', () => {
|
||||
if (window.art.fullscreen)
|
||||
window.fullscreenOrder.push(['resize', window.art.template.$player.classList.contains('art-fullscreen')])
|
||||
})
|
||||
})
|
||||
await page.click('#play')
|
||||
await expect.poll(() => page.evaluate(() => window.art.fullscreen && window.art.template.$player.classList.contains('art-fullscreen'))).toBe(true)
|
||||
expect(await page.evaluate(() => window.fullscreenOrder.slice(0, 2))).toEqual([['fullscreen', false], ['resize', true]])
|
||||
await page.evaluate(async () => {
|
||||
await document[window.fullscreenAPI[1]]()
|
||||
window.art.destroy()
|
||||
window.other.destroy()
|
||||
})
|
||||
})
|
||||
|
||||
for (const mode of ['promise-exit', 'promise-destroy', 'void-exit', 'void-destroy']) {
|
||||
test(`candidate: pending native entry ${mode} releases a late own entry`, async ({ page }) => {
|
||||
await setup(page, 'candidate')
|
||||
expect(await page.evaluate(async (mode) => {
|
||||
const art = window.art
|
||||
const [request, exit, element, change] = window.fullscreenAPI
|
||||
const originalExit = document[exit]
|
||||
const originalElement = Object.getOwnPropertyDescriptor(document, element)
|
||||
let current = null
|
||||
let complete
|
||||
let exits = 0
|
||||
Object.defineProperty(document, element, { configurable: true, get: () => current })
|
||||
art.template.$player[request] = () => {
|
||||
if (mode.startsWith('promise'))
|
||||
return new Promise(resolve => complete = resolve)
|
||||
complete = () => document.dispatchEvent(new Event(change))
|
||||
}
|
||||
document[exit] = () => {
|
||||
exits++
|
||||
current = null
|
||||
document.dispatchEvent(new Event(change))
|
||||
return Promise.resolve()
|
||||
}
|
||||
const setter = Object.getOwnPropertyDescriptor(art, 'fullscreen').set
|
||||
const pending = setter.call(art, true)
|
||||
if (mode.endsWith('destroy'))
|
||||
art.destroy(false)
|
||||
else
|
||||
await setter.call(art, false)
|
||||
await pending
|
||||
current = art.template.$player
|
||||
complete()
|
||||
// Flush the request's native Promise observer and setter settlement chain.
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
const result = { exits, fullscreen: art.fullscreen, className: art.template.$player.classList.contains('art-fullscreen'), events: window.fullscreenEvents[0] }
|
||||
art.destroy()
|
||||
window.other.destroy()
|
||||
document[exit] = originalExit
|
||||
if (originalElement)
|
||||
Object.defineProperty(document, element, originalElement)
|
||||
else
|
||||
delete document[element]
|
||||
return result
|
||||
}, mode)).toEqual({ exits: 1, fullscreen: false, className: false, events: [] })
|
||||
})
|
||||
}
|
||||
|
||||
test('candidate: cancelling an actual native request in the initiating click releases fullscreen', async ({ page }) => {
|
||||
await setup(page, 'candidate')
|
||||
await page.evaluate(() => {
|
||||
window.entrySettled = false
|
||||
document.querySelector('#play').onclick = () => {
|
||||
const setter = Object.getOwnPropertyDescriptor(window.art, 'fullscreen').set
|
||||
const entering = setter.call(window.art, true)
|
||||
window.art.fullscreen = false
|
||||
void entering.then(() => window.entrySettled = true)
|
||||
}
|
||||
})
|
||||
await page.click('#play')
|
||||
await expect.poll(() => page.evaluate(() => window.entrySettled && !document[window.fullscreenAPI[2]])).toBe(true)
|
||||
await page.evaluate(() => {
|
||||
window.art.destroy()
|
||||
window.other.destroy()
|
||||
})
|
||||
})
|
||||
|
||||
test('candidate: rejected exit retains actual fullscreen state and can be retried', async ({ page }) => {
|
||||
await setup(page, 'candidate')
|
||||
await page.click('#play')
|
||||
await expect.poll(() => page.evaluate(() => window.art.template.$player.classList.contains('art-fullscreen'))).toBe(true)
|
||||
expect(await page.evaluate(async () => {
|
||||
const [, exit] = window.fullscreenAPI
|
||||
const originalExit = document[exit]
|
||||
const failure = new Error('controlled exit denial')
|
||||
document[exit] = () => Promise.reject(failure)
|
||||
const setter = Object.getOwnPropertyDescriptor(window.art, 'fullscreen').set
|
||||
const rejected = await setter.call(window.art, false).then(() => false, error => error === failure)
|
||||
const result = { rejected, fullscreen: window.art.fullscreen, className: window.art.template.$player.classList.contains('art-fullscreen') }
|
||||
document[exit] = originalExit
|
||||
await setter.call(window.art, false)
|
||||
return result
|
||||
})).toEqual({ rejected: true, fullscreen: true, className: true })
|
||||
await expect.poll(() => page.evaluate(() => window.art.fullscreen || window.art.template.$player.classList.contains('art-fullscreen'))).toBe(false)
|
||||
await page.evaluate(() => {
|
||||
window.art.destroy()
|
||||
window.other.destroy()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,161 @@
|
||||
import { expect, test } from './fixtures.js'
|
||||
|
||||
async function setup(page, core) {
|
||||
await page.setViewportSize({ width: 400, height: 800 })
|
||||
await page.addInitScript(() => Object.defineProperty(navigator, 'userAgent', { configurable: true, value: 'ArtPlayer Android orientation fixture' }))
|
||||
await page.goto(`/test/player.html?core=${core}&chapter=published`)
|
||||
await page.evaluate(() => {
|
||||
window.Artplayer.AUTO_ORIENTATION_TIME = 10
|
||||
window.art = new window.Artplayer({ container: '.player', url: '/test/pattern.mp4', muted: true, autoOrientation: true })
|
||||
})
|
||||
await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
|
||||
}
|
||||
|
||||
test('candidate: a cancelled rotation timer cannot rotate a later fullscreen session early', async ({ page }) => {
|
||||
await setup(page, 'candidate')
|
||||
await page.clock.install({ time: new Date('2026-09-11T00:00:00Z') })
|
||||
await page.clock.pauseAt(new Date('2026-09-11T00:00:01Z'))
|
||||
await page.evaluate(() => {
|
||||
window.Artplayer.AUTO_ORIENTATION_TIME = 1000
|
||||
window.art.fullscreenWeb = true
|
||||
})
|
||||
await page.clock.runFor(700)
|
||||
await page.evaluate(() => {
|
||||
window.art.fullscreenWeb = false
|
||||
window.art.fullscreenWeb = true
|
||||
})
|
||||
await page.clock.runFor(400)
|
||||
expect(await page.evaluate(() => window.art.plugins.autoOrientation.state)).toBe(false)
|
||||
await page.clock.runFor(600)
|
||||
expect(await page.evaluate(() => window.art.plugins.autoOrientation.state)).toBe(true)
|
||||
await page.evaluate(() => window.art.destroy())
|
||||
})
|
||||
|
||||
for (const removeHtml of [false, true]) {
|
||||
test(`candidate: rotation destruction restores styles and cancels subscriptions removeHtml=${removeHtml}`, async ({ page }) => {
|
||||
await setup(page, 'candidate')
|
||||
await page.evaluate(() => {
|
||||
const art = window.art
|
||||
art.constructor.FULLSCREEN_WEB_IN_BODY = true
|
||||
art.template.$player.style.cssText = 'width:75%;height:80%;transform:scale(0.9);transform-origin:center;'
|
||||
window.beforeRotation = art.template.$player.getAttribute('style')
|
||||
art.fullscreenWeb = true
|
||||
})
|
||||
await expect.poll(() => page.evaluate(() => window.art.plugins.autoOrientation.state)).toBe(true)
|
||||
expect(await page.evaluate((remove) => {
|
||||
const art = window.art
|
||||
const player = art.template.$player
|
||||
art.destroy(remove)
|
||||
const style = player.getAttribute('style')
|
||||
art.emit('fullscreenWeb', true)
|
||||
art.emit('fullscreen', true)
|
||||
return { restored: style === window.beforeRotation, rotated: art.isRotate, state: art.plugins.autoOrientation.state, retained: player.isConnected, changed: style !== player.getAttribute('style') }
|
||||
}, removeHtml)).toEqual({ restored: true, rotated: false, state: false, retained: !removeHtml, changed: false })
|
||||
})
|
||||
}
|
||||
|
||||
test('candidate: destroying a pending rotation removes its timer and produces no late resize', async ({ page }) => {
|
||||
await setup(page, 'candidate')
|
||||
await page.clock.install({ time: new Date('2026-09-11T00:00:00Z') })
|
||||
await page.clock.pauseAt(new Date('2026-09-11T00:00:01Z'))
|
||||
await page.evaluate(() => {
|
||||
const art = window.art
|
||||
art.constructor.AUTO_ORIENTATION_TIME = 1000
|
||||
art.fullscreenWeb = true
|
||||
art.destroy(false)
|
||||
window.lateRotations = []
|
||||
art.on('resize', () => window.lateRotations.push(art.isRotate))
|
||||
})
|
||||
await page.clock.runFor(2000)
|
||||
expect(await page.evaluate(() => ({ rotated: window.art.plugins.autoOrientation.state, events: window.lateRotations }))).toEqual({ rotated: false, events: [] })
|
||||
})
|
||||
|
||||
test('candidate: rotated playback supports ratio and flip changes before restoring normal layout', async ({ page }) => {
|
||||
await setup(page, 'candidate')
|
||||
await page.evaluate(async () => {
|
||||
await window.art.play()
|
||||
window.art.fullscreenWeb = true
|
||||
})
|
||||
await expect.poll(() => page.evaluate(() => window.art.plugins.autoOrientation.state && window.art.currentTime > 0.1)).toBe(true)
|
||||
expect(await page.evaluate(() => {
|
||||
const art = window.art
|
||||
art.aspectRatio = '4:3'
|
||||
art.flip = 'horizontal'
|
||||
const video = art.template.$video
|
||||
const ratio = video.clientWidth / video.clientHeight
|
||||
const flipped = art.flip
|
||||
art.fullscreenWeb = false
|
||||
art.aspectRatio = 'default'
|
||||
art.flip = 'normal'
|
||||
const result = { ratioClose: Math.abs(ratio - 4 / 3) < 0.01, flipped, playing: art.playing, rotate: art.isRotate, cleared: video.style.width === '' && video.style.height === '' }
|
||||
art.destroy()
|
||||
return result
|
||||
})).toEqual({ ratioClose: true, flipped: 'horizontal', playing: true, rotate: false, cleared: true })
|
||||
})
|
||||
|
||||
test('candidate: repeated web fullscreen retains rotated dimensions and final styles', async ({ page }) => {
|
||||
await setup(page, 'candidate')
|
||||
await page.evaluate(() => {
|
||||
window.art.fullscreenWeb = true
|
||||
})
|
||||
await expect.poll(() => page.evaluate(() => window.art.plugins.autoOrientation.state)).toBe(true)
|
||||
expect(await page.evaluate(() => {
|
||||
const art = window.art
|
||||
art.fullscreenWeb = true
|
||||
const result = { width: art.template.$player.style.width, height: art.template.$player.style.height, state: art.plugins.autoOrientation.state }
|
||||
art.destroy()
|
||||
return result
|
||||
})).toEqual({ width: '800px', height: '400px', state: true })
|
||||
})
|
||||
|
||||
for (const core of ['published', 'candidate']) {
|
||||
test(`${core}: rotated web fullscreen restores original inline geometry`, async ({ page }) => {
|
||||
await setup(page, core)
|
||||
await page.evaluate(() => {
|
||||
const art = window.art
|
||||
art.template.$player.style.cssText = 'width:75%;height:80%;transform:scale(0.9);transform-origin:center;'
|
||||
window.beforeRotation = art.template.$player.getAttribute('style')
|
||||
art.fullscreenWeb = true
|
||||
})
|
||||
await expect.poll(() => page.evaluate(() => window.art.isRotate)).toBe(true)
|
||||
expect(await page.evaluate(() => {
|
||||
const art = window.art
|
||||
const rotated = { state: art.plugins.autoOrientation.state, width: art.template.$player.style.width, height: art.template.$player.style.height }
|
||||
art.fullscreenWeb = false
|
||||
const original = document.createElement('div')
|
||||
original.setAttribute('style', window.beforeRotation)
|
||||
const restored = ['width', 'height', 'transform', 'transform-origin'].every(name => art.template.$player.style.getPropertyValue(name) === original.style.getPropertyValue(name))
|
||||
const result = { rotated, restored, state: art.plugins.autoOrientation.state, isRotate: art.isRotate }
|
||||
art.destroy()
|
||||
return result
|
||||
})).toEqual({ rotated: { state: true, width: '800px', height: '400px' }, restored: core === 'candidate', state: false, isRotate: false })
|
||||
})
|
||||
|
||||
test(`${core}: late orientation lock after fullscreen exit does not reactivate the player`, async ({ page }) => {
|
||||
await setup(page, core)
|
||||
expect(await page.evaluate(async () => {
|
||||
const art = window.art
|
||||
const calls = []
|
||||
let resolveLock
|
||||
Object.defineProperty(screen, 'orientation', { configurable: true, value: {
|
||||
type: 'portrait-primary',
|
||||
lock(mode) {
|
||||
calls.push(mode)
|
||||
return new Promise((resolve) => {
|
||||
resolveLock = resolve
|
||||
})
|
||||
},
|
||||
unlock() {
|
||||
calls.push('unlock')
|
||||
},
|
||||
} })
|
||||
art.emit('fullscreen', true)
|
||||
art.emit('fullscreen', false)
|
||||
resolveLock()
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
const result = { requested: calls[0], unlocked: calls.includes('unlock'), active: art.template.$player.classList.contains('art-auto-orientation-fullscreen') }
|
||||
art.destroy()
|
||||
return result
|
||||
})).toEqual({ requested: 'landscape', unlocked: core === 'candidate', active: core !== 'candidate' })
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
import { expect, test } from './fixtures.js'
|
||||
|
||||
async function setup(page, core) {
|
||||
await page.addInitScript(() => {
|
||||
Object.defineProperty(document, 'pictureInPictureEnabled', { configurable: true, value: false })
|
||||
const modes = new WeakMap()
|
||||
Object.defineProperties(HTMLVideoElement.prototype, {
|
||||
webkitSupportsPresentationMode: { configurable: true, writable: true, value: () => true },
|
||||
webkitPresentationMode: { configurable: true, get() { return modes.get(this) || 'inline' } },
|
||||
webkitSetPresentationMode: { configurable: true, writable: true, value(mode) {
|
||||
modes.set(this, mode)
|
||||
this.dispatchEvent(new Event('webkitpresentationmodechanged'))
|
||||
this.dispatchEvent(new Event(mode === 'picture-in-picture' ? 'enterpictureinpicture' : 'leavepictureinpicture'))
|
||||
} },
|
||||
})
|
||||
})
|
||||
await page.goto(`/test/player.html?core=${core}&chapter=published`)
|
||||
await page.evaluate(() => {
|
||||
window.createPlayer('/test/pattern.mp4')
|
||||
window.pipEvents = []
|
||||
window.art.on('pip', value => window.pipEvents.push(value))
|
||||
})
|
||||
await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
|
||||
}
|
||||
|
||||
for (const core of ['published', 'candidate']) {
|
||||
test(`${core}: WebKit PiP keeps boolean state and repeated synchronous setter events`, async ({ page }) => {
|
||||
await setup(page, core)
|
||||
expect(await page.evaluate(() => {
|
||||
const art = window.art
|
||||
art.pip = true
|
||||
art.pip = true
|
||||
const entered = art.pip
|
||||
art.pip = false
|
||||
const result = { entered, exited: art.pip, events: [...window.pipEvents] }
|
||||
art.destroy()
|
||||
return result
|
||||
})).toEqual({ entered: true, exited: false, events: [true, true, false] })
|
||||
})
|
||||
|
||||
test(`${core}: WebKit PiP follows native UI transitions`, async ({ page }) => {
|
||||
await setup(page, core)
|
||||
expect(await page.evaluate(() => {
|
||||
const video = window.art.template.$video
|
||||
video.webkitSetPresentationMode('picture-in-picture')
|
||||
video.webkitSetPresentationMode('inline')
|
||||
const result = [...window.pipEvents]
|
||||
window.art.destroy()
|
||||
return result
|
||||
})).toEqual(core === 'candidate' ? [true, false] : [])
|
||||
})
|
||||
|
||||
test(`${core}: destroying WebKit PiP returns presentation to inline`, async ({ page }) => {
|
||||
await setup(page, core)
|
||||
expect(await page.evaluate(() => {
|
||||
window.art.pip = true
|
||||
window.pipEvents.length = 0
|
||||
window.art.destroy(false)
|
||||
return { mode: window.art.template.$video.webkitPresentationMode, events: window.pipEvents }
|
||||
})).toEqual({ mode: core === 'candidate' ? 'inline' : 'picture-in-picture', events: [] })
|
||||
})
|
||||
}
|
||||
|
||||
test('candidate: WebKit PiP pending entry is cleaned up after destruction', async ({ page }) => {
|
||||
await setup(page, 'candidate')
|
||||
expect(await page.evaluate(() => {
|
||||
const art = window.art
|
||||
const video = art.template.$video
|
||||
const setMode = video.webkitSetPresentationMode.bind(video)
|
||||
video.webkitSetPresentationMode = () => {}
|
||||
art.pip = true
|
||||
art.destroy(false)
|
||||
video.webkitSetPresentationMode = setMode
|
||||
setMode('picture-in-picture')
|
||||
return { mode: video.webkitPresentationMode, events: window.pipEvents }
|
||||
})).toEqual({ mode: 'inline', events: [] })
|
||||
})
|
||||
@@ -0,0 +1,161 @@
|
||||
import { expect, test } from './fixtures.js'
|
||||
|
||||
async function setup(page, core) {
|
||||
await page.addInitScript(() => {
|
||||
window.pipElement = null
|
||||
window.pipExits = 0
|
||||
Object.defineProperties(document, {
|
||||
pictureInPictureEnabled: { configurable: true, value: true },
|
||||
pictureInPictureElement: { configurable: true, get: () => window.pipElement },
|
||||
exitPictureInPicture: { configurable: true, writable: true, value() {
|
||||
window.pipExits++
|
||||
const previous = window.pipElement
|
||||
window.pipElement = null
|
||||
previous?.dispatchEvent(new Event('leavepictureinpicture'))
|
||||
return Promise.resolve()
|
||||
} },
|
||||
})
|
||||
Object.defineProperty(HTMLVideoElement.prototype, 'requestPictureInPicture', {
|
||||
configurable: true,
|
||||
writable: true,
|
||||
value() {
|
||||
window.pipElement = this
|
||||
this.dispatchEvent(new Event('enterpictureinpicture'))
|
||||
return Promise.resolve({ width: 320, height: 180 })
|
||||
},
|
||||
})
|
||||
})
|
||||
await page.goto(`/test/player.html?core=${core}&chapter=published`)
|
||||
await page.evaluate(() => {
|
||||
window.createPlayer('/test/pattern.mp4')
|
||||
window.pipEvents = []
|
||||
window.art.on('pip', value => window.pipEvents.push(value))
|
||||
})
|
||||
await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
|
||||
}
|
||||
|
||||
for (const core of ['published', 'candidate']) {
|
||||
test(`${core}: native PiP preserves element/null getter, void setter and boolean events`, async ({ page }) => {
|
||||
await setup(page, core)
|
||||
expect(await page.evaluate(() => {
|
||||
const art = window.art
|
||||
const setter = Object.getOwnPropertyDescriptor(art, 'pip').set
|
||||
const entered = setter.call(art, true)
|
||||
const ownElement = art.pip === art.template.$video
|
||||
const exited = setter.call(art, false)
|
||||
const result = { ownElement, empty: art.pip === null, entered: typeof entered, exited: typeof exited, events: [...window.pipEvents] }
|
||||
art.destroy()
|
||||
return result
|
||||
})).toEqual({ ownElement: true, empty: true, entered: 'undefined', exited: 'undefined', events: [true, false] })
|
||||
})
|
||||
|
||||
test(`${core}: a player cannot report or exit another video's PiP`, async ({ page }) => {
|
||||
await setup(page, core)
|
||||
expect(await page.evaluate(() => {
|
||||
const other = document.createElement('video')
|
||||
window.pipElement = other
|
||||
const reported = window.art.pip === other
|
||||
window.art.pip = false
|
||||
const result = { reported, stillOther: window.pipElement === other, exits: window.pipExits }
|
||||
window.pipElement = null
|
||||
window.art.destroy()
|
||||
return result
|
||||
})).toEqual({ reported: core === 'published', stillOther: core === 'candidate', exits: core === 'published' ? 1 : 0 })
|
||||
})
|
||||
|
||||
test(`${core}: destroy releases active PiP without a late player event`, async ({ page }) => {
|
||||
await setup(page, core)
|
||||
expect(await page.evaluate(async () => {
|
||||
window.art.pip = true
|
||||
await Promise.resolve()
|
||||
window.pipEvents.length = 0
|
||||
window.art.destroy(false)
|
||||
await Promise.resolve()
|
||||
return { active: Boolean(window.pipElement), events: window.pipEvents }
|
||||
})).toEqual({ active: core === 'published', events: [] })
|
||||
})
|
||||
}
|
||||
|
||||
for (const action of ['cancel', 'destroy']) {
|
||||
test(`candidate: pending PiP ${action} releases late native entry`, async ({ page }) => {
|
||||
await setup(page, 'candidate')
|
||||
expect(await page.evaluate(async (action) => {
|
||||
const art = window.art
|
||||
const video = art.template.$video
|
||||
let complete
|
||||
video.requestPictureInPicture = () => new Promise(resolve => complete = resolve)
|
||||
art.pip = true
|
||||
if (action === 'destroy')
|
||||
art.destroy(false)
|
||||
else
|
||||
art.pip = false
|
||||
window.pipElement = video
|
||||
video.dispatchEvent(new Event('enterpictureinpicture'))
|
||||
complete({ width: 320, height: 180 })
|
||||
await new Promise(resolve => setTimeout(resolve, 0))
|
||||
const result = { active: Boolean(window.pipElement), events: [...window.pipEvents] }
|
||||
art.destroy()
|
||||
return result
|
||||
}, action)).toEqual({ active: false, events: [] })
|
||||
})
|
||||
}
|
||||
|
||||
test('candidate: rejected PiP assignment shows notice without an unhandled rejection', async ({ page }) => {
|
||||
await setup(page, 'candidate')
|
||||
await page.evaluate(() => {
|
||||
window.art.template.$video.requestPictureInPicture = () => Promise.reject(new Error('controlled PiP denial'))
|
||||
window.art.pip = true
|
||||
})
|
||||
await expect(page.locator('.art-notice')).toContainText('controlled PiP denial')
|
||||
await page.evaluate(() => window.art.destroy())
|
||||
})
|
||||
|
||||
test('candidate: actual native PiP gesture and playback or explicit unsupported capability', async ({ page }, testInfo) => {
|
||||
await page.goto('/test/player.html?core=candidate&chapter=published')
|
||||
await page.evaluate(() => window.createPlayer('/test/pattern.mp4'))
|
||||
await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
|
||||
const capability = await page.evaluate(() => ({
|
||||
enabled: document.pictureInPictureEnabled === true,
|
||||
request: typeof window.art.template.$video.requestPictureInPicture === 'function',
|
||||
exit: typeof document.exitPictureInPicture === 'function',
|
||||
}))
|
||||
await testInfo.attach('native-pip-capability', { contentType: 'application/json', body: JSON.stringify(capability) })
|
||||
if (!capability.enabled || !capability.request || !capability.exit) {
|
||||
expect(await page.evaluate(() => window.art.pip)).toBe(false)
|
||||
await page.evaluate(() => window.art.destroy())
|
||||
return
|
||||
}
|
||||
await page.evaluate(() => {
|
||||
const video = window.art.template.$video
|
||||
const request = video.requestPictureInPicture.bind(video)
|
||||
video.requestPictureInPicture = () => {
|
||||
const result = request()
|
||||
void result.then(pipWindow => window.actualPipWindow = { width: pipWindow.width, height: pipWindow.height })
|
||||
return result
|
||||
}
|
||||
document.querySelector('#play').onclick = () => {
|
||||
void window.art.play()
|
||||
window.art.pip = true
|
||||
}
|
||||
})
|
||||
await page.evaluate(() => window.art.mini = true)
|
||||
await page.click('#play')
|
||||
await expect.poll(() => page.evaluate(() => window.art.pip === window.art.template.$video)).toBe(true)
|
||||
expect(await page.evaluate(() => window.art.mini)).toBe(false)
|
||||
await expect.poll(() => page.evaluate(() => window.actualPipWindow?.width > 0 && window.art.currentTime > 0 && !window.art.template.$video.paused)).toBe(true)
|
||||
await testInfo.attach('native-pip-playback', { contentType: 'application/json', body: JSON.stringify(await page.evaluate(() => ({ window: window.actualPipWindow, currentTime: window.art.currentTime, events: window.events }))) })
|
||||
await page.evaluate(() => window.art.fullscreenWeb = true)
|
||||
await expect.poll(() => page.evaluate(() => document.pictureInPictureElement)).toBe(null)
|
||||
expect(await page.evaluate(() => window.art.fullscreenWeb)).toBe(true)
|
||||
await page.evaluate(() => window.art.fullscreenWeb = false)
|
||||
await page.click('#play')
|
||||
await expect.poll(() => page.evaluate(() => window.art.pip === window.art.template.$video)).toBe(true)
|
||||
await page.evaluate(() => {
|
||||
document.querySelector('#play').onclick = () => window.art.fullscreen = true
|
||||
})
|
||||
await page.click('#play')
|
||||
await expect.poll(() => page.evaluate(() => window.art.fullscreen && !document.pictureInPictureElement)).toBe(true)
|
||||
await page.evaluate(() => window.art.fullscreen = false)
|
||||
await expect.poll(() => page.evaluate(() => window.art.fullscreen)).toBe(false)
|
||||
await page.evaluate(() => window.art.destroy())
|
||||
})
|
||||
@@ -0,0 +1,161 @@
|
||||
import { expect, test } from './fixtures.js'
|
||||
|
||||
async function setup(page, core) {
|
||||
await page.goto(`/test/player.html?core=${core}&chapter=published`)
|
||||
await page.evaluate(() => window.createPlayer('/test/pattern.mp4'))
|
||||
await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
|
||||
}
|
||||
|
||||
for (const core of ['published', 'candidate']) {
|
||||
test(`${core}: sizing methods remain detachable own properties and ratio parsing retains legacy coercion`, async ({ page }) => {
|
||||
await setup(page, core)
|
||||
expect(await page.evaluate(() => {
|
||||
const art = window.art
|
||||
const descriptors = ['autoSize', 'autoHeight', 'aspectRatio', 'flip'].map((name) => {
|
||||
const descriptor = Object.getOwnPropertyDescriptor(art, name)
|
||||
return { name, configurable: descriptor.configurable, enumerable: descriptor.enumerable, writable: descriptor.writable ?? null }
|
||||
})
|
||||
const { autoSize, autoHeight } = art
|
||||
const returns = [typeof Reflect.apply(autoSize, null, []), typeof Reflect.apply(autoHeight, {}, [])]
|
||||
art.template.$player.style.cssText = 'width:640px;height:360px;'
|
||||
const events = []
|
||||
art.on('aspectRatio', value => events.push(value))
|
||||
art.aspectRatio = ' 4 : 3 : ignored'
|
||||
const parsed = { value: art.aspectRatio, width: art.template.$video.style.width, height: art.template.$video.style.height }
|
||||
art.aspectRatio = ''
|
||||
art.aspectRatio = 'default'
|
||||
art.flip = 'custom'
|
||||
const custom = art.flip
|
||||
art.flip = ''
|
||||
const result = { descriptors, returns, parsed, events, custom, reset: art.flip }
|
||||
art.destroy()
|
||||
return result
|
||||
})).toEqual({
|
||||
descriptors: ['autoSize', 'autoHeight', 'aspectRatio', 'flip'].map(name => ({ name, configurable: false, enumerable: false, writable: name.startsWith('auto') ? false : null })),
|
||||
returns: ['undefined', 'undefined'],
|
||||
parsed: { value: ' 4 : 3 : ignored', width: '480px', height: '100%' },
|
||||
events: [' 4 : 3 : ignored', 'default', 'default'],
|
||||
custom: 'custom',
|
||||
reset: 'normal',
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
test('candidate: hidden containers defer sizing and recover after becoming visible', async ({ page }) => {
|
||||
await setup(page, 'candidate')
|
||||
expect(await page.evaluate(() => {
|
||||
const art = window.art
|
||||
const { $container, $video, $player } = art.template
|
||||
Object.defineProperties($video, { videoWidth: { configurable: true, value: 1920 }, videoHeight: { configurable: true, value: 1080 } })
|
||||
$container.style.cssText = 'display:none;width:640px;height:480px;'
|
||||
$player.style.cssText = 'width:80%;height:70%;'
|
||||
const events = []
|
||||
art.on('autoSize', value => events.push(['size', value]))
|
||||
art.on('autoHeight', value => events.push(['height', value]))
|
||||
art.autoSize()
|
||||
art.autoHeight()
|
||||
const hidden = { width: $player.style.width, height: $player.style.height, containerHeight: $container.style.height, count: events.length }
|
||||
$container.style.display = 'block'
|
||||
art.autoSize()
|
||||
art.autoHeight()
|
||||
const result = { hidden, events, height: $container.style.height }
|
||||
art.destroy()
|
||||
return result
|
||||
})).toEqual({ hidden: { width: '80%', height: '70%', containerHeight: '480px', count: 0 }, events: [['size', { width: 640, height: 360 }], ['height', 360]], height: '360px' })
|
||||
})
|
||||
|
||||
test('candidate: malformed ratios preserve layout while retaining notices, datasets and events', async ({ page }) => {
|
||||
await setup(page, 'candidate')
|
||||
expect(await page.evaluate(() => {
|
||||
const art = window.art
|
||||
const { $video } = art.template
|
||||
art.aspectRatio = '4:3'
|
||||
const before = $video.getAttribute('style')
|
||||
const values = []
|
||||
art.on('aspectRatio', value => values.push(value))
|
||||
const stable = ['bad', '0:0', '-1:2', '1:0', 'Infinity:2'].map((value) => {
|
||||
art.aspectRatio = value
|
||||
return $video.getAttribute('style') === before && art.aspectRatio === value && art.template.$notice.textContent.includes(value)
|
||||
})
|
||||
art.destroy()
|
||||
return { stable, values }
|
||||
})).toEqual({ stable: [true, true, true, true, true], values: ['bad', '0:0', '-1:2', '1:0', 'Infinity:2'] })
|
||||
})
|
||||
|
||||
test('candidate: retained display setters and sizing methods do not write or emit after destroy', async ({ page }) => {
|
||||
await setup(page, 'candidate')
|
||||
expect(await page.evaluate(() => {
|
||||
const art = window.art
|
||||
const { $container, $player, $video } = art.template
|
||||
art.destroy(false)
|
||||
const before = [$container.outerHTML, $player.outerHTML, $video.outerHTML]
|
||||
const events = []
|
||||
for (const name of ['autoSize', 'autoHeight', 'aspectRatio', 'flip'])
|
||||
art.on(name, () => events.push(name))
|
||||
art.autoSize()
|
||||
art.autoHeight()
|
||||
art.aspectRatio = '4:3'
|
||||
art.flip = 'horizontal'
|
||||
return { same: before.every((html, index) => html === [$container, $player, $video][index].outerHTML), events }
|
||||
})).toEqual({ same: true, events: [] })
|
||||
})
|
||||
|
||||
for (const core of ['published', 'candidate']) {
|
||||
test(`${core}: sizing preserves valid geometry, return values and emitted measurements`, async ({ page }) => {
|
||||
await setup(page, core)
|
||||
expect(await page.evaluate(() => {
|
||||
const art = window.art
|
||||
const { $container, $player, $video } = art.template
|
||||
Object.defineProperties($video, { videoWidth: { configurable: true, value: 1920 }, videoHeight: { configurable: true, value: 1080 } })
|
||||
$container.style.cssText = 'width:640px;height:480px;'
|
||||
const sizes = []
|
||||
const heights = []
|
||||
art.on('autoSize', value => sizes.push(value))
|
||||
art.on('autoHeight', value => heights.push(value))
|
||||
const autoSize = art.autoSize()
|
||||
const styles = { width: $player.style.width, height: $player.style.height }
|
||||
const autoHeight = art.autoHeight()
|
||||
const result = { styles, sizes, heights, returns: [typeof autoSize, typeof autoHeight], containerHeight: $container.style.height }
|
||||
art.destroy()
|
||||
return result
|
||||
})).toEqual({ styles: { width: '100%', height: '75%' }, sizes: [{ width: 640, height: 360 }], heights: [360], returns: ['undefined', 'undefined'], containerHeight: '360px' })
|
||||
})
|
||||
|
||||
test(`${core}: aspect ratio and flip preserve dataset, notices and event order`, async ({ page }) => {
|
||||
await setup(page, core)
|
||||
expect(await page.evaluate(() => {
|
||||
const art = window.art
|
||||
const events = []
|
||||
art.on('aspectRatio', value => events.push(['aspectRatio', value]))
|
||||
art.on('flip', value => events.push(['flip', value]))
|
||||
art.aspectRatio = '4:3'
|
||||
const video = art.template.$video
|
||||
const ratio = { width: video.style.width, height: video.style.height, margin: video.style.margin, value: art.aspectRatio }
|
||||
art.flip = 'horizontal'
|
||||
const flip = art.flip
|
||||
art.aspectRatio = 'default'
|
||||
art.flip = 'normal'
|
||||
const result = { ratio, flip, reset: { width: video.style.width, height: video.style.height, margin: video.style.margin, ratio: art.aspectRatio, flip: art.flip }, events }
|
||||
art.destroy()
|
||||
return result
|
||||
})).toEqual({ ratio: { width: '480px', height: '100%', margin: '0px auto', value: '4:3' }, flip: 'horizontal', reset: { width: '', height: '', margin: '', ratio: 'default', flip: 'normal' }, events: [['aspectRatio', '4:3'], ['flip', 'horizontal'], ['aspectRatio', 'default'], ['flip', 'normal']] })
|
||||
})
|
||||
|
||||
test(`${core}: unresolved media dimensions do not partially change layout or emit NaN height`, async ({ page }) => {
|
||||
await setup(page, core)
|
||||
expect(await page.evaluate(() => {
|
||||
const art = window.art
|
||||
const { $video, $player } = art.template
|
||||
Object.defineProperties($video, { videoWidth: { configurable: true, value: 0 }, videoHeight: { configurable: true, value: 0 } })
|
||||
$player.style.width = '80%'
|
||||
$player.style.height = '70%'
|
||||
const events = []
|
||||
art.on('autoHeight', value => events.push(Number.isFinite(value)))
|
||||
art.autoSize()
|
||||
art.autoHeight()
|
||||
const result = { width: $player.style.width, height: $player.style.height, finiteHeightEvents: events }
|
||||
art.destroy()
|
||||
return result
|
||||
})).toEqual({ width: core === 'candidate' ? '80%' : '100%', height: '70%', finiteHeightEvents: core === 'candidate' ? [] : [false] })
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
import { expect, test } from './fixtures.js'
|
||||
|
||||
async function setup(page, core) {
|
||||
await page.addInitScript(() => {
|
||||
// Exercise the video-only branch without claiming an iOS device test.
|
||||
for (const name of ['fullscreenEnabled', 'webkitFullscreenEnabled', 'webkitFullScreenEnabled', 'mozFullScreenEnabled', 'msFullscreenEnabled'])
|
||||
Object.defineProperty(document, name, { configurable: true, value: false })
|
||||
const state = new WeakMap()
|
||||
Object.defineProperties(HTMLVideoElement.prototype, {
|
||||
webkitSupportsFullscreen: { configurable: true, get: () => true },
|
||||
webkitDisplayingFullscreen: { configurable: true, get() { return state.get(this) === true } },
|
||||
webkitPresentationMode: { configurable: true, get() { return state.get(this) ? 'fullscreen' : 'inline' } },
|
||||
webkitEnterFullscreen: { configurable: true, writable: true, value() {
|
||||
state.set(this, true)
|
||||
this.dispatchEvent(new Event('webkitbeginfullscreen'))
|
||||
this.dispatchEvent(new Event('webkitpresentationmodechanged'))
|
||||
} },
|
||||
webkitExitFullscreen: { configurable: true, writable: true, value() {
|
||||
state.set(this, false)
|
||||
this.dispatchEvent(new Event('webkitendfullscreen'))
|
||||
this.dispatchEvent(new Event('webkitpresentationmodechanged'))
|
||||
} },
|
||||
})
|
||||
window.videoFullscreenState = state
|
||||
})
|
||||
await page.goto(`/test/player.html?core=${core}&chapter=published`)
|
||||
await page.evaluate(() => {
|
||||
window.createPlayer('/test/pattern.mp4')
|
||||
window.modeEvents = []
|
||||
window.art.on('fullscreen', value => window.modeEvents.push(value))
|
||||
})
|
||||
await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
|
||||
}
|
||||
|
||||
for (const core of ['published', 'candidate']) {
|
||||
test(`${core}: video-only fullscreen reads its native video state and deduplicates signals`, async ({ page }) => {
|
||||
await setup(page, core)
|
||||
expect(await page.evaluate(() => {
|
||||
const art = window.art
|
||||
art.fullscreen = true
|
||||
const entered = art.fullscreen
|
||||
art.emit('document:webkitfullscreenchange')
|
||||
art.fullscreen = false
|
||||
const result = { entered, exited: art.fullscreen, events: [...window.modeEvents] }
|
||||
art.destroy()
|
||||
return result
|
||||
})).toEqual({ entered: core === 'candidate', exited: false, events: core === 'candidate' ? [true, false] : [false] })
|
||||
})
|
||||
|
||||
test(`${core}: destroying video-only fullscreen releases native presentation`, async ({ page }) => {
|
||||
await setup(page, core)
|
||||
expect(await page.evaluate(() => {
|
||||
const art = window.art
|
||||
art.fullscreen = true
|
||||
window.modeEvents.length = 0
|
||||
art.destroy(false)
|
||||
return { native: art.template.$video.webkitDisplayingFullscreen, events: window.modeEvents }
|
||||
})).toEqual({ native: core === 'published', events: [] })
|
||||
})
|
||||
}
|
||||
|
||||
test('candidate: cancelled video-only entry after destroy is released without late player events', async ({ page }) => {
|
||||
await setup(page, 'candidate')
|
||||
expect(await page.evaluate(() => {
|
||||
const art = window.art
|
||||
const video = art.template.$video
|
||||
video.webkitEnterFullscreen = () => {}
|
||||
art.fullscreen = true
|
||||
art.destroy(false)
|
||||
window.videoFullscreenState.set(video, true)
|
||||
video.dispatchEvent(new Event('webkitbeginfullscreen'))
|
||||
return { native: video.webkitDisplayingFullscreen, events: window.modeEvents }
|
||||
})).toEqual({ native: false, events: [] })
|
||||
})
|
||||
|
||||
test('candidate: video-only fullscreen preserves synchronous errors and supports exit retry', async ({ page }) => {
|
||||
await setup(page, 'candidate')
|
||||
expect(await page.evaluate(() => {
|
||||
const art = window.art
|
||||
const video = art.template.$video
|
||||
art.fullscreen = true
|
||||
const exit = video.webkitExitFullscreen
|
||||
const failure = new Error('controlled video exit denial')
|
||||
video.webkitExitFullscreen = () => {
|
||||
throw failure
|
||||
}
|
||||
let rejected = false
|
||||
try {
|
||||
art.fullscreen = false
|
||||
}
|
||||
catch (error) { rejected = error === failure }
|
||||
const active = art.fullscreen
|
||||
video.webkitExitFullscreen = exit
|
||||
art.fullscreen = false
|
||||
const result = { rejected, active, exited: art.fullscreen, events: window.modeEvents }
|
||||
art.destroy()
|
||||
return result
|
||||
})).toEqual({ rejected: true, active: true, exited: false, events: [true, false] })
|
||||
})
|
||||
@@ -0,0 +1,233 @@
|
||||
import { expect, test } from './fixtures.js'
|
||||
|
||||
async function setup(page, core) {
|
||||
await page.goto(`/test/player.html?core=${core}&chapter=published`)
|
||||
await page.evaluate(() => window.createPlayer('/test/pattern.mp4'))
|
||||
await expect.poll(() => page.evaluate(() => window.art.isReady)).toBe(true)
|
||||
}
|
||||
|
||||
for (const removeHtml of [false, true]) {
|
||||
for (const movedBeforeThrow of [false, true]) {
|
||||
test(`candidate: failed destroy restoration cannot orphan the player removeHtml=${removeHtml} movedBeforeThrow=${movedBeforeThrow}`, async ({ page }) => {
|
||||
await setup(page, 'candidate')
|
||||
expect(await page.evaluate(({ removeHtml, movedBeforeThrow }) => {
|
||||
const art = window.art
|
||||
const { $player: player, $container: container } = art.template
|
||||
art.constructor.FULLSCREEN_WEB_IN_BODY = true
|
||||
art.fullscreenWeb = true
|
||||
const original = container.insertBefore
|
||||
const failure = new Error('destroy restore denied')
|
||||
container.insertBefore = function (node, anchor) {
|
||||
if (movedBeforeThrow)
|
||||
original.call(this, node, anchor)
|
||||
throw failure
|
||||
}
|
||||
let sameError = false
|
||||
try {
|
||||
art.destroy(removeHtml)
|
||||
}
|
||||
catch (error) {
|
||||
sameError = error === failure
|
||||
}
|
||||
container.insertBefore = original
|
||||
return { sameError, orphan: player.isConnected && !container.contains(player), retained: container.contains(player), destroyed: art.isDestroy, registered: art.constructor.instances.includes(art) }
|
||||
}, { removeHtml, movedBeforeThrow })).toEqual({ sameError: true, orphan: false, retained: movedBeforeThrow && !removeHtml, destroyed: true, registered: false })
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
test('candidate: web fullscreen listener can exit without stale resize or overwritten restoration', async ({ page }) => {
|
||||
await setup(page, 'candidate')
|
||||
const result = await page.evaluate(() => {
|
||||
const art = window.art
|
||||
const player = art.template.$player
|
||||
player.style.width = '75%'
|
||||
const style = player.style.cssText
|
||||
const events = []
|
||||
art.on('fullscreenWeb', (value) => {
|
||||
events.push(value)
|
||||
if (value)
|
||||
art.fullscreenWeb = false
|
||||
})
|
||||
art.on('resize', () => events.push('resize'))
|
||||
art.fullscreenWeb = true
|
||||
const result = { events, active: art.fullscreenWeb, restored: player.style.cssText === style }
|
||||
art.destroy()
|
||||
return result
|
||||
})
|
||||
expect(result).toEqual({ events: [true, false, 'resize'], active: false, restored: true })
|
||||
})
|
||||
|
||||
test('candidate: web fullscreen listener destroy restores ownership without post-destroy resize', async ({ page }) => {
|
||||
await setup(page, 'candidate')
|
||||
expect(await page.evaluate(() => {
|
||||
const art = window.art
|
||||
const { $player: player, $container: container } = art.template
|
||||
art.constructor.FULLSCREEN_WEB_IN_BODY = true
|
||||
const style = player.style.cssText
|
||||
const events = []
|
||||
art.on('fullscreenWeb', () => art.destroy(false))
|
||||
art.on('destroy', () => events.push('destroy'))
|
||||
art.on('resize', () => events.push('resize'))
|
||||
art.fullscreenWeb = true
|
||||
art.fullscreenWeb = true
|
||||
return { events, retained: container.contains(player), style: player.style.cssText === style, fullscreen: player.classList.contains('art-fullscreen-web') }
|
||||
})).toEqual({ events: ['destroy'], retained: true, style: true, fullscreen: false })
|
||||
})
|
||||
|
||||
test('candidate: a failed body move restores style and keeps its original error', async ({ page }) => {
|
||||
await setup(page, 'candidate')
|
||||
expect(await page.evaluate(() => {
|
||||
const art = window.art
|
||||
const { $player: player, $container: container } = art.template
|
||||
const style = player.getAttribute('style')
|
||||
art.constructor.FULLSCREEN_WEB_IN_BODY = true
|
||||
const append = document.body.appendChild
|
||||
const failure = new Error('controlled body insertion failure')
|
||||
document.body.appendChild = function (node) {
|
||||
if (node === player)
|
||||
throw failure
|
||||
return append.call(this, node)
|
||||
}
|
||||
let sameError = false
|
||||
try {
|
||||
art.fullscreenWeb = true
|
||||
}
|
||||
catch (error) {
|
||||
sameError = error === failure
|
||||
}
|
||||
document.body.appendChild = append
|
||||
const result = { sameError, retained: container.contains(player), style: player.getAttribute('style') === style, active: art.fullscreenWeb }
|
||||
art.destroy()
|
||||
return result
|
||||
})).toEqual({ sameError: true, retained: true, style: true, active: false })
|
||||
})
|
||||
|
||||
test('candidate: a failed web fullscreen exit can retry restoring the original placement', async ({ page }) => {
|
||||
await setup(page, 'candidate')
|
||||
expect(await page.evaluate(() => {
|
||||
const art = window.art
|
||||
const { $player: player, $container: container } = art.template
|
||||
art.constructor.FULLSCREEN_WEB_IN_BODY = true
|
||||
const style = player.getAttribute('style')
|
||||
art.fullscreenWeb = true
|
||||
const insert = container.insertBefore
|
||||
const failure = new Error('controlled restore failure')
|
||||
container.insertBefore = () => {
|
||||
throw failure
|
||||
}
|
||||
let sameError = false
|
||||
try {
|
||||
art.fullscreenWeb = false
|
||||
}
|
||||
catch (error) {
|
||||
sameError = error === failure
|
||||
}
|
||||
container.insertBefore = insert
|
||||
art.fullscreenWeb = false
|
||||
const result = { sameError, retained: container.contains(player), style: player.getAttribute('style') === style, active: art.fullscreenWeb }
|
||||
art.destroy()
|
||||
return result
|
||||
})).toEqual({ sameError: true, retained: true, style: true, active: false })
|
||||
})
|
||||
|
||||
test('candidate: reentry during web fullscreen restoration preserves the newer active mode', async ({ page }) => {
|
||||
await setup(page, 'candidate')
|
||||
expect(await page.evaluate(() => {
|
||||
const art = window.art
|
||||
const { $player: player, $container: container } = art.template
|
||||
art.constructor.FULLSCREEN_WEB_IN_BODY = true
|
||||
player.style.width = '65%'
|
||||
const style = player.getAttribute('style')
|
||||
art.fullscreenWeb = true
|
||||
const insert = container.insertBefore
|
||||
container.insertBefore = function (node, anchor) {
|
||||
container.insertBefore = insert
|
||||
const result = insert.call(this, node, anchor)
|
||||
art.fullscreenWeb = true
|
||||
return result
|
||||
}
|
||||
art.fullscreenWeb = false
|
||||
const winner = art.fullscreenWeb && player.parentNode === document.body && player.style.width === '100%'
|
||||
art.fullscreenWeb = false
|
||||
const restored = container.contains(player) && player.getAttribute('style') === style
|
||||
art.destroy()
|
||||
return { winner, restored }
|
||||
})).toEqual({ winner: true, restored: true })
|
||||
})
|
||||
|
||||
for (const core of ['published', 'candidate']) {
|
||||
for (const initial of ['', 'width: 70%; height: 80%; color: red;']) {
|
||||
test(`${core}: web fullscreen restores initial style after repeated entry (${initial || 'empty'})`, async ({ page }, testInfo) => {
|
||||
await setup(page, core)
|
||||
const result = await page.evaluate((initial) => {
|
||||
const art = window.art
|
||||
art.constructor.FULLSCREEN_WEB_IN_BODY = false
|
||||
const player = art.template.$player
|
||||
player.style.cssText = initial
|
||||
const before = player.style.cssText
|
||||
const events = []
|
||||
let restoredAtExit
|
||||
art.on('fullscreenWeb', (value) => {
|
||||
events.push(value)
|
||||
if (!value)
|
||||
restoredAtExit = player.style.cssText
|
||||
})
|
||||
art.fullscreenWeb = true
|
||||
art.fullscreenWeb = true
|
||||
art.fullscreenWeb = false
|
||||
const result = { restored: restoredAtExit === before, events, active: art.fullscreenWeb, dimensions: [player.style.width, player.style.height, player.style.color], styles: { before, restoredAtExit, after: player.style.cssText } }
|
||||
art.destroy()
|
||||
return result
|
||||
}, initial)
|
||||
const { styles, ...observed } = result
|
||||
await testInfo.attach('fullscreen-styles', { contentType: 'application/json', body: JSON.stringify(styles) })
|
||||
expect(observed).toEqual({ restored: core === 'candidate', events: [true, true, false], active: false, dimensions: core === 'candidate' ? (initial ? ['70%', '80%', 'red'] : ['', '', '']) : ['100%', '100%', initial ? 'red' : ''] })
|
||||
})
|
||||
}
|
||||
|
||||
for (const changeFlag of [false, true]) {
|
||||
test(`${core}: body fullscreen restores original parent and sibling when flag changes=${changeFlag}`, async ({ page }) => {
|
||||
await setup(page, core)
|
||||
const result = await page.evaluate((changeFlag) => {
|
||||
const art = window.art
|
||||
const player = art.template.$player
|
||||
const wrapper = document.createElement('div')
|
||||
const sibling = document.createElement('span')
|
||||
art.template.$container.append(wrapper)
|
||||
wrapper.append(player, sibling)
|
||||
art.constructor.FULLSCREEN_WEB_IN_BODY = true
|
||||
art.fullscreenWeb = true
|
||||
const entered = player.parentNode === document.body
|
||||
if (changeFlag)
|
||||
art.constructor.FULLSCREEN_WEB_IN_BODY = false
|
||||
art.fullscreenWeb = false
|
||||
const result = { entered, parent: player.parentNode === wrapper, sibling: player.nextSibling === sibling, inBody: player.parentNode === document.body }
|
||||
art.destroy()
|
||||
player.remove()
|
||||
return result
|
||||
}, changeFlag)
|
||||
expect(result).toEqual({ entered: true, parent: core === 'candidate', sibling: core === 'candidate', inBody: core === 'published' && changeFlag })
|
||||
})
|
||||
}
|
||||
|
||||
for (const removeHtml of [false, true]) {
|
||||
test(`${core}: destroy while web fullscreen removeHtml=${removeHtml} restores DOM ownership`, async ({ page }) => {
|
||||
await setup(page, core)
|
||||
const result = await page.evaluate((removeHtml) => {
|
||||
const art = window.art
|
||||
const { $player: player, $container: container } = art.template
|
||||
const style = player.style.cssText
|
||||
art.constructor.FULLSCREEN_WEB_IN_BODY = true
|
||||
art.fullscreenWeb = true
|
||||
art.destroy(removeHtml)
|
||||
const result = { orphan: player.parentNode === document.body, retained: container.contains(player), style: player.style.cssText === style, fullscreen: player.classList.contains('art-fullscreen-web') }
|
||||
player.remove()
|
||||
return result
|
||||
}, removeHtml)
|
||||
expect(result).toEqual(core === 'candidate'
|
||||
? { orphan: false, retained: !removeHtml, style: true, fullscreen: false }
|
||||
: { orphan: true, retained: false, style: false, fullscreen: true })
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,30 @@
|
||||
import assert from 'node:assert/strict'
|
||||
// eslint-disable-next-line test/no-import-node-test -- Use the repository Node test runner.
|
||||
import { test } from 'node:test'
|
||||
import { loadModules } from './helpers/load.js'
|
||||
|
||||
const { miniPosition, clampMini } = await loadModules({
|
||||
miniPosition: { file: 'packages/artplayer/src/display/mini-layout', name: 'miniPosition' },
|
||||
clampMini: { file: 'packages/artplayer/src/display/mini-layout', name: 'clampMini' },
|
||||
})
|
||||
const geometry = { width: 320, height: 180, viewportWidth: 960, viewportHeight: 720 }
|
||||
|
||||
test('mini preserves finite visible stored coordinates including zero', () => {
|
||||
assert.deepEqual(miniPosition(0, 0, geometry), { left: 0, top: 0, reset: false })
|
||||
assert.deepEqual(miniPosition(640, 540, geometry), { left: 640, top: 540, reset: false })
|
||||
})
|
||||
|
||||
test('mini replaces missing, non-finite and offscreen storage with the legacy 50px inset', () => {
|
||||
for (const [left, top] of [[undefined, null], [Number.NaN, 20], [20, Infinity], ['20', 20], [-1, 50], [800, 400], [200, 700]])
|
||||
assert.deepEqual(miniPosition(left, top, geometry), { left: 590, top: 490, reset: true })
|
||||
})
|
||||
|
||||
test('mini default position remains reachable on small viewports', () => {
|
||||
assert.deepEqual(miniPosition(null, null, { width: 280, height: 160, viewportWidth: 280, viewportHeight: 160 }), { left: 0, top: 0, reset: true })
|
||||
})
|
||||
|
||||
test('mini drag clamps both boundaries without changing positions inside the viewport', () => {
|
||||
assert.deepEqual(clampMini(-100, 900, geometry), { left: 0, top: 540 })
|
||||
assert.deepEqual(clampMini(600, 400, geometry), { left: 600, top: 400 })
|
||||
assert.deepEqual(clampMini(900, -2, geometry), { left: 640, top: 0 })
|
||||
})
|
||||
@@ -0,0 +1,324 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { getEventListeners } from 'node:events'
|
||||
// eslint-disable-next-line test/no-import-node-test -- Use the repository Node test runner.
|
||||
import { test } from 'node:test'
|
||||
import { setImmediate } from 'node:timers/promises'
|
||||
import { loadModules } from './helpers/load.js'
|
||||
|
||||
const { ResourceScope, requestFullscreen, nativeFullscreen, beginLifecycle, getScope } = await loadModules({
|
||||
ResourceScope: 'packages/artplayer/src/lifecycle/scope',
|
||||
requestFullscreen: { file: 'packages/artplayer/src/display/fullscreen-request', name: 'requestFullscreen' },
|
||||
nativeFullscreen: { file: 'packages/artplayer/src/display/native-fullscreen', name: 'nativeFullscreen' },
|
||||
beginLifecycle: { file: 'packages/artplayer/src/lifecycle/instance', name: 'beginLifecycle' },
|
||||
getScope: { file: 'packages/artplayer/src/lifecycle/instance', name: 'getScope' },
|
||||
})
|
||||
|
||||
function fixture() {
|
||||
const classes = new Set()
|
||||
const target = { classList: {
|
||||
remove: name => classes.delete(name),
|
||||
toggle: (name, value) => value ? classes.add(name) : classes.delete(name),
|
||||
} }
|
||||
const adapter = {
|
||||
document: new EventTarget(),
|
||||
target,
|
||||
element: null,
|
||||
elementProperty: 'fullscreenElement',
|
||||
changeEvent: 'fullscreenchange',
|
||||
errorEvent: 'fullscreenerror',
|
||||
request: () => Promise.resolve(),
|
||||
exit: () => Promise.resolve(),
|
||||
}
|
||||
Object.defineProperty(adapter.document, 'fullscreenElement', { get: () => adapter.element })
|
||||
const events = []
|
||||
const art = {
|
||||
template: { $player: target, $video: {} },
|
||||
notice: { show: '' },
|
||||
state: 'standard',
|
||||
emit: (...args) => events.push(args),
|
||||
}
|
||||
beginLifecycle(art)
|
||||
const descriptor = nativeFullscreen(art, adapter)
|
||||
const change = (element) => {
|
||||
adapter.element = element
|
||||
adapter.document.dispatchEvent(new Event(adapter.changeEvent))
|
||||
}
|
||||
const listeners = () => [adapter.changeEvent, adapter.errorEvent].map(name => getEventListeners(adapter.document, name).length)
|
||||
return { art, adapter, classes, descriptor, change, events, listeners }
|
||||
}
|
||||
|
||||
test('fullscreen native invocation retains the caller stack and original rejection', async () => {
|
||||
const { adapter } = fixture()
|
||||
const parent = new ResourceScope()
|
||||
const failure = new Error('denied')
|
||||
let called = false
|
||||
adapter.request = () => {
|
||||
called = true
|
||||
return Promise.reject(failure)
|
||||
}
|
||||
const operation = requestFullscreen(adapter, parent, true, () => assert.fail('not cancelled'))
|
||||
assert.equal(called, true)
|
||||
await assert.rejects(operation.promise, error => error === failure)
|
||||
assert.equal(getEventListeners(adapter.document, adapter.changeEvent).length, 1)
|
||||
assert.equal(getEventListeners(adapter.document, adapter.errorEvent).length, 1)
|
||||
parent.dispose()
|
||||
})
|
||||
|
||||
test('void fullscreen APIs wait for the actual change and remove operation listeners', async () => {
|
||||
const { adapter, change, listeners } = fixture()
|
||||
adapter.request = () => undefined
|
||||
const parent = new ResourceScope()
|
||||
const operation = requestFullscreen(adapter, parent, true, () => {})
|
||||
let settled = false
|
||||
void operation.promise.then(() => settled = true)
|
||||
await setImmediate()
|
||||
assert.equal(settled, false)
|
||||
assert.deepEqual(listeners(), [2, 2])
|
||||
change(adapter.target)
|
||||
await operation.promise
|
||||
assert.deepEqual(listeners(), [1, 1])
|
||||
parent.dispose()
|
||||
})
|
||||
|
||||
test('void native exit waits while the instance video still owns fullscreen', async () => {
|
||||
const { art, adapter, descriptor, change, listeners } = fixture()
|
||||
change(art.template.$video)
|
||||
adapter.exit = () => undefined
|
||||
let settled = false
|
||||
const exit = descriptor.set(false).then(() => settled = true)
|
||||
await setImmediate()
|
||||
assert.equal(settled, false)
|
||||
assert.deepEqual(listeners(), [2, 2])
|
||||
change(null)
|
||||
await exit
|
||||
assert.equal(settled, true)
|
||||
assert.deepEqual(listeners(), [1, 1])
|
||||
getScope(art).dispose()
|
||||
})
|
||||
|
||||
test('fullscreen request disposal settles without waiting and handles late completion once', async () => {
|
||||
const { adapter, listeners } = fixture()
|
||||
let complete
|
||||
let late = 0
|
||||
adapter.request = () => new Promise(resolve => complete = resolve)
|
||||
const parent = new ResourceScope()
|
||||
const operation = requestFullscreen(adapter, parent, true, () => late++)
|
||||
parent.dispose()
|
||||
await operation.promise
|
||||
assert.deepEqual(listeners(), [1, 1])
|
||||
complete()
|
||||
await setImmediate()
|
||||
assert.equal(late, 1)
|
||||
})
|
||||
|
||||
test('closed fullscreen request scopes never call the browser', async () => {
|
||||
const { adapter } = fixture()
|
||||
adapter.request = () => assert.fail('closed request invoked')
|
||||
const parent = new ResourceScope()
|
||||
parent.dispose()
|
||||
await requestFullscreen(adapter, parent, true, () => {}).promise
|
||||
})
|
||||
|
||||
test('native promise rejection retains its error when fullscreenerror arrives first', async () => {
|
||||
const { adapter } = fixture()
|
||||
const failure = new Error('browser permission denied')
|
||||
adapter.request = () => {
|
||||
adapter.document.dispatchEvent(new Event(adapter.errorEvent))
|
||||
return Promise.reject(failure)
|
||||
}
|
||||
const parent = new ResourceScope()
|
||||
await assert.rejects(requestFullscreen(adapter, parent, true, () => {}).promise, error => error === failure)
|
||||
parent.dispose()
|
||||
})
|
||||
|
||||
test('void fullscreen errors reject and release both temporary listeners', async () => {
|
||||
const { adapter, listeners } = fixture()
|
||||
adapter.request = () => undefined
|
||||
const parent = new ResourceScope()
|
||||
const operation = requestFullscreen(adapter, parent, true, () => {})
|
||||
adapter.document.dispatchEvent(new Event(adapter.errorEvent))
|
||||
await assert.rejects(operation.promise, /Fullscreen request failed/)
|
||||
assert.deepEqual(listeners(), [1, 1])
|
||||
parent.dispose()
|
||||
})
|
||||
|
||||
test('synchronous void fullscreen errors reject without waiting for a second event', async () => {
|
||||
const { adapter, listeners } = fixture()
|
||||
adapter.request = () => {
|
||||
adapter.document.dispatchEvent(new Event(adapter.errorEvent))
|
||||
}
|
||||
const parent = new ResourceScope()
|
||||
await assert.rejects(requestFullscreen(adapter, parent, true, () => {}).promise, /Fullscreen request failed/)
|
||||
assert.deepEqual(listeners(), [1, 1])
|
||||
parent.dispose()
|
||||
})
|
||||
|
||||
for (const close of ['exit', 'destroy']) {
|
||||
test(`pending fullscreen ${close} settles promptly and exits its late native entry`, async () => {
|
||||
const { art, adapter, descriptor, listeners } = fixture()
|
||||
let complete
|
||||
let exits = 0
|
||||
adapter.request = () => new Promise(resolve => complete = resolve)
|
||||
adapter.exit = () => {
|
||||
exits++
|
||||
adapter.element = null
|
||||
return Promise.resolve()
|
||||
}
|
||||
const entering = descriptor.set(true)
|
||||
if (close === 'destroy')
|
||||
getScope(art).dispose()
|
||||
else
|
||||
await descriptor.set(false)
|
||||
await entering
|
||||
assert.deepEqual(listeners(), close === 'destroy' ? [0, 0] : [1, 1])
|
||||
adapter.element = adapter.target
|
||||
complete()
|
||||
await setImmediate()
|
||||
assert.equal(exits, 1)
|
||||
assert.equal(descriptor.get(), false)
|
||||
})
|
||||
}
|
||||
|
||||
test('late cancelled fullscreen completion cannot exit another player', async () => {
|
||||
const { art, adapter, descriptor } = fixture()
|
||||
let complete
|
||||
adapter.request = () => new Promise(resolve => complete = resolve)
|
||||
adapter.exit = () => assert.fail('exited another player')
|
||||
const entering = descriptor.set(true)
|
||||
getScope(art).dispose()
|
||||
await entering
|
||||
adapter.element = {}
|
||||
complete()
|
||||
await setImmediate()
|
||||
})
|
||||
|
||||
test('cancelled void fullscreen requests retain no instance listeners and release late entry', async () => {
|
||||
const { art, adapter, descriptor, change, listeners, events } = fixture()
|
||||
let exits = 0
|
||||
adapter.request = () => undefined
|
||||
adapter.exit = () => {
|
||||
exits++
|
||||
adapter.element = null
|
||||
}
|
||||
const entering = descriptor.set(true)
|
||||
getScope(art).dispose()
|
||||
await entering
|
||||
// One shared document guard remains; both per-instance listeners are gone.
|
||||
assert.deepEqual(listeners(), [1, 0])
|
||||
change({})
|
||||
assert.equal(exits, 0)
|
||||
change(adapter.target)
|
||||
assert.equal(exits, 1)
|
||||
assert.equal(adapter.element, null)
|
||||
assert.deepEqual(events, [])
|
||||
})
|
||||
|
||||
test('new native entry supersedes an abandoned void request for the same target', async () => {
|
||||
const { art, adapter, descriptor, change } = fixture()
|
||||
adapter.request = () => undefined
|
||||
adapter.exit = () => assert.fail('new entry was mistaken for cancellation')
|
||||
const first = descriptor.set(true)
|
||||
await descriptor.set(false)
|
||||
await first
|
||||
const second = descriptor.set(true)
|
||||
change(adapter.target)
|
||||
await second
|
||||
assert.equal(descriptor.get(), true)
|
||||
adapter.element = null
|
||||
getScope(art).dispose()
|
||||
})
|
||||
|
||||
test('cancelled void entry never emits a stale true event or sets its fullscreen class', async () => {
|
||||
const { art, adapter, descriptor, change, events, classes } = fixture()
|
||||
adapter.request = () => undefined
|
||||
adapter.exit = () => change(null)
|
||||
const entering = descriptor.set(true)
|
||||
await descriptor.set(false)
|
||||
await entering
|
||||
change(adapter.target)
|
||||
assert.deepEqual(events, [])
|
||||
assert.equal(classes.has('art-fullscreen'), false)
|
||||
assert.equal(descriptor.get(), false)
|
||||
getScope(art).dispose()
|
||||
})
|
||||
|
||||
test('cancelled promise entry rejects a late change before its native promise resolves', async () => {
|
||||
const { art, adapter, descriptor, change, events, classes } = fixture()
|
||||
let complete
|
||||
adapter.request = () => new Promise(resolve => complete = resolve)
|
||||
adapter.exit = () => {
|
||||
change(null)
|
||||
return Promise.resolve()
|
||||
}
|
||||
const entering = descriptor.set(true)
|
||||
await descriptor.set(false)
|
||||
await entering
|
||||
change(adapter.target)
|
||||
complete()
|
||||
await setImmediate()
|
||||
assert.deepEqual(events, [])
|
||||
assert.equal(classes.has('art-fullscreen'), false)
|
||||
assert.equal(descriptor.get(), false)
|
||||
getScope(art).dispose()
|
||||
})
|
||||
|
||||
test('fullscreen rejection stays observable through the descriptor and reports notice', async () => {
|
||||
const { art, adapter, descriptor, listeners } = fixture()
|
||||
const failure = new Error('denied')
|
||||
adapter.request = () => Promise.reject(failure)
|
||||
await assert.rejects(descriptor.set(true), error => error === failure)
|
||||
assert.equal(art.notice.show, failure)
|
||||
assert.deepEqual(listeners(), [1, 1])
|
||||
getScope(art).dispose()
|
||||
})
|
||||
|
||||
test('fullscreen event precedes the class update and resize', async () => {
|
||||
const { art, adapter, descriptor, classes, change } = fixture()
|
||||
const observations = []
|
||||
art.emit = (name, value) => observations.push([name, value, classes.has('art-fullscreen')])
|
||||
adapter.request = () => {
|
||||
change(adapter.target)
|
||||
return Promise.resolve()
|
||||
}
|
||||
await descriptor.set(true)
|
||||
assert.deepEqual(observations, [['fullscreen', true, false], ['resize', undefined, true]])
|
||||
getScope(art).dispose()
|
||||
})
|
||||
|
||||
test('reentrant true during fullscreen entry still updates its class and resize', async () => {
|
||||
const { art, adapter, descriptor, classes, change, events } = fixture()
|
||||
art.emit = (...args) => {
|
||||
events.push(args)
|
||||
if (args[0] === 'fullscreen' && args[1])
|
||||
void descriptor.set(true)
|
||||
}
|
||||
adapter.request = () => {
|
||||
change(adapter.target)
|
||||
return Promise.resolve()
|
||||
}
|
||||
await descriptor.set(true)
|
||||
assert.equal(classes.has('art-fullscreen'), true)
|
||||
assert.deepEqual(events, [['fullscreen', true], ['resize']])
|
||||
getScope(art).dispose()
|
||||
})
|
||||
|
||||
test('reentrant exit during fullscreen entry prevents stale class and resize', async () => {
|
||||
const { art, adapter, descriptor, classes, change, events } = fixture()
|
||||
art.emit = (...args) => {
|
||||
events.push(args)
|
||||
if (args[0] === 'fullscreen' && args[1])
|
||||
void descriptor.set(false)
|
||||
}
|
||||
adapter.request = () => {
|
||||
change(adapter.target)
|
||||
return Promise.resolve()
|
||||
}
|
||||
adapter.exit = () => {
|
||||
change(null)
|
||||
return Promise.resolve()
|
||||
}
|
||||
await descriptor.set(true)
|
||||
assert.equal(classes.has('art-fullscreen'), false)
|
||||
assert.deepEqual(events, [['fullscreen', true], ['fullscreen', false], ['resize']])
|
||||
getScope(art).dispose()
|
||||
})
|
||||
@@ -0,0 +1,141 @@
|
||||
import assert from 'node:assert/strict'
|
||||
// eslint-disable-next-line test/no-import-node-test -- Use the repository Node test runner.
|
||||
import { test } from 'node:test'
|
||||
import { setImmediate } from 'node:timers/promises'
|
||||
import { loadModules } from './helpers/load.js'
|
||||
|
||||
const { nativeOrientation, beginLifecycle, getScope } = await loadModules({
|
||||
nativeOrientation: { file: 'packages/artplayer/src/display/orientation-native', name: 'nativeOrientation' },
|
||||
beginLifecycle: { file: 'packages/artplayer/src/lifecycle/instance', name: 'beginLifecycle' },
|
||||
getScope: { file: 'packages/artplayer/src/lifecycle/instance', name: 'getScope' },
|
||||
})
|
||||
|
||||
function fixture(shared, need = () => true) {
|
||||
const calls = []
|
||||
const locks = []
|
||||
const orientation = shared || {
|
||||
type: 'portrait-primary',
|
||||
lock(mode) {
|
||||
calls.push(mode)
|
||||
return new Promise((resolve, reject) => locks.push({ resolve, reject }))
|
||||
},
|
||||
unlock() {
|
||||
calls.push('unlock')
|
||||
},
|
||||
}
|
||||
const classes = new Set()
|
||||
const art = {
|
||||
template: { $player: { classList: { add: name => classes.add(name), remove: name => classes.delete(name) }, ownerDocument: { defaultView: { screen: { orientation } } } } },
|
||||
notice: { show: '' },
|
||||
}
|
||||
beginLifecycle(art)
|
||||
return { art, orientation, calls, locks, classes, set: nativeOrientation(art, need), dispose: () => getScope(art).dispose() }
|
||||
}
|
||||
|
||||
test('orientation invokes synchronously, marks after success, and unlocks on exit', async () => {
|
||||
const f = fixture()
|
||||
assert.equal(f.set(true), undefined)
|
||||
assert.deepEqual(f.calls, ['landscape'])
|
||||
assert.equal(f.classes.size, 0)
|
||||
f.locks[0].resolve()
|
||||
await setImmediate()
|
||||
assert.deepEqual([...f.classes], ['art-auto-orientation-fullscreen'])
|
||||
f.set(false)
|
||||
assert.deepEqual(f.calls, ['landscape', 'unlock'])
|
||||
assert.equal(f.classes.size, 0)
|
||||
f.dispose()
|
||||
})
|
||||
|
||||
test('orientation repeated entry uses one pending or active lock', async () => {
|
||||
const f = fixture()
|
||||
f.set(true)
|
||||
f.set(true)
|
||||
f.locks[0].resolve()
|
||||
await setImmediate()
|
||||
f.set(true)
|
||||
assert.deepEqual(f.calls, ['landscape'])
|
||||
f.dispose()
|
||||
})
|
||||
|
||||
test('cancelled late orientation success is released without reactivating classes', async () => {
|
||||
const f = fixture()
|
||||
f.set(true)
|
||||
f.set(false)
|
||||
f.locks[0].resolve()
|
||||
await setImmediate()
|
||||
assert.deepEqual(f.calls, ['landscape', 'unlock', 'unlock'])
|
||||
assert.equal(f.classes.size, 0)
|
||||
assert.equal(f.art.notice.show, '')
|
||||
f.dispose()
|
||||
})
|
||||
|
||||
test('destroyed pending orientation ignores late rejection and further events', async () => {
|
||||
const f = fixture()
|
||||
f.set(true)
|
||||
f.dispose()
|
||||
f.art.notice.show = 'replacement notice'
|
||||
f.locks[0].reject(new Error('aborted'))
|
||||
await setImmediate()
|
||||
f.set(true)
|
||||
assert.deepEqual(f.calls, ['landscape', 'unlock'])
|
||||
assert.equal(f.art.notice.show, 'replacement notice')
|
||||
assert.equal(f.classes.size, 0)
|
||||
})
|
||||
|
||||
test('orientation failure preserves original error identity and allows retry', async () => {
|
||||
const f = fixture()
|
||||
const error = new Error('unsupported')
|
||||
f.set(true)
|
||||
f.locks[0].reject(error)
|
||||
await setImmediate()
|
||||
assert.equal(f.art.notice.show, error)
|
||||
f.orientation.type = 'landscape-primary'
|
||||
f.set(true)
|
||||
assert.deepEqual(f.calls, ['landscape', 'portrait'])
|
||||
f.locks[1].resolve()
|
||||
await setImmediate()
|
||||
f.dispose()
|
||||
})
|
||||
|
||||
test('orientation synchronous lock failures are reported and cleanup failure remains best effort', () => {
|
||||
const f = fixture()
|
||||
const error = new Error('sync denied')
|
||||
f.orientation.lock = () => {
|
||||
throw error
|
||||
}
|
||||
f.set(true)
|
||||
assert.equal(f.art.notice.show, error)
|
||||
f.orientation.lock = () => new Promise(() => {})
|
||||
f.orientation.unlock = () => {
|
||||
throw new Error('unlock denied')
|
||||
}
|
||||
f.set(true)
|
||||
assert.doesNotThrow(f.dispose)
|
||||
})
|
||||
|
||||
test('superseded orientation completion and old destruction cannot unlock a newer instance', async () => {
|
||||
const first = fixture()
|
||||
const second = fixture(first.orientation)
|
||||
first.set(true)
|
||||
second.set(true)
|
||||
first.locks[1].resolve()
|
||||
await setImmediate()
|
||||
first.locks[0].resolve()
|
||||
await setImmediate()
|
||||
first.dispose()
|
||||
assert.deepEqual(first.calls, ['landscape', 'landscape'])
|
||||
assert.equal(first.classes.size, 0)
|
||||
assert.equal(second.classes.size, 1)
|
||||
second.dispose()
|
||||
assert.deepEqual(first.calls, ['landscape', 'landscape', 'unlock'])
|
||||
})
|
||||
|
||||
test('no rotation requirement and incomplete platform capabilities do not call lock', () => {
|
||||
const f = fixture(undefined, () => false)
|
||||
f.set(true)
|
||||
assert.deepEqual(f.calls, [])
|
||||
f.dispose()
|
||||
const missing = fixture({ type: 'portrait-primary', lock: true })
|
||||
assert.doesNotThrow(() => missing.set(true))
|
||||
missing.dispose()
|
||||
})
|
||||
@@ -0,0 +1,215 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { getEventListeners } from 'node:events'
|
||||
// eslint-disable-next-line test/no-import-node-test -- Use the repository Node test runner.
|
||||
import { test } from 'node:test'
|
||||
import { setImmediate } from 'node:timers/promises'
|
||||
import { loadModules } from './helpers/load.js'
|
||||
|
||||
const { Emitter, nativePip, webkitPip, beginLifecycle, getScope } = await loadModules({
|
||||
Emitter: 'packages/artplayer/src/utils/emitter',
|
||||
nativePip: { file: 'packages/artplayer/src/display/native-pip', name: 'nativePip' },
|
||||
webkitPip: { file: 'packages/artplayer/src/display/webkit-pip', name: 'webkitPip' },
|
||||
beginLifecycle: { file: 'packages/artplayer/src/lifecycle/instance', name: 'beginLifecycle' },
|
||||
getScope: { file: 'packages/artplayer/src/lifecycle/instance', name: 'getScope' },
|
||||
})
|
||||
|
||||
function fixture(kind = 'native') {
|
||||
const video = new EventTarget()
|
||||
video.ownerDocument = {
|
||||
pictureInPictureElement: null,
|
||||
exitPictureInPicture() {
|
||||
const previous = this.pictureInPictureElement
|
||||
this.pictureInPictureElement = null
|
||||
previous?.dispatchEvent(new Event('leavepictureinpicture'))
|
||||
return Promise.resolve()
|
||||
},
|
||||
}
|
||||
video.requestPictureInPicture = () => {
|
||||
video.ownerDocument.pictureInPictureElement = video
|
||||
video.dispatchEvent(new Event('enterpictureinpicture'))
|
||||
return Promise.resolve({ width: 320, height: 180 })
|
||||
}
|
||||
video.webkitPresentationMode = 'inline'
|
||||
video.webkitSetPresentationMode = (mode) => {
|
||||
video.webkitPresentationMode = mode
|
||||
video.dispatchEvent(new Event('webkitpresentationmodechanged'))
|
||||
video.dispatchEvent(new Event(mode === 'picture-in-picture' ? 'enterpictureinpicture' : 'leavepictureinpicture'))
|
||||
}
|
||||
const art = Object.assign(new Emitter(), { template: { $video: video }, state: 'standard', notice: { show: '' }, i18n: { get: key => key } })
|
||||
beginLifecycle(art)
|
||||
Object.defineProperty(art, 'pip', kind === 'native' ? nativePip(art) : webkitPip(art))
|
||||
const events = []
|
||||
art.on('pip', value => events.push(value))
|
||||
return { art, video, document: video.ownerDocument, events }
|
||||
}
|
||||
|
||||
test('native PiP invokes immediately, retains element identity and void setter', () => {
|
||||
const { art, video, events } = fixture()
|
||||
assert.equal(Object.getOwnPropertyDescriptor(art, 'pip').set(true), undefined)
|
||||
assert.equal(art.pip, video)
|
||||
assert.deepEqual(events, [true])
|
||||
art.pip = false
|
||||
assert.equal(art.pip, null)
|
||||
assert.deepEqual(events, [true, false])
|
||||
getScope(art).dispose()
|
||||
})
|
||||
|
||||
test('native PiP ignores a queued leave from an older session while the video still owns PiP', () => {
|
||||
const { art, video, events } = fixture()
|
||||
art.pip = true
|
||||
video.dispatchEvent(new Event('leavepictureinpicture'))
|
||||
assert.equal(art.pip, video)
|
||||
assert.deepEqual(events, [true])
|
||||
art.pip = false
|
||||
assert.deepEqual(events, [true, false])
|
||||
getScope(art).dispose()
|
||||
})
|
||||
|
||||
test('native PiP rejection preserves the notice error without unhandled rejection', async () => {
|
||||
const { art, video, events } = fixture()
|
||||
const failure = new Error('denied')
|
||||
video.requestPictureInPicture = () => Promise.reject(failure)
|
||||
art.pip = true
|
||||
await setImmediate()
|
||||
assert.equal(art.notice.show, failure)
|
||||
assert.equal(art.pip, null)
|
||||
assert.deepEqual(events, [])
|
||||
getScope(art).dispose()
|
||||
})
|
||||
|
||||
test('native PiP synchronous errors remain synchronous and retain identity', () => {
|
||||
const { art, video } = fixture()
|
||||
const failure = new Error('sync denial')
|
||||
video.requestPictureInPicture = () => {
|
||||
throw failure
|
||||
}
|
||||
assert.throws(() => {
|
||||
art.pip = true
|
||||
}, error => error === failure)
|
||||
getScope(art).dispose()
|
||||
})
|
||||
|
||||
test('obsolete native PiP failure does not replace the latest notice', async () => {
|
||||
const { art, video } = fixture()
|
||||
let reject
|
||||
video.requestPictureInPicture = () => new Promise((resolve, fail) => reject = fail)
|
||||
art.pip = true
|
||||
art.pip = false
|
||||
art.notice.show = 'current notice'
|
||||
reject(new Error('old request'))
|
||||
await setImmediate()
|
||||
assert.equal(art.notice.show, 'current notice')
|
||||
getScope(art).dispose()
|
||||
})
|
||||
|
||||
test('destroyed native PiP ignores late completion when another video owns PiP', async () => {
|
||||
const { art, video, document } = fixture()
|
||||
let complete
|
||||
video.requestPictureInPicture = () => new Promise(resolve => complete = resolve)
|
||||
art.pip = true
|
||||
getScope(art).dispose()
|
||||
document.pictureInPictureElement = {}
|
||||
document.exitPictureInPicture = () => assert.fail('exited another video')
|
||||
complete({})
|
||||
await setImmediate()
|
||||
assert.equal(getEventListeners(video, 'enterpictureinpicture').length, 0)
|
||||
assert.equal(getEventListeners(video, 'leavepictureinpicture').length, 0)
|
||||
})
|
||||
|
||||
test('native PiP exit rejection keeps real state and permits retry', async () => {
|
||||
const { art, document, video, events } = fixture()
|
||||
art.pip = true
|
||||
await setImmediate()
|
||||
const exit = document.exitPictureInPicture
|
||||
const failure = new Error('exit denied')
|
||||
document.exitPictureInPicture = () => Promise.reject(failure)
|
||||
art.pip = false
|
||||
await setImmediate()
|
||||
assert.equal(art.pip, video)
|
||||
assert.equal(art.notice.show, failure)
|
||||
document.exitPictureInPicture = exit
|
||||
art.pip = false
|
||||
await setImmediate()
|
||||
assert.equal(art.pip, null)
|
||||
assert.deepEqual(events, [true, false])
|
||||
getScope(art).dispose()
|
||||
})
|
||||
|
||||
test('WebKit PiP retains boolean getter and repeated synchronous setter events', () => {
|
||||
const { art, events } = fixture('webkit')
|
||||
art.pip = true
|
||||
art.pip = true
|
||||
assert.equal(art.pip, true)
|
||||
art.pip = false
|
||||
assert.equal(art.pip, false)
|
||||
assert.deepEqual(events, [true, true, false])
|
||||
getScope(art).dispose()
|
||||
})
|
||||
|
||||
test('WebKit PiP observes external presentation transitions without duplicate native signals', () => {
|
||||
const { art, video, events } = fixture('webkit')
|
||||
video.webkitSetPresentationMode('picture-in-picture')
|
||||
video.webkitSetPresentationMode('inline')
|
||||
assert.deepEqual(events, [true, false])
|
||||
getScope(art).dispose()
|
||||
})
|
||||
|
||||
test('WebKit PiP false does not exit a different fullscreen presentation', () => {
|
||||
const { art, video } = fixture('webkit')
|
||||
video.webkitPresentationMode = 'fullscreen'
|
||||
art.pip = false
|
||||
assert.equal(video.webkitPresentationMode, 'fullscreen')
|
||||
getScope(art).dispose()
|
||||
})
|
||||
|
||||
test('destroyed WebKit PiP releases delayed entry without retaining instance listeners', () => {
|
||||
const { art, video, events } = fixture('webkit')
|
||||
const setMode = video.webkitSetPresentationMode
|
||||
video.webkitSetPresentationMode = () => {}
|
||||
art.pip = true
|
||||
getScope(art).dispose()
|
||||
video.webkitSetPresentationMode = setMode
|
||||
setMode('picture-in-picture')
|
||||
assert.equal(video.webkitPresentationMode, 'inline')
|
||||
assert.deepEqual(events, [])
|
||||
assert.equal(getEventListeners(video, 'webkitpresentationmodechanged').length, 0)
|
||||
})
|
||||
|
||||
test('WebKit PiP destruction inside entry callback stops stale notifications', () => {
|
||||
const { art, video, events } = fixture('webkit')
|
||||
art.on('pip', () => getScope(art).dispose())
|
||||
art.pip = true
|
||||
assert.equal(video.webkitPresentationMode, 'inline')
|
||||
assert.deepEqual(events, [true])
|
||||
})
|
||||
|
||||
for (const kind of ['native', 'webkit']) {
|
||||
test(`${kind} PiP reentrant cancellation retains its branch-specific setter event contract`, async () => {
|
||||
const { art, video, events } = fixture(kind)
|
||||
// eslint-disable-next-line accessor-pairs -- Intercept mode synchronization without implementing the unrelated state getter.
|
||||
Object.defineProperty(art, 'state', { set(value) {
|
||||
if (value === 'pip')
|
||||
art.pip = false
|
||||
} })
|
||||
if (kind === 'native')
|
||||
await video.requestPictureInPicture()
|
||||
else
|
||||
video.webkitSetPresentationMode('picture-in-picture')
|
||||
assert.deepEqual(events, kind === 'native' ? [] : [false])
|
||||
getScope(art).dispose()
|
||||
})
|
||||
}
|
||||
|
||||
test('WebKit PiP checks current support and can retry after media becomes eligible', () => {
|
||||
const { art, video, events } = fixture('webkit')
|
||||
video.webkitSupportsPresentationMode = () => false
|
||||
art.pip = true
|
||||
assert.equal(art.pip, false)
|
||||
assert.equal(art.notice.show, 'PIP Not Supported')
|
||||
assert.deepEqual(events, [])
|
||||
video.webkitSupportsPresentationMode = () => true
|
||||
art.pip = true
|
||||
assert.equal(art.pip, true)
|
||||
assert.deepEqual(events, [true])
|
||||
getScope(art).dispose()
|
||||
})
|
||||
@@ -0,0 +1,43 @@
|
||||
import assert from 'node:assert/strict'
|
||||
// eslint-disable-next-line test/no-import-node-test -- Use the repository Node test runner.
|
||||
import { test } from 'node:test'
|
||||
import { loadModules } from './helpers/load.js'
|
||||
|
||||
const { containSize, proportionalHeight } = await loadModules({
|
||||
containSize: { file: 'packages/artplayer/src/display/sizing', name: 'containSize' },
|
||||
proportionalHeight: { file: 'packages/artplayer/src/display/sizing', name: 'proportionalHeight' },
|
||||
})
|
||||
|
||||
test('contained media preserves its ratio, fits both bounds and fills one axis', () => {
|
||||
for (const container of [{ width: 640, height: 480 }, { width: 160, height: 900 }, { width: 0.5, height: 0.25 }]) {
|
||||
for (const ratio of [16 / 9, 9 / 16, 1, 4 / 3, 0.001, 1000]) {
|
||||
const size = containSize(container, ratio)
|
||||
assert.ok(size.width <= container.width && size.height <= container.height)
|
||||
assert.ok(Math.abs(size.width / size.height - ratio) <= ratio * 1e-12)
|
||||
assert.ok(size.width === container.width || size.height === container.height)
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
test('zero, negative and non-finite geometry cannot produce layout writes', () => {
|
||||
for (const invalid of [0, -1, Number.NaN, Infinity, -Infinity]) {
|
||||
assert.equal(containSize({ width: invalid, height: 480 }, 16 / 9), undefined)
|
||||
assert.equal(containSize({ width: 640, height: invalid }, 16 / 9), undefined)
|
||||
assert.equal(containSize({ width: 640, height: 480 }, invalid), undefined)
|
||||
assert.equal(proportionalHeight(invalid, { width: 1920, height: 1080 }), undefined)
|
||||
assert.equal(proportionalHeight(640, { width: invalid, height: 1080 }), undefined)
|
||||
assert.equal(proportionalHeight(640, { width: 1920, height: invalid }), undefined)
|
||||
}
|
||||
})
|
||||
|
||||
test('auto height uses media proportions and recovers when dimensions become ready', () => {
|
||||
assert.equal(proportionalHeight(640, { width: 0, height: 0 }), undefined)
|
||||
assert.equal(proportionalHeight(640, { width: 1920, height: 1080 }), 360)
|
||||
assert.equal(proportionalHeight(360, { width: 1080, height: 1920 }), 640)
|
||||
})
|
||||
|
||||
test('overflow and underflow never escape as infinite or zero layout measurements', () => {
|
||||
assert.equal(proportionalHeight(Number.MAX_VALUE, { width: Number.MIN_VALUE, height: 1 }), undefined)
|
||||
assert.equal(proportionalHeight(Number.MIN_VALUE, { width: Number.MAX_VALUE, height: 1 }), undefined)
|
||||
assert.equal(containSize({ width: Number.MIN_VALUE, height: Number.MIN_VALUE }, Number.MAX_VALUE), undefined)
|
||||
})
|
||||
@@ -0,0 +1,151 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import { getEventListeners } from 'node:events'
|
||||
// eslint-disable-next-line test/no-import-node-test -- Use the repository Node test runner.
|
||||
import { test } from 'node:test'
|
||||
import { loadModules } from './helpers/load.js'
|
||||
|
||||
const { Emitter, videoFullscreen, beginLifecycle, getScope } = await loadModules({
|
||||
Emitter: 'packages/artplayer/src/utils/emitter',
|
||||
videoFullscreen: { file: 'packages/artplayer/src/display/video-fullscreen', name: 'videoFullscreen' },
|
||||
beginLifecycle: { file: 'packages/artplayer/src/lifecycle/instance', name: 'beginLifecycle' },
|
||||
getScope: { file: 'packages/artplayer/src/lifecycle/instance', name: 'getScope' },
|
||||
})
|
||||
|
||||
function fixture() {
|
||||
const video = new EventTarget()
|
||||
video.ownerDocument = { fullscreenElement: null }
|
||||
video.webkitDisplayingFullscreen = false
|
||||
video.webkitEnterFullscreen = function () {
|
||||
this.webkitDisplayingFullscreen = true
|
||||
this.dispatchEvent(new Event('webkitbeginfullscreen'))
|
||||
}
|
||||
video.webkitExitFullscreen = function () {
|
||||
this.webkitDisplayingFullscreen = false
|
||||
this.dispatchEvent(new Event('webkitendfullscreen'))
|
||||
}
|
||||
const art = Object.assign(new Emitter(), { template: { $video: video }, state: 'standard' })
|
||||
beginLifecycle(art)
|
||||
Object.defineProperty(art, 'fullscreen', videoFullscreen(art))
|
||||
const events = []
|
||||
art.on('fullscreen', value => events.push(['fullscreen', value]))
|
||||
art.on('resize', () => events.push(['resize']))
|
||||
return { art, video, events }
|
||||
}
|
||||
|
||||
test('video-only native callbacks preserve boolean state and normal event/resize order', () => {
|
||||
const { art, video, events } = fixture()
|
||||
art.fullscreen = true
|
||||
art.emit('document:webkitfullscreenchange')
|
||||
video.dispatchEvent(new Event('webkitpresentationmodechanged'))
|
||||
assert.equal(art.fullscreen, true)
|
||||
art.fullscreen = false
|
||||
assert.equal(art.fullscreen, false)
|
||||
assert.deepEqual(events, [['fullscreen', true], ['resize'], ['fullscreen', false], ['resize']])
|
||||
getScope(art).dispose()
|
||||
assert.equal(getEventListeners(video, 'webkitbeginfullscreen').length, 0)
|
||||
assert.equal(getEventListeners(video, 'webkitendfullscreen').length, 0)
|
||||
assert.equal(getEventListeners(video, 'webkitpresentationmodechanged').length, 0)
|
||||
})
|
||||
|
||||
test('video presentation PiP is distinct from fullscreen even if displayingFullscreen is true', () => {
|
||||
const { art, video, events } = fixture()
|
||||
video.webkitPresentationMode = 'picture-in-picture'
|
||||
video.webkitDisplayingFullscreen = true
|
||||
video.dispatchEvent(new Event('webkitpresentationmodechanged'))
|
||||
assert.equal(art.fullscreen, false)
|
||||
assert.deepEqual(events, [])
|
||||
getScope(art).dispose()
|
||||
})
|
||||
|
||||
test('video fullscreen falls back to native events if a shim has no state property', () => {
|
||||
const { art, video } = fixture()
|
||||
delete video.webkitDisplayingFullscreen
|
||||
video.dispatchEvent(new Event('webkitbeginfullscreen'))
|
||||
assert.equal(art.fullscreen, true)
|
||||
video.dispatchEvent(new Event('webkitendfullscreen'))
|
||||
assert.equal(art.fullscreen, false)
|
||||
getScope(art).dispose()
|
||||
})
|
||||
|
||||
test('video fullscreen destruction during its entry event stops stale resize and exits', () => {
|
||||
const { art, video, events } = fixture()
|
||||
art.on('fullscreen', (value) => {
|
||||
if (value)
|
||||
getScope(art).dispose()
|
||||
})
|
||||
art.fullscreen = true
|
||||
assert.equal(video.webkitDisplayingFullscreen, false)
|
||||
assert.equal(art.fullscreen, false)
|
||||
assert.deepEqual(events, [['fullscreen', true]])
|
||||
})
|
||||
|
||||
test('video fullscreen same-state reentry preserves the one resize', () => {
|
||||
const { art, events } = fixture()
|
||||
art.on('fullscreen', (value) => {
|
||||
if (value)
|
||||
art.fullscreen = true
|
||||
})
|
||||
art.fullscreen = true
|
||||
assert.equal(art.fullscreen, true)
|
||||
assert.deepEqual(events, [['fullscreen', true], ['resize']])
|
||||
getScope(art).dispose()
|
||||
})
|
||||
|
||||
test('video fullscreen synchronous request errors keep their identity and allow retry', () => {
|
||||
const { art, video } = fixture()
|
||||
const enter = video.webkitEnterFullscreen
|
||||
const error = new Error('denied')
|
||||
video.webkitEnterFullscreen = () => {
|
||||
throw error
|
||||
}
|
||||
assert.throws(() => {
|
||||
art.fullscreen = true
|
||||
}, actual => actual === error)
|
||||
video.webkitEnterFullscreen = enter
|
||||
art.fullscreen = true
|
||||
assert.equal(art.fullscreen, true)
|
||||
getScope(art).dispose()
|
||||
})
|
||||
|
||||
test('cancelled video fullscreen entry is released without stale player events', () => {
|
||||
const { art, video, events } = fixture()
|
||||
const enter = video.webkitEnterFullscreen
|
||||
video.webkitEnterFullscreen = () => {}
|
||||
art.fullscreen = true
|
||||
art.fullscreen = false
|
||||
enter.call(video)
|
||||
assert.equal(video.webkitDisplayingFullscreen, false)
|
||||
assert.equal(art.fullscreen, false)
|
||||
assert.deepEqual(events, [])
|
||||
getScope(art).dispose()
|
||||
})
|
||||
|
||||
test('new video fullscreen entry supersedes the previous cancellation', () => {
|
||||
const { art, video, events } = fixture()
|
||||
const enter = video.webkitEnterFullscreen
|
||||
video.webkitEnterFullscreen = () => {}
|
||||
art.fullscreen = true
|
||||
art.fullscreen = false
|
||||
video.webkitEnterFullscreen = enter
|
||||
art.fullscreen = true
|
||||
assert.equal(art.fullscreen, true)
|
||||
assert.deepEqual(events, [['fullscreen', true], ['resize']])
|
||||
getScope(art).dispose()
|
||||
})
|
||||
|
||||
test('late entry on a video shim without state properties is released after destroy', () => {
|
||||
const { art, video, events } = fixture()
|
||||
delete video.webkitDisplayingFullscreen
|
||||
let exits = 0
|
||||
video.webkitEnterFullscreen = () => {}
|
||||
video.webkitExitFullscreen = () => {
|
||||
exits++
|
||||
video.dispatchEvent(new Event('webkitendfullscreen'))
|
||||
}
|
||||
art.fullscreen = true
|
||||
getScope(art).dispose()
|
||||
video.dispatchEvent(new Event('webkitbeginfullscreen'))
|
||||
assert.equal(exits, 1)
|
||||
assert.deepEqual(events, [])
|
||||
assert.equal(getEventListeners(video, 'webkitbeginfullscreen').length, 0)
|
||||
})
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { MiniHost } from '../../packages/artplayer/src/display/types'
|
||||
import miniMix from '../../packages/artplayer/src/player/miniMix'
|
||||
|
||||
declare const host: MiniHost
|
||||
miniMix(host)
|
||||
const active: boolean = host.mini
|
||||
host.mini = true
|
||||
host.mini = false
|
||||
// @ts-expect-error Mini retains boolean setters.
|
||||
host.mini = 'true'
|
||||
// @ts-expect-error Mini storage persists numeric coordinates.
|
||||
host.storage.set('left', '25px')
|
||||
export { active }
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { OrientationHost, OrientationLock } from '../../packages/artplayer/src/display/orientation-types'
|
||||
import autoOrientation from '../../packages/artplayer/src/plugins/autoOrientation'
|
||||
|
||||
declare const host: OrientationHost
|
||||
declare const lock: OrientationLock
|
||||
const plugin = autoOrientation(host)
|
||||
const state: boolean = plugin.state
|
||||
// @ts-expect-error The builtin exposes a getter, not a writable flag.
|
||||
plugin.state = true
|
||||
// @ts-expect-error Internal orientation requests use the opposite portrait/landscape mode.
|
||||
lock.lock('upside-down')
|
||||
// @ts-expect-error Fullscreen events carry boolean state.
|
||||
host.on('fullscreen', (value: string) => value)
|
||||
export { state }
|
||||
@@ -0,0 +1,13 @@
|
||||
import type { PipHost } from '../../packages/artplayer/src/display/types'
|
||||
import pipMix from '../../packages/artplayer/src/player/pipMix'
|
||||
|
||||
declare const host: PipHost
|
||||
pipMix(host)
|
||||
const state: HTMLElement | null | boolean = host.pip
|
||||
// @ts-expect-error Native PiP historically returns the element or null, not just boolean.
|
||||
const booleanOnly: boolean = host.pip
|
||||
host.pip = true
|
||||
host.pip = false
|
||||
// @ts-expect-error PiP setters accept a boolean input.
|
||||
host.pip = 'picture-in-picture'
|
||||
export { booleanOnly, state }
|
||||
@@ -0,0 +1,21 @@
|
||||
import type { AutoHeightHost, AutoSizeHost } from '../../packages/artplayer/src/display/sizing-types'
|
||||
import { containSize, proportionalHeight } from '../../packages/artplayer/src/display/sizing'
|
||||
import autoHeightMix from '../../packages/artplayer/src/player/autoHeightMix'
|
||||
import autoSizeMix from '../../packages/artplayer/src/player/autoSizeMix'
|
||||
|
||||
declare const sizeHost: AutoSizeHost
|
||||
declare const heightHost: AutoHeightHost
|
||||
autoSizeMix(sizeHost)
|
||||
autoHeightMix(heightHost)
|
||||
const height: number | undefined = proportionalHeight(640, { width: 1920, height: 1080 })
|
||||
const size = containSize({ width: 640, height: 480 }, 16 / 9)
|
||||
if (size) {
|
||||
const axis: 'width' | 'height' = size.axis
|
||||
sizeHost.emit('autoSize', { width: size.width, height: size.height })
|
||||
void axis
|
||||
}
|
||||
// @ts-expect-error The internal measurement cannot be a CSS string.
|
||||
heightHost.emit('autoHeight', '360px')
|
||||
// @ts-expect-error Dimensions must describe both axes.
|
||||
proportionalHeight(640, { width: 1920 })
|
||||
export { height }
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { WebFullscreenHost } from '../../packages/artplayer/src/display/types'
|
||||
import { capturePlacement, restorePlacement } from '../../packages/artplayer/src/display/placement'
|
||||
import fullscreenWebMix from '../../packages/artplayer/src/player/fullscreenWebMix'
|
||||
|
||||
declare const host: WebFullscreenHost
|
||||
fullscreenWebMix(host)
|
||||
host.fullscreenWeb = true
|
||||
const active: boolean = host.fullscreenWeb
|
||||
const placement = capturePlacement(host.template.$player)
|
||||
restorePlacement(placement)
|
||||
// @ts-expect-error Display setters retain a boolean input.
|
||||
host.fullscreenWeb = 'true'
|
||||
// @ts-expect-error DOM placement cannot be constructed from a string.
|
||||
capturePlacement('player')
|
||||
export { active }
|
||||
Reference in new issue
Block a user