mirror of
https://github.com/zhw2590582/ArtPlayer.git
synced 2026-10-24 06:31:20 -08:00
refactor(ambilight): [PKG-AMBILIGHT-03] split typed lifecycle and recover native canvas sampling
This commit is contained in:
1 parent
43f0e9a8c6
commit
6caee44dbf
25 files changed
+1680
-306
No files matched your search
@@ -1,115 +0,0 @@
|
||||
export default function artplayerPluginAmbilight(option = {}) {
|
||||
return (art) => {
|
||||
const { $video } = art.template
|
||||
const { createElement, addClass, setStyles } = art.constructor.utils
|
||||
const { blur = '50px', opacity = 0.5, frequency = 10, duration = 0.3 } = option
|
||||
|
||||
const $ambilight = createElement('div')
|
||||
const gridItems = createGridItems($ambilight)
|
||||
|
||||
setupAmbilight($ambilight, $video)
|
||||
setupGridItems(gridItems, blur, opacity, duration)
|
||||
|
||||
const updateColors = createColorUpdater($video, gridItems, frequency)
|
||||
|
||||
let animationFrameId = null
|
||||
|
||||
function start() {
|
||||
if (!animationFrameId) {
|
||||
updateColors()
|
||||
}
|
||||
}
|
||||
|
||||
function stop() {
|
||||
if (animationFrameId) {
|
||||
cancelAnimationFrame(animationFrameId)
|
||||
animationFrameId = null
|
||||
}
|
||||
}
|
||||
|
||||
art.on('ready', start)
|
||||
art.on('destroy', stop)
|
||||
|
||||
return {
|
||||
name: 'artplayerPluginAmbilight',
|
||||
start,
|
||||
stop,
|
||||
}
|
||||
|
||||
function createGridItems($ambilight) {
|
||||
$ambilight.innerHTML = Array.from({ length: 9 }).fill('<div></div>').join('')
|
||||
return Array.from($ambilight.children)
|
||||
}
|
||||
|
||||
function setupAmbilight($ambilight, $video) {
|
||||
addClass($ambilight, 'artplayer-plugin-ambilight')
|
||||
$video.parentNode.insertBefore($ambilight, $video)
|
||||
setStyles($ambilight, {
|
||||
position: 'absolute',
|
||||
top: 0,
|
||||
left: 0,
|
||||
zIndex: 9,
|
||||
inset: 0,
|
||||
display: 'grid',
|
||||
gridTemplateColumns: '1fr 1fr 1fr',
|
||||
gridTemplateRows: '1fr 1fr 1fr',
|
||||
})
|
||||
}
|
||||
|
||||
function setupGridItems(gridItems, blur, opacity, duration) {
|
||||
gridItems.forEach(($item) => {
|
||||
setStyles($item, {
|
||||
opacity,
|
||||
filter: `blur(${blur})`,
|
||||
transition: `background-color ${duration}s ease`,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function createColorUpdater($video, gridItems, frequency) {
|
||||
const canvas = createElement('canvas')
|
||||
const ctx = canvas.getContext('2d')
|
||||
canvas.width = 3
|
||||
canvas.height = 3
|
||||
|
||||
function getAverageColor(x, y, w, h) {
|
||||
ctx.drawImage($video, x, y, w, h, 0, 0, 1, 1)
|
||||
const [r, g, b] = ctx.getImageData(0, 0, 1, 1).data
|
||||
return `rgb(${r}, ${g}, ${b})`
|
||||
}
|
||||
|
||||
let lastUpdateTime = 0
|
||||
|
||||
return function updateColors() {
|
||||
const now = performance.now()
|
||||
if (now - lastUpdateTime < 1000 / frequency || !art.playing) {
|
||||
animationFrameId = requestAnimationFrame(updateColors)
|
||||
return
|
||||
}
|
||||
|
||||
lastUpdateTime = now
|
||||
|
||||
const w = $video.videoWidth / 3
|
||||
const h = $video.videoHeight / 3
|
||||
|
||||
const colors = [
|
||||
[0, 0],
|
||||
[w, 0],
|
||||
[2 * w, 0],
|
||||
[0, h],
|
||||
[w, h],
|
||||
[2 * w, h],
|
||||
[0, 2 * h],
|
||||
[w, 2 * h],
|
||||
[2 * w, 2 * h],
|
||||
].map(([x, y]) => getAverageColor(x, y, w, h))
|
||||
|
||||
gridItems.forEach(($item, index) => {
|
||||
$item.style.backgroundColor = colors[index]
|
||||
})
|
||||
|
||||
animationFrameId = requestAnimationFrame(updateColors)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
import type Artplayer from 'artplayer'
|
||||
import type PublicPlugin from '../types/artplayer-plugin-ambilight'
|
||||
import { createColorSampler } from './sampler'
|
||||
import { createFrameLoop } from './scheduler'
|
||||
import { createAmbilightView } from './view'
|
||||
|
||||
type Option = Parameters<typeof PublicPlugin>[0]
|
||||
type Subscription = ['ready' | 'destroy', () => void]
|
||||
|
||||
export default function artplayerPluginAmbilight(option: Option = {}) {
|
||||
return (art: Artplayer) => {
|
||||
let host: Artplayer | null = art
|
||||
let closed = art.isDestroy
|
||||
let view: ReturnType<typeof createAmbilightView> | null = null
|
||||
let sampler: ReturnType<typeof createColorSampler> | null = null
|
||||
let loop: ReturnType<typeof createFrameLoop> | null = null
|
||||
const subscriptions: Subscription[] = []
|
||||
const result = {
|
||||
name: 'artplayerPluginAmbilight' as const,
|
||||
start() {
|
||||
if (!closed)
|
||||
loop?.start()
|
||||
},
|
||||
stop() {
|
||||
if (!closed)
|
||||
loop?.stop()
|
||||
},
|
||||
}
|
||||
if (closed) {
|
||||
host = null
|
||||
return result
|
||||
}
|
||||
|
||||
function releaseResources() {
|
||||
const releases = [loop?.destroy, sampler?.destroy, view?.destroy]
|
||||
loop = null
|
||||
sampler = null
|
||||
view = null
|
||||
const failures: unknown[] = []
|
||||
for (const release of releases) {
|
||||
try {
|
||||
release?.()
|
||||
}
|
||||
catch (error) { failures.push(error) }
|
||||
}
|
||||
if (failures.length)
|
||||
throw failures[0]
|
||||
}
|
||||
|
||||
function destroy() {
|
||||
if (closed)
|
||||
return
|
||||
closed = true
|
||||
const current = host
|
||||
host = null
|
||||
const failures: unknown[] = []
|
||||
for (const [event, callback] of subscriptions.splice(0)) {
|
||||
try {
|
||||
current?.off(event, callback)
|
||||
}
|
||||
catch (error) { failures.push(error) }
|
||||
}
|
||||
try {
|
||||
releaseResources()
|
||||
}
|
||||
catch (error) { failures.push(error) }
|
||||
if (failures.length)
|
||||
throw failures[0]
|
||||
}
|
||||
|
||||
function listen(event: Subscription[0], callback: Subscription[1]) {
|
||||
subscriptions.push([event, callback])
|
||||
host?.on(event, callback)
|
||||
}
|
||||
|
||||
try {
|
||||
listen('destroy', destroy)
|
||||
if (closed)
|
||||
return result
|
||||
const { $video } = art.template
|
||||
const utils = (art.constructor as typeof Artplayer).utils
|
||||
const { blur = '50px', opacity = 0.5, frequency = 10, duration = 0.3 } = option
|
||||
if (closed)
|
||||
return result
|
||||
view = createAmbilightView(utils)
|
||||
if (!closed)
|
||||
view.mount($video, { blur, opacity, duration }, () => !closed)
|
||||
if (closed) {
|
||||
releaseResources()
|
||||
return result
|
||||
}
|
||||
const canvas = utils.createElement('canvas')
|
||||
if (closed) {
|
||||
canvas.width = 0
|
||||
canvas.height = 0
|
||||
return result
|
||||
}
|
||||
sampler = createColorSampler(canvas, $video, () => utils.createElement('canvas'))
|
||||
if (closed) {
|
||||
releaseResources()
|
||||
return result
|
||||
}
|
||||
loop = createFrameLoop(frequency, () => Boolean(host?.playing), (valid) => {
|
||||
const colors = sampler?.read(valid)
|
||||
if (colors && valid())
|
||||
view?.render(colors, valid)
|
||||
})
|
||||
listen('ready', result.start)
|
||||
if (closed)
|
||||
releaseResources()
|
||||
}
|
||||
catch (error) {
|
||||
try {
|
||||
destroy()
|
||||
}
|
||||
catch { /* Preserve the setup failure after attempting every cleanup. */ }
|
||||
try {
|
||||
releaseResources()
|
||||
}
|
||||
catch { /* A reentrant constructor may return a resource after destruction. */ }
|
||||
throw error
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,80 @@
|
||||
export function createColorSampler(canvas: HTMLCanvasElement, video: HTMLVideoElement, createCanvas: () => HTMLCanvasElement) {
|
||||
let context: CanvasRenderingContext2D | null
|
||||
try {
|
||||
context = canvas.getContext('2d')
|
||||
canvas.width = 3
|
||||
canvas.height = 3
|
||||
}
|
||||
catch (error) {
|
||||
canvas.width = 0
|
||||
canvas.height = 0
|
||||
throw error
|
||||
}
|
||||
let destroyed = false
|
||||
let replaceCanvas = false
|
||||
|
||||
function read(active: () => boolean): string[] | null {
|
||||
if (destroyed || !active())
|
||||
return null
|
||||
const width = video.videoWidth
|
||||
const height = video.videoHeight
|
||||
if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0)
|
||||
return null
|
||||
const w = width / 3
|
||||
const h = height / 3
|
||||
const colors: string[] = []
|
||||
try {
|
||||
if (replaceCanvas) {
|
||||
const replacement = createCanvas()
|
||||
if (destroyed || !active()) {
|
||||
replacement.width = 0
|
||||
replacement.height = 0
|
||||
return null
|
||||
}
|
||||
canvas = replacement
|
||||
context = null
|
||||
context = canvas.getContext('2d')
|
||||
if (destroyed || !active()) {
|
||||
canvas.width = 0
|
||||
canvas.height = 0
|
||||
return null
|
||||
}
|
||||
canvas.width = 3
|
||||
canvas.height = 3
|
||||
replaceCanvas = false
|
||||
}
|
||||
if (!context)
|
||||
return null
|
||||
for (let row = 0; row < 3; row++) {
|
||||
for (let column = 0; column < 3; column++) {
|
||||
if (destroyed || !active())
|
||||
return null
|
||||
context.drawImage(video, column * w, row * h, w, h, 0, 0, 1, 1)
|
||||
if (destroyed || !active())
|
||||
return null
|
||||
const [r, g, b] = context.getImageData(0, 0, 1, 1).data
|
||||
colors.push(`rgb(${r}, ${g}, ${b})`)
|
||||
}
|
||||
}
|
||||
return colors
|
||||
}
|
||||
catch {
|
||||
// Firefox can retain taint after a size reset; replace on the next eligible sample.
|
||||
if (!destroyed) {
|
||||
replaceCanvas = true
|
||||
canvas.width = 0
|
||||
canvas.height = 0
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
function destroy() {
|
||||
if (destroyed)
|
||||
return
|
||||
destroyed = true
|
||||
canvas.width = 0
|
||||
canvas.height = 0
|
||||
}
|
||||
return { read, destroy }
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
export function createFrameLoop(frequency: number, playing: () => boolean, update: (active: () => boolean) => void) {
|
||||
let state: 'stopped' | 'running' | 'destroyed' = 'stopped'
|
||||
let pending: number | null = null
|
||||
let generation = 0
|
||||
let inFrame = false
|
||||
let lastUpdateTime = 0
|
||||
|
||||
function schedule() {
|
||||
const current = generation
|
||||
try {
|
||||
pending = requestAnimationFrame(() => {
|
||||
if (state !== 'running' || current !== generation)
|
||||
return
|
||||
pending = null
|
||||
tick()
|
||||
})
|
||||
}
|
||||
catch (error) {
|
||||
state = 'stopped'
|
||||
generation++
|
||||
throw error
|
||||
}
|
||||
}
|
||||
|
||||
function tick() {
|
||||
const current = generation
|
||||
const active = () => state === 'running' && current === generation
|
||||
inFrame = true
|
||||
try {
|
||||
const now = performance.now()
|
||||
if (now - lastUpdateTime < 1000 / frequency || !playing() || !active())
|
||||
return
|
||||
lastUpdateTime = now
|
||||
update(active)
|
||||
}
|
||||
finally {
|
||||
inFrame = false
|
||||
if (state === 'running' && pending === null)
|
||||
schedule()
|
||||
}
|
||||
}
|
||||
|
||||
function start() {
|
||||
if (state !== 'stopped')
|
||||
return
|
||||
state = 'running'
|
||||
generation++
|
||||
if (!inFrame)
|
||||
tick()
|
||||
}
|
||||
|
||||
function stop() {
|
||||
if (state === 'destroyed')
|
||||
return
|
||||
state = 'stopped'
|
||||
generation++
|
||||
const frame = pending
|
||||
pending = null
|
||||
if (frame !== null)
|
||||
cancelAnimationFrame(frame)
|
||||
}
|
||||
|
||||
function destroy() {
|
||||
if (state === 'destroyed')
|
||||
return
|
||||
state = 'destroyed'
|
||||
generation++
|
||||
const frame = pending
|
||||
pending = null
|
||||
if (frame !== null)
|
||||
cancelAnimationFrame(frame)
|
||||
}
|
||||
return { start, stop, destroy }
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import type Artplayer from 'artplayer'
|
||||
|
||||
type Utilities = Pick<typeof Artplayer.utils, 'createElement' | 'addClass'> & {
|
||||
// Historical setStyles accepts numeric CSS values; preserve its original arguments.
|
||||
setStyles: (element: HTMLElement, styles: object) => unknown
|
||||
}
|
||||
interface ViewOptions { blur: string, opacity: number, duration: number }
|
||||
|
||||
export function createAmbilightView(utils: Utilities) {
|
||||
const element = utils.createElement('div')
|
||||
element.innerHTML = Array.from({ length: 9 }).fill('<div></div>').join('')
|
||||
const items = Array.from(element.children) as HTMLElement[]
|
||||
|
||||
function mount(video: HTMLVideoElement, option: ViewOptions, active: () => boolean) {
|
||||
if (!active())
|
||||
return
|
||||
utils.addClass(element, 'artplayer-plugin-ambilight')
|
||||
if (!active())
|
||||
return
|
||||
video.parentNode!.insertBefore(element, video)
|
||||
if (!active())
|
||||
return
|
||||
utils.setStyles(element, { position: 'absolute', top: 0, left: 0, zIndex: 9, inset: 0, display: 'grid', gridTemplateColumns: '1fr 1fr 1fr', gridTemplateRows: '1fr 1fr 1fr' })
|
||||
for (const item of items) {
|
||||
if (!active())
|
||||
return
|
||||
utils.setStyles(item, { opacity: option.opacity, filter: `blur(${option.blur})`, transition: `background-color ${option.duration}s ease` })
|
||||
}
|
||||
}
|
||||
|
||||
function render(colors: string[], active: () => boolean) {
|
||||
for (let index = 0; index < colors.length; index++) {
|
||||
if (!active())
|
||||
return
|
||||
const item = items[index]
|
||||
const color = colors[index]
|
||||
if (item && color !== undefined)
|
||||
item.style.backgroundColor = color
|
||||
}
|
||||
}
|
||||
|
||||
function destroy() {
|
||||
try {
|
||||
element.parentNode?.removeChild(element)
|
||||
}
|
||||
finally { items.length = 0 }
|
||||
}
|
||||
return { mount, render, destroy }
|
||||
}
|
||||
Reference in new issue
Block a user