refactor(core): CORE-19 type capture and preserve progress lifetimes

This commit is contained in:
Harvey Zhao committed 2026-09-11 14:24:59 +08:00
1 parent 98a1800032
commit ccf77c4ed2
49 files changed
+31570 -681

No files matched your search

+28
View File
@@ -0,0 +1,28 @@
export type CaptureMedia = CanvasImageSource & {
videoWidth: number
videoHeight: number
currentTime: number
}
export function drawFrame(canvas: HTMLCanvasElement, video: CaptureMedia): void {
canvas.width = video.videoWidth
canvas.height = video.videoHeight
const context = canvas.getContext('2d')
if (!context)
throw new TypeError('Canvas 2D context is unavailable')
context.drawImage(video, 0, 0)
}
export function captureBlobUrl(canvas: HTMLCanvasElement, resolve: (url: string) => void, reject: (error: unknown) => void): void {
canvas.toBlob((blob) => {
try {
if (!blob)
throw new Error('Unable to encode screenshot blob')
// The public getBlobUrl caller owns this URL, including after destroy.
resolve(URL.createObjectURL(blob))
}
catch (error) {
reject(error)
}
})
}
@@ -1,4 +1,7 @@
import type { ControlHost } from '../types'
import { entryScope } from '../../component/resources'
import { isClosing } from '../../lifecycle/instance'
import { captureSource } from '../../source/operation'
import { isMobile } from '../../utils'
import { controlEvents } from '../resources'
import { getPosFromEvent, setCurrentTime } from './position'
@@ -7,17 +10,30 @@ export function installProgressInteractions(art: ControlHost, $control: HTMLDivE
const { $progress } = art.template
const $indicator = $control.querySelector('.art-progress-indicator')
const { on, proxy } = controlEvents(art, $control)
const scope = entryScope($control)
let action = 0
const capture = () => {
const sourceActive = captureSource(art)
return () => !scope.closed && !isClosing(art) && sourceActive()
}
const captureAction = () => {
const revision = ++action
const active = capture()
return () => revision === action && active()
}
if (!isMobile) {
let isDragging = false
let dragging: (() => boolean) | undefined
proxy($progress, 'click', (event) => {
if (event.target !== $indicator) {
setCurrentTime(art, event)
setCurrentTime(art, event, captureAction())
}
})
proxy($progress, 'mousemove', (event) => {
const active = capture()
const { percentage } = getPosFromEvent(art, event)
art.emit('setBar', 'hover', percentage, event)
if (active())
art.emit('setBar', 'hover', percentage, event)
})
proxy($progress, 'mouseleave', (event) => {
@@ -25,21 +41,26 @@ export function installProgressInteractions(art: ControlHost, $control: HTMLDivE
})
proxy($progress, 'mousedown', (event) => {
isDragging = event.button === 0
dragging = event.button === 0 ? captureAction() : undefined
})
on('document:mousemove', (event) => {
if (isDragging) {
const active = dragging
if (active?.()) {
const { second, percentage } = getPosFromEvent(art, event)
if (!active() || dragging !== active)
return
art.emit('setBar', 'played', percentage, event)
art.seek = second
if (active() && dragging === active)
art.seek = second
}
else {
dragging = undefined
}
})
on('document:mouseup', () => {
if (isDragging) {
isDragging = false
}
dragging = undefined
})
}
}
@@ -23,15 +23,21 @@ export function getPosFromEvent(art: ProgressPositionHost, event: ProgressEvent)
}
export function setCurrentTime(art: ProgressPositionHost, event: ProgressEvent, active: () => boolean = () => true) {
if (!active())
return
if (art.isRotate) {
const percentage = ((event as TouchEvent).touches[0]!.clientY - art.top) / art.height
const second = percentage * art.duration
if (!active())
return
art.emit('setBar', 'played', percentage, event)
if (active())
art.seek = second
}
else {
const { second, percentage } = getPosFromEvent(art, event)
if (!active())
return
art.emit('setBar', 'played', percentage, event)
if (active())
art.seek = second
+2 -1
View File
@@ -1,5 +1,6 @@
import type { ControlFactory, ControlOption } from './types'
import { appendElement } from '../component/dom'
import { silencePromise } from '../utils'
import { controlEvents } from './resources'
export default function screenshot(option: ControlOption): ControlFactory {
@@ -12,7 +13,7 @@ export default function screenshot(option: ControlOption): ControlFactory {
appendElement($control, icons.screenshot)
proxy($control, 'click', () => {
art.screenshot()
silencePromise(art.screenshot())
})
},
})
+146
View File
@@ -0,0 +1,146 @@
import ResourceScope from '../lifecycle/scope'
function requestImage(url: string, scale: number | undefined, owner: undefined): Promise<HTMLImageElement>
function requestImage(url: string, scale: number | undefined, owner: ResourceScope): Promise<HTMLImageElement | undefined>
function requestImage(url: string, scale: number | undefined, owner: ResourceScope | undefined): Promise<HTMLImageElement | undefined> {
return new Promise((resolve, reject) => {
const request = owner ? owner.child() : new ResourceScope()
let complete = false
let releaseBlob = () => {}
function fail(error: unknown): void {
if (complete)
return
complete = true
try {
request.dispose()
}
catch (cleanupError) {
console.warn('Additional image cleanup failure:', cleanupError)
}
try {
releaseBlob()
}
catch (cleanupError) {
console.warn('Additional image cleanup failure:', cleanupError)
}
reject(error)
}
function finish(image: HTMLImageElement): void {
if (complete || request.closed)
return
complete = true
try {
request.dispose()
resolve(image)
}
catch (error) {
try {
releaseBlob()
}
finally {
reject(error)
}
}
}
request.add(() => {
if (!complete) {
complete = true
try {
releaseBlob()
}
finally {
resolve(undefined)
}
}
})
if (request.closed)
return
function watch(image: HTMLImageElement, loaded: () => void): void {
image.onload = () => {
if (!complete && !request.closed) {
try {
loaded()
}
catch (error) {
fail(error)
}
}
}
image.onerror = () => fail(new Error(`Image load failed: ${url}`))
request.add(() => {
image.onload = null
image.onerror = null
if (!complete)
image.removeAttribute('src')
})
}
try {
const image = new Image()
watch(image, () => {
if (!scale || scale === 1) {
finish(image)
return
}
const canvas = document.createElement('canvas')
const context = canvas.getContext('2d')
canvas.width = image.width * scale
canvas.height = image.height * scale
if (!context)
throw new TypeError('Canvas 2D context is unavailable')
if (complete || request.closed)
return
context.drawImage(image, 0, 0, canvas.width, canvas.height)
if (complete || request.closed)
return
canvas.toBlob((blob) => {
if (complete || request.closed)
return
try {
if (!blob)
throw new Error('Unable to encode thumbnail image')
const blobUrl = URL.createObjectURL(blob)
let revoked = false
const revoke = (): undefined => {
if (!revoked) {
revoked = true
URL.revokeObjectURL(blobUrl)
}
}
releaseBlob = owner ? owner.add(revoke) : revoke
if (complete || request.closed) {
releaseBlob()
return
}
const scaled = new Image()
watch(scaled, () => finish(scaled))
if (!request.closed)
scaled.src = blobUrl
}
catch (error) {
fail(error)
}
})
})
if (!request.closed)
image.src = url
}
catch (error) {
fail(error)
}
})
}
// Public callers own a scaled result's image.src object URL.
export function loadImg(url: string, scale?: number): Promise<HTMLImageElement> {
return requestImage(url, scale, undefined)
}
// Internal previews own their result until configuration replacement or destroy.
export function loadThumbnailImage(url: string, scale: number | undefined, owner: ResourceScope): Promise<HTMLImageElement | undefined> {
return requestImage(url, scale, owner)
}
@@ -0,0 +1,9 @@
const revisions = new WeakMap<object, number>()
export function positionRevision(art: object): number {
return revisions.get(art) || 0
}
export function advancePosition(art: object): void {
revisions.set(art, positionRevision(art) + 1)
}
@@ -1,5 +1,6 @@
import type { MediaHost } from '../media/hosts'
import type { Position } from '../media/playback'
import { advancePosition } from '../media/position-revision'
import { clamp, def } from '../utils'
export default function currentTimeMix(art: MediaHost<Pick<HTMLMediaElement, 'currentTime'>> & Pick<Position, 'duration'>): asserts art is typeof art & Position {
@@ -11,6 +12,7 @@ export default function currentTimeMix(art: MediaHost<Pick<HTMLMediaElement, 'cu
const parsed = Number.parseFloat(time as string)
if (Number.isNaN(parsed))
return
advancePosition(art)
$video.currentTime = clamp(parsed, 0, art.duration)
},
})
@@ -1,54 +0,0 @@
import { createElement, def, download, secondToTime } from '../utils'
export default function screenshotMix(art) {
const {
notice,
template: { $video },
} = art
const $canvas = createElement('canvas')
def(art, 'getDataURL', {
value: () =>
new Promise((resolve, reject) => {
try {
$canvas.width = $video.videoWidth
$canvas.height = $video.videoHeight
$canvas.getContext('2d').drawImage($video, 0, 0)
resolve($canvas.toDataURL('image/png'))
}
catch (err) {
notice.show = err
reject(err)
}
}),
})
def(art, 'getBlobUrl', {
value: () =>
new Promise((resolve, reject) => {
try {
$canvas.width = $video.videoWidth
$canvas.height = $video.videoHeight
$canvas.getContext('2d').drawImage($video, 0, 0)
$canvas.toBlob((blob) => {
resolve(URL.createObjectURL(blob))
})
}
catch (err) {
notice.show = err
reject(err)
}
}),
})
def(art, 'screenshot', {
value: async (name) => {
const dataUri = await art.getDataURL()
const fileName = name || `artplayer_${secondToTime($video.currentTime)}`
download(dataUri, `${fileName}.png`)
art.emit('screenshot', dataUri)
return dataUri
},
})
}
@@ -0,0 +1,65 @@
import type { CaptureMedia } from '../capture/frame'
import { captureBlobUrl, drawFrame } from '../capture/frame'
import { isClosing } from '../lifecycle/instance'
import { captureSource } from '../source/operation'
import { def, download, secondToTime } from '../utils'
export interface ScreenshotHost {
template: { $video: CaptureMedia }
notice: { set show(value: unknown) }
getDataURL: () => Promise<string>
emit: (name: 'screenshot', dataUri: string) => unknown
}
export default function screenshotMix(art: ScreenshotHost): void {
const { notice, template: { $video } } = art
const canvas = document.createElement('canvas')
function capture(blob: boolean): Promise<string> {
const active = captureSource(art)
// Draw synchronously as before; awaiting must not capture a later frame.
return new Promise<string>((resolve, reject) => {
const fail = (error: unknown) => {
try {
if (!isClosing(art) && active())
notice.show = error
}
catch (noticeError) {
reject(noticeError)
return
}
reject(error)
}
try {
drawFrame(canvas, $video)
if (blob) {
// Handle failures inside toBlob as well as synchronous canvas errors.
captureBlobUrl(canvas, resolve, fail)
}
else {
resolve(canvas.toDataURL('image/png'))
}
}
catch (error) {
fail(error)
}
})
}
def(art, 'getDataURL', { value: () => capture(false) })
def(art, 'getBlobUrl', { value: () => capture(true) })
def(art, 'screenshot', {
value: async (name?: string): Promise<string> => {
const active = captureSource(art)
const dataUri = await art.getDataURL()
if (!isClosing(art) && active()) {
const fileName = name || `artplayer_${secondToTime($video.currentTime)}`
if (!isClosing(art) && active())
download(dataUri, `${fileName}.png`)
if (!isClosing(art) && active())
art.emit('screenshot', dataUri)
}
return dataUri
},
})
}
@@ -1,84 +0,0 @@
import { def, isMobile, loadImg, setStyle } from '../utils'
export default function thumbnailsMix(art) {
const {
option,
template: { $progress, $video },
} = art
let timer = null
let loding = false
let image = null
function reset() {
clearTimeout(timer)
timer = null
loding = false
image = null
}
function showThumbnails(posWidth) {
const $thumbnails = art.controls?.thumbnails
if (!$thumbnails)
return
const { number, column, width, height, scale } = option.thumbnails
const width2 = width * scale || image.naturalWidth / column
const height2 = height * scale || width2 / ($video.videoWidth / $video.videoHeight)
const perWidth = $progress.clientWidth / number
const perIndex = Math.floor(posWidth / perWidth)
const yIndex = Math.ceil(perIndex / column) - 1
const xIndex = perIndex % column || column - 1
setStyle($thumbnails, 'backgroundImage', `url(${image.src})`)
setStyle($thumbnails, 'height', `${height2}px`)
setStyle($thumbnails, 'width', `${width2}px`)
setStyle($thumbnails, 'backgroundPosition', `-${xIndex * width2}px -${yIndex * height2}px`)
if (posWidth <= width2 / 2) {
setStyle($thumbnails, 'left', 0)
}
else if (posWidth > $progress.clientWidth - width2 / 2) {
setStyle($thumbnails, 'left', `${$progress.clientWidth - width2}px`)
}
else {
setStyle($thumbnails, 'left', `${posWidth - width2 / 2}px`)
}
}
art.on('setBar', async (type, percentage, event) => {
const $thumbnails = art.controls?.thumbnails
const { url, scale } = option.thumbnails
if (!$thumbnails || !url)
return
const isMobileDragging = type === 'played' && event && isMobile
if (type === 'hover' || isMobileDragging) {
if (!image && !loding) {
loding = true
image = await loadImg(url, scale)
loding = false
}
if (!image)
return
const width = $progress.clientWidth * percentage
if (width > 0 && width < $progress.clientWidth) {
showThumbnails(width)
}
}
})
def(art, 'thumbnails', {
get() {
return art.option.thumbnails
},
set(thumbnails) {
if (thumbnails.url && !art.option.isLive) {
art.option.thumbnails = thumbnails
reset()
}
},
})
}
@@ -0,0 +1,115 @@
import type { Thumbnails } from '../../types/option'
import type { SubscriptionHost } from '../component/resources'
import { entryScope } from '../component/resources'
import { eventSubscriptions } from '../events/subscriptions'
import { loadThumbnailImage } from '../image/load'
import { getScope, isClosing } from '../lifecycle/instance'
import { captureSource } from '../source/operation'
import { thumbnailLayout } from '../thumbnails/layout'
import { def, isMobile, setStyle } from '../utils'
interface ThumbnailEvents {
setBar: [type: string, percentage: number, event?: Event]
}
export interface ThumbnailsHost extends SubscriptionHost<ThumbnailEvents> {
option: { thumbnails: Thumbnails, isLive: boolean }
template: { $progress: HTMLElement, $video: { videoWidth: number, videoHeight: number } }
controls?: { thumbnails?: HTMLDivElement }
}
export default function thumbnailsMix(art: ThumbnailsHost): void {
const { option, template: { $progress, $video } } = art
let scope = getScope(art).child()
let image: HTMLImageElement | undefined
let loading = false
let control: HTMLDivElement | undefined
let hover: { percentage: number, element: HTMLDivElement, sourceActive: () => boolean } | undefined
function reset(): void {
const previous = scope
scope = getScope(art).child()
image = undefined
loading = false
hover = undefined
previous.dispose()
}
function render(): void {
const current = hover
const currentScope = scope
if (!current || !image)
return
const active = () => !isClosing(art) && !currentScope.closed && scope === currentScope
&& hover === current && current.sourceActive() && art.controls?.thumbnails === current.element
if (!active())
return
const position = $progress.clientWidth * current.percentage
if (!(position > 0 && position < $progress.clientWidth))
return
const styles = {
backgroundImage: `url(${image.src})`,
...thumbnailLayout(option.thumbnails, {
imageWidth: image.naturalWidth,
videoWidth: $video.videoWidth,
videoHeight: $video.videoHeight,
progressWidth: $progress.clientWidth,
position,
}),
}
for (const [key, value] of Object.entries(styles)) {
if (!active())
return
setStyle(current.element, key, value)
}
}
eventSubscriptions<ThumbnailEvents>(art)('setBar', (type, percentage, event) => {
const element = art.controls?.thumbnails
const { url, scale } = option.thumbnails
if (isClosing(art) || !element || !url || !(type === 'hover' || (type === 'played' && event && isMobile)))
return
if (control !== element) {
const elementScope = entryScope(element)
if (elementScope.closed)
return
control = element
elementScope.add(() => {
if (control === element) {
control = undefined
reset()
}
})
}
hover = { percentage, element, sourceActive: captureSource(art) }
if (image) {
render()
}
else if (!loading) {
const currentScope = scope
loading = true
loadThumbnailImage(url, scale, currentScope).then((loaded) => {
if (scope !== currentScope || currentScope.closed)
return
loading = false
image = loaded
render()
}).catch((error: unknown) => {
if (scope === currentScope && !currentScope.closed) {
loading = false
console.warn('ArtPlayer thumbnail load failed:', error)
}
})
}
})
def(art, 'thumbnails', {
get: () => art.option.thumbnails,
set: (thumbnails: Thumbnails) => {
if (!isClosing(art) && thumbnails.url && !art.option.isLive && !isClosing(art)) {
art.option.thumbnails = thumbnails
reset()
}
},
})
}
+1 -2
View File
@@ -50,8 +50,7 @@ export default function urlMix<Media extends SourceMedia, Host extends UrlHost<M
void Promise.resolve(result).catch(error => failSource(operation, error))
}
else {
if (oldUrl)
URL.revokeObjectURL(oldUrl)
// Media URLs are supplied by callers or adapters, which own revocation.
if (!operation.active())
return
$video.src = newUrl
@@ -0,0 +1,49 @@
import type { SwitchHost } from './types'
import { positionRevision } from '../media/position-revision'
// Native media clocks may round within a frame. Larger misses need one correction.
const POSITION_TOLERANCE = 0.05
export function positionRestoration(art: Pick<SwitchHost, 'currentTime' | 'template'>, target: number, active: () => boolean) {
let expected: number | undefined
let observedSeek = false
let corrected = false
let manual = false
let revision = positionRevision(art)
const manualPosition = () => manual || positionRevision(art) !== revision
const seeking = () => !!art.template?.$video?.seeking
return {
restore(): void {
if (!active() || manualPosition())
return
const previousRevision = positionRevision(art)
art.currentTime = target
revision = positionRevision(art)
// Nested public writes supersede the restoration's own setter call.
if (revision > previousRevision + 1)
manual = true
if (!active())
return
// Keep the actual clamped value accepted by the player setter.
expected = art.currentTime
if (!active() || manualPosition())
return
observedSeek = seeking()
},
manual(): void {
manual = true
},
ready(): boolean {
if (!active() || seeking())
return false
const missed = expected !== undefined && Math.abs(art.currentTime - expected) > POSITION_TOLERANCE
if (!manualPosition() && observedSeek && !corrected && expected !== undefined && missed) {
corrected = true
if (!active())
return false
art.currentTime = expected
}
return active() && !seeking()
},
}
}
+50 -26
View File
@@ -2,6 +2,7 @@ import type { SourceEvent, SourceListener, SwitchHost } from './types'
import { isClosing } from '../lifecycle/instance'
import { listenSource } from './listen'
import { assignUrl, beginSource } from './operation'
import { positionRestoration } from './restore-position'
export function switchSource(art: SwitchHost, url: string, currentTime: number): Promise<void> {
return new Promise<void>((resolve, reject) => {
@@ -39,44 +40,67 @@ export function switchSource(art: SwitchHost, url: string, currentTime: number):
return
const readiness = scope.child()
let canPlay = false
let resuming = false
let rateRestored = false
const position = positionRestoration(art, currentTime, active)
const resume = async () => {
art.aspectRatio = aspectRatio
if (!active())
return
if (playing) {
// The public play promise still rejects; this internal resume is best effort.
try {
await art.play()
}
catch {}
}
if (!active())
return
art.notice.show = ''
settle()
}
const resumeWhenReady = () => {
if (!active() || resuming || !canPlay)
return
try {
// canplay can arrive before the metadata restoration seek has settled.
if (art.template?.$video?.seeking)
return
if (!active())
return
if (!rateRestored) {
rateRestored = true
art.playbackRate = playbackRate
}
if (!position.ready() || !active() || resuming)
return
resuming = true
readiness.dispose()
void resume().catch(fail)
}
catch (error) {
fail(error)
}
}
const handlers = {
'video:error': fail,
'video:loadedmetadata': () => {
if (!active())
return
try {
art.currentTime = currentTime
position.restore()
}
catch (error) {
fail(error)
}
},
'video:canplay': () => {
if (!active())
return
// Detach readiness handlers before awaiting a possibly pending play.
readiness.dispose()
const resume = async () => {
art.playbackRate = playbackRate
if (!active())
return
art.aspectRatio = aspectRatio
if (!active())
return
if (playing) {
// The public play promise still rejects; this internal resume is best effort.
try {
await art.play()
}
catch {}
}
if (!active())
return
art.notice.show = ''
settle()
}
void resume().catch(fail)
canPlay = true
resumeWhenReady()
},
'video:seeked': resumeWhenReady,
'seek': position.manual,
}
const capture = scope.child()
const queued: [SourceEvent, unknown][] = []
@@ -101,7 +125,7 @@ export function switchSource(art: SwitchHost, url: string, currentTime: number):
}
const dispatch = {} as Record<SourceEvent, SourceListener>
for (const name of names)
dispatch[name] = listenSource(readiness, art, name, handlers[name])
dispatch[name] = listenSource(readiness, art, name, handlers[name], name !== 'video:seeked')
for (const [name, event] of queued) {
if (!active() || readiness.closed)
break
+2 -1
View File
@@ -1,4 +1,4 @@
export type SourceEvent = 'video:error' | 'video:loadedmetadata' | 'video:canplay'
export type SourceEvent = 'video:error' | 'video:loadedmetadata' | 'video:canplay' | 'video:seeked' | 'seek'
export type SourceListener = (event?: unknown) => void
export interface SourceEvents {
@@ -28,6 +28,7 @@ export interface UrlHost<Media extends SourceMedia, Host> extends UrlTarget, Sou
}
export interface SwitchHost extends UrlTarget, SourceEvents {
template?: { $video?: { readonly seeking?: boolean } }
currentTime: number
readonly playing: boolean
aspectRatio: string
@@ -0,0 +1,32 @@
import type { Thumbnails } from '../../types/option'
export interface ThumbnailGeometry {
imageWidth: number
videoWidth: number
videoHeight: number
progressWidth: number
position: number
}
export function thumbnailLayout(option: Thumbnails, geometry: ThumbnailGeometry) {
const { number, column, width, height, scale } = option
const { imageWidth, videoWidth, videoHeight, progressWidth, position } = geometry
const columns = Number(column)
const previewWidth = Number(width) * Number(scale) || imageWidth / columns
const previewHeight = Number(height) * Number(scale) || previewWidth / (videoWidth / videoHeight)
const index = Math.floor(position / (progressWidth / Number(number)))
// Match the zero-based cells produced by artplayer-tool-thumbnail.
const row = Math.floor(index / columns)
const cell = index % columns
const left = position <= previewWidth / 2
? 0
: position > progressWidth - previewWidth / 2
? `${progressWidth - previewWidth}px`
: `${position - previewWidth / 2}px`
return {
height: `${previewHeight}px`,
width: `${previewWidth}px`,
backgroundPosition: `-${cell * previewWidth}px -${row * previewHeight}px`,
left,
}
}
+1 -41
View File
@@ -1,5 +1,6 @@
import { isMobile } from './compatibility'
export { loadImg } from '../image/load'
export { setStyleText } from '../style/inject'
/** @param {string} selector @param {ParentNode} parent */
@@ -112,47 +113,6 @@ export function getRect(el) {
return el.getBoundingClientRect()
}
export function loadImg(url, scale) {
return new Promise((resolve, reject) => {
const img = new Image()
img.onload = function () {
if (!scale || scale === 1) {
resolve(img)
}
else {
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d')
canvas.width = img.width * scale
canvas.height = img.height * scale
ctx.drawImage(img, 0, 0, canvas.width, canvas.height)
canvas.toBlob((blob) => {
const blobUrl = URL.createObjectURL(blob)
const scaledImg = new Image()
scaledImg.onload = function () {
resolve(scaledImg)
}
scaledImg.onerror = function () {
URL.revokeObjectURL(blobUrl)
reject(new Error(`Image load failed: ${url}`))
}
scaledImg.src = blobUrl
})
}
}
img.onerror = function () {
reject(new Error(`Image load failed: ${url}`))
}
img.src = url
})
}
export function getComposedPath(event) {
if (event.composedPath)
return event.composedPath()