refactor(ambilight): [PKG-AMBILIGHT-03] split typed lifecycle and recover native canvas sampling

This commit is contained in:
Harvey Zhao committed 2026-09-12 11:35:13 +08:00
1 parent 43f0e9a8c6
commit 6caee44dbf
25 files changed
+1680 -306

No files matched your search

@@ -0,0 +1,46 @@
# Ambilight maintenance
Read the frozen published contracts in `refactor/baselines/ambilight-contract.md`
before changing the public factory or distribution. Source is strict TypeScript;
the existing public declaration remains the input contract until task 04.
| Module | Responsibility |
| --- | --- |
| `src/index.ts` | Factory, ready/destroy subscriptions, construction rollback, terminal ownership |
| `src/view.ts` | Nine-cell grid, historical placement/styles, atomic color rendering, owned DOM removal |
| `src/sampler.ts` | Native video dimensions, nine region-to-pixel samples, failed canvas replacement |
| `src/scheduler.ts` | Frequency gate, native RAF ownership, generation checks for reentrant stop/start |
The registration function reads options. `ready` calls `start`; stop cancels the
pending frame but retains the grid and its colors. A paused player retains one
frame loop without sampling. Core destruction closes the public methods, detaches
subscriptions, cancels the frame (including ID zero), releases canvas storage and
removes the grid. Attaching to an already destroyed host is inert.
Cleanup attempts every resource even if another release throws. Construction
preserves the primary error. Synchronous callbacks from custom host utilities can
destroy the host during setup or sampling: check the terminal state after each
external boundary. A frame generation prevents cancelled callbacks and reentrant
stop/start from publishing obsolete colors or creating duplicate loops.
The nine colors are collected before rendering. Missing contexts and invalid
dimensions do not throw. A draw/read exception discards that sample, zeros the old
canvas and replaces it on the next eligible sample. The replacement is necessary:
Firefox's real canvas retained SecurityError after a width reset, while a fresh
canvas could read the recovered same-origin video. Retries remain frequency-gated;
with a permanently cross-origin source this allocates at most one replacement per
eligible sample. No bypass of canvas origin restrictions is attempted.
Compatibility constraints: keep numeric style arguments, nine row-major samples,
blur/opacity/frequency/duration defaults, synchronous start/stop returns, option
reading at registration, and the old frequency comparison (including NaN and
Infinity behavior). The historical zIndex option remains ignored (style is 9).
Do not conflate old declaration differences with newly authorized API changes.
Validation: `yarn test:ambilight`, package strict typecheck, normal package build,
and `yarn test:browser test/browser/ambilight-lifecycle.spec.js`. Historical tests
in `test/ambilight.test.js` and `test/browser/ambilight.spec.js` intentionally retain
the old failures. Candidate tests must remain separate. The browser suite runs real
video/canvas with a lexical native RAF observer across published cores 5.1.7/5.4.0
and candidate core in Chromium/Firefox/WebKit. Proxy/device, packed consumer and
public type acceptance remain tasks 04-06; see `refactor/ambilight-validation.md`.
@@ -2,6 +2,13 @@
ambilight plugin for ArtPlayer
`start()` and `stop()` are synchronous. The plugin starts on the player's `ready`
event; `stop()` retains its last colors. Destroying the player removes the plugin's
grid and frame loop, and previously retained methods then do nothing. Canvas read
failures are skipped and retried, allowing a later readable source to recover.
Internal modules and compatibility tests are documented in [ARCHITECTURE.md](ARCHITECTURE.md).
## Demo
[https://artplayer.org](https://artplayer.org/?libs=./uncompiled/artplayer-plugin-ambilight/index.js&example=ambilight)
@@ -4,4 +4,4 @@
* (c) 2017-2026 Harvey Zhao
* Released under the MIT License.
*/
!function(e,t){"object"===typeof exports&&"undefined"!==typeof module?module.exports=t():"function"==typeof define&&define.amd?(t.artplayerPluginAmbilight=t(),define(function(){return t.artplayerPluginAmbilight})):(e="undefined"!==typeof globalThis?globalThis:e||self).artplayerPluginAmbilight=t()}(this,function(){"use strict";return function(e={}){return t=>{const{$video:n}=t.template,{createElement:r,addClass:i,setStyles:o}=t.constructor.utils,{blur:a="50px",opacity:l=.5,frequency:u=10,duration:f=.3}=e,s=r("div"),d=((c=s).innerHTML=Array.from({length:9}).fill("<div></div>").join(""),Array.from(c.children));var c,m,p,g;!function(e,t){i(e,"artplayer-plugin-ambilight"),t.parentNode.insertBefore(e,t),o(e,{position:"absolute",top:0,left:0,zIndex:9,inset:0,display:"grid",gridTemplateColumns:"1fr 1fr 1fr",gridTemplateRows:"1fr 1fr 1fr"})}(s,n),m=a,p=l,g=f,d.forEach(e=>{o(e,{opacity:p,filter:`blur(${m})`,transition:`background-color ${g}s ease`})});const y=function(e,n,i){const o=r("canvas"),a=o.getContext("2d");o.width=3,o.height=3;let l=0;return function r(){const o=performance.now();if(o-l<1e3/i||!t.playing)return void(h=requestAnimationFrame(r));l=o;const u=e.videoWidth/3,f=e.videoHeight/3,s=[[0,0],[u,0],[2*u,0],[0,f],[u,f],[2*u,f],[0,2*f],[u,2*f],[2*u,2*f]].map(([t,n])=>function(t,n,r,i){a.drawImage(e,t,n,r,i,0,0,1,1);const[o,l,u]=a.getImageData(0,0,1,1).data;return`rgb(${o}, ${l}, ${u})`}(t,n,u,f));n.forEach((e,t)=>{e.style.backgroundColor=s[t]}),h=requestAnimationFrame(r)}}(n,d,u);let h=null;function b(){h||y()}function v(){h&&(cancelAnimationFrame(h),h=null)}return t.on("ready",b),t.on("destroy",v),{name:"artplayerPluginAmbilight",start:b,stop:v}}}});
!function(t,n){"object"===typeof exports&&"undefined"!==typeof module?module.exports=n():"function"==typeof define&&define.amd?(t.artplayerPluginAmbilight=n(),define(function(){return t.artplayerPluginAmbilight})):(t="undefined"!==typeof globalThis?globalThis:t||self).artplayerPluginAmbilight=n()}(this,function(){"use strict";function t(t,n,e){let r="stopped",o=null,i=0,l=!1,u=0;function c(){const s=i,f=()=>"running"===r&&s===i;l=!0;try{const r=performance.now();if(r-u<1e3/t||!n()||!f())return;u=r,e(f)}finally{l=!1,"running"===r&&null===o&&function(){const t=i;try{o=requestAnimationFrame(()=>{"running"===r&&t===i&&(o=null,c())})}catch(n){throw r="stopped",i++,n}}()}}return{start:function(){"stopped"===r&&(r="running",i++,l||c())},stop:function(){if("destroyed"===r)return;r="stopped",i++;const t=o;o=null,null!==t&&cancelAnimationFrame(t)},destroy:function(){if("destroyed"===r)return;r="destroyed",i++;const t=o;o=null,null!==t&&cancelAnimationFrame(t)}}}return function(n={}){return e=>{let r=e,o=e.isDestroy,i=null,l=null,u=null;const c=[],s={name:"artplayerPluginAmbilight",start(){o||u?.start()},stop(){o||u?.stop()}};if(o)return r=null,s;function f(){const t=[u?.destroy,l?.destroy,i?.destroy];u=null,l=null,i=null;const n=[];for(const r of t)try{r?.()}catch(e){n.push(e)}if(n.length)throw n[0]}function a(){if(o)return;o=!0;const t=r;r=null;const n=[];for(const[r,o]of c.splice(0))try{t?.off(r,o)}catch(e){n.push(e)}try{f()}catch(e){n.push(e)}if(n.length)throw n[0]}function d(t,n){c.push([t,n]),r?.on(t,n)}try{if(d("destroy",a),o)return s;const{$video:c}=e.template,h=e.constructor.utils,{blur:y="50px",opacity:p=.5,frequency:g=10,duration:m=.3}=n;if(o)return s;if(i=function(t){const n=t.createElement("div");n.innerHTML=Array.from({length:9}).fill("<div></div>").join("");const e=Array.from(n.children);return{mount:function(r,o,i){if(i()&&(t.addClass(n,"artplayer-plugin-ambilight"),i()&&(r.parentNode.insertBefore(n,r),i()))){t.setStyles(n,{position:"absolute",top:0,left:0,zIndex:9,inset:0,display:"grid",gridTemplateColumns:"1fr 1fr 1fr",gridTemplateRows:"1fr 1fr 1fr"});for(const n of e){if(!i())return;t.setStyles(n,{opacity:o.opacity,filter:`blur(${o.blur})`,transition:`background-color ${o.duration}s ease`})}}},render:function(t,n){for(let r=0;r<t.length;r++){if(!n())return;const o=e[r],i=t[r];o&&void 0!==i&&(o.style.backgroundColor=i)}},destroy:function(){try{n.parentNode?.removeChild(n)}finally{e.length=0}}}}(h),o||i.mount(c,{blur:y,opacity:p,duration:m},()=>!o),o)return f(),s;const b=h.createElement("canvas");if(o)return b.width=0,b.height=0,s;if(l=function(t,n,e){let r;try{r=t.getContext("2d"),t.width=3,t.height=3}catch(l){throw t.width=0,t.height=0,l}let o=!1,i=!1;return{read:function(l){if(o||!l())return null;const u=n.videoWidth,c=n.videoHeight;if(!Number.isFinite(u)||!Number.isFinite(c)||u<=0||c<=0)return null;const s=u/3,f=c/3,a=[];try{if(i){const n=e();if(o||!l())return n.width=0,n.height=0,null;if(r=null,r=(t=n).getContext("2d"),o||!l())return t.width=0,t.height=0,null;t.width=3,t.height=3,i=!1}if(!r)return null;for(let t=0;t<3;t++)for(let e=0;e<3;e++){if(o||!l())return null;if(r.drawImage(n,e*s,t*f,s,f,0,0,1,1),o||!l())return null;const[i,u,c]=r.getImageData(0,0,1,1).data;a.push(`rgb(${i}, ${u}, ${c})`)}return a}catch{return o||(i=!0,t.width=0,t.height=0),null}},destroy:function(){o||(o=!0,t.width=0,t.height=0)}}}(b,c,()=>h.createElement("canvas")),o)return f(),s;u=t(g,()=>Boolean(r?.playing),t=>{const n=l?.read(t);n&&t()&&i?.render(n,t)}),d("ready",s.start),o&&f()}catch(h){try{a()}catch{}try{f()}catch{}throw h}return s}}});
@@ -4,4 +4,4 @@
* (c) 2017-2026 Harvey Zhao
* Released under the MIT License.
*/
!function(e,t){"object"===typeof exports&&"undefined"!==typeof module?module.exports=t():"function"==typeof define&&define.amd?(t.artplayerPluginAmbilight=t(),define(function(){return t.artplayerPluginAmbilight})):(e="undefined"!==typeof globalThis?globalThis:e||self).artplayerPluginAmbilight=t()}(this,function(){"use strict";return function(e={}){return t=>{const{$video:n}=t.template,{createElement:r,addClass:i,setStyles:o}=t.constructor.utils,{blur:a="50px",opacity:l=.5,frequency:u=10,duration:f=.3}=e,s=r("div"),d=((c=s).innerHTML=Array.from({length:9}).fill("<div></div>").join(""),Array.from(c.children));var c,m,p,g;!function(e,t){i(e,"artplayer-plugin-ambilight"),t.parentNode.insertBefore(e,t),o(e,{position:"absolute",top:0,left:0,zIndex:9,inset:0,display:"grid",gridTemplateColumns:"1fr 1fr 1fr",gridTemplateRows:"1fr 1fr 1fr"})}(s,n),m=a,p=l,g=f,d.forEach(e=>{o(e,{opacity:p,filter:`blur(${m})`,transition:`background-color ${g}s ease`})});const y=function(e,n,i){const o=r("canvas"),a=o.getContext("2d");o.width=3,o.height=3;let l=0;return function r(){const o=performance.now();if(o-l<1e3/i||!t.playing)return void(h=requestAnimationFrame(r));l=o;const u=e.videoWidth/3,f=e.videoHeight/3,s=[[0,0],[u,0],[2*u,0],[0,f],[u,f],[2*u,f],[0,2*f],[u,2*f],[2*u,2*f]].map(([t,n])=>function(t,n,r,i){a.drawImage(e,t,n,r,i,0,0,1,1);const[o,l,u]=a.getImageData(0,0,1,1).data;return`rgb(${o}, ${l}, ${u})`}(t,n,u,f));n.forEach((e,t)=>{e.style.backgroundColor=s[t]}),h=requestAnimationFrame(r)}}(n,d,u);let h=null;function b(){h||y()}function v(){h&&(cancelAnimationFrame(h),h=null)}return t.on("ready",b),t.on("destroy",v),{name:"artplayerPluginAmbilight",start:b,stop:v}}}});
!function(t,n){"object"===typeof exports&&"undefined"!==typeof module?module.exports=n():"function"==typeof define&&define.amd?(t.artplayerPluginAmbilight=n(),define(function(){return t.artplayerPluginAmbilight})):(t="undefined"!==typeof globalThis?globalThis:t||self).artplayerPluginAmbilight=n()}(this,function(){"use strict";function t(t,n,e){let r="stopped",o=null,i=0,l=!1,u=0;function c(){const s=i,f=()=>"running"===r&&s===i;l=!0;try{const r=performance.now();if(r-u<1e3/t||!n()||!f())return;u=r,e(f)}finally{l=!1,"running"===r&&null===o&&function(){const t=i;try{o=requestAnimationFrame(()=>{"running"===r&&t===i&&(o=null,c())})}catch(n){throw r="stopped",i++,n}}()}}return{start:function(){"stopped"===r&&(r="running",i++,l||c())},stop:function(){if("destroyed"===r)return;r="stopped",i++;const t=o;o=null,null!==t&&cancelAnimationFrame(t)},destroy:function(){if("destroyed"===r)return;r="destroyed",i++;const t=o;o=null,null!==t&&cancelAnimationFrame(t)}}}return function(n={}){return e=>{let r=e,o=e.isDestroy,i=null,l=null,u=null;const c=[],s={name:"artplayerPluginAmbilight",start(){o||null==u||u.start()},stop(){o||null==u||u.stop()}};if(o)return r=null,s;function f(){const t=[null==u?void 0:u.destroy,null==l?void 0:l.destroy,null==i?void 0:i.destroy];u=null,l=null,i=null;const n=[];for(const r of t)try{null==r||r()}catch(e){n.push(e)}if(n.length)throw n[0]}function a(){if(o)return;o=!0;const t=r;r=null;const n=[];for(const[r,o]of c.splice(0))try{null==t||t.off(r,o)}catch(e){n.push(e)}try{f()}catch(e){n.push(e)}if(n.length)throw n[0]}function d(t,n){c.push([t,n]),null==r||r.on(t,n)}try{if(d("destroy",a),o)return s;const{$video:c}=e.template,h=e.constructor.utils,{blur:y="50px",opacity:p=.5,frequency:g=10,duration:m=.3}=n;if(o)return s;if(i=function(t){const n=t.createElement("div");n.innerHTML=Array.from({length:9}).fill("<div></div>").join("");const e=Array.from(n.children);return{mount:function(r,o,i){if(i()&&(t.addClass(n,"artplayer-plugin-ambilight"),i()&&(r.parentNode.insertBefore(n,r),i()))){t.setStyles(n,{position:"absolute",top:0,left:0,zIndex:9,inset:0,display:"grid",gridTemplateColumns:"1fr 1fr 1fr",gridTemplateRows:"1fr 1fr 1fr"});for(const n of e){if(!i())return;t.setStyles(n,{opacity:o.opacity,filter:`blur(${o.blur})`,transition:`background-color ${o.duration}s ease`})}}},render:function(t,n){for(let r=0;r<t.length;r++){if(!n())return;const o=e[r],i=t[r];o&&void 0!==i&&(o.style.backgroundColor=i)}},destroy:function(){var t;try{null==(t=n.parentNode)||t.removeChild(n)}finally{e.length=0}}}}(h),o||i.mount(c,{blur:y,opacity:p,duration:m},()=>!o),o)return f(),s;const b=h.createElement("canvas");if(o)return b.width=0,b.height=0,s;if(l=function(t,n,e){let r;try{r=t.getContext("2d"),t.width=3,t.height=3}catch(l){throw t.width=0,t.height=0,l}let o=!1,i=!1;return{read:function(l){if(o||!l())return null;const u=n.videoWidth,c=n.videoHeight;if(!Number.isFinite(u)||!Number.isFinite(c)||u<=0||c<=0)return null;const s=u/3,f=c/3,a=[];try{if(i){const n=e();if(o||!l())return n.width=0,n.height=0,null;if(r=null,r=(t=n).getContext("2d"),o||!l())return t.width=0,t.height=0,null;t.width=3,t.height=3,i=!1}if(!r)return null;for(let t=0;t<3;t++)for(let e=0;e<3;e++){if(o||!l())return null;if(r.drawImage(n,e*s,t*f,s,f,0,0,1,1),o||!l())return null;const[i,u,c]=r.getImageData(0,0,1,1).data;a.push(`rgb(${i}, ${u}, ${c})`)}return a}catch(d){return o||(i=!0,t.width=0,t.height=0),null}},destroy:function(){o||(o=!0,t.width=0,t.height=0)}}}(b,c,()=>h.createElement("canvas")),o)return f(),s;u=t(g,()=>Boolean(null==r?void 0:r.playing),t=>{const n=null==l?void 0:l.read(t);n&&t()&&(null==i||i.render(n,t))}),d("ready",s.start),o&&f()}catch(h){try{a()}catch(y){}try{f()}catch(y){}throw h}return s}}});
@@ -4,99 +4,299 @@
* (c) 2017-2026 Harvey Zhao
* Released under the MIT License.
*/
function createColorSampler(canvas, video, createCanvas) {
let context;
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) {
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 = [];
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 {
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 };
}
function createFrameLoop(frequency, playing, update) {
let state = "stopped";
let pending = 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 < 1e3 / 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 };
}
function createAmbilightView(utils) {
const element = utils.createElement("div");
element.innerHTML = Array.from({ length: 9 }).fill("<div></div>").join("");
const items = Array.from(element.children);
function mount(video, option, active) {
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, active) {
for (let index = 0; index < colors.length; index++) {
if (!active())
return;
const item = items[index];
const color = colors[index];
if (item && color !== void 0)
item.style.backgroundColor = color;
}
}
function destroy() {
try {
element.parentNode?.removeChild(element);
} finally {
items.length = 0;
}
}
return { mount, render, destroy };
}
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 {
let host = art;
let closed = art.isDestroy;
let view = null;
let sampler = null;
let loop = null;
const subscriptions = [];
const result = {
name: "artplayerPluginAmbilight",
start,
stop
};
function createGridItems($ambilight2) {
$ambilight2.innerHTML = Array.from({ length: 9 }).fill("<div></div>").join("");
return Array.from($ambilight2.children);
}
function setupAmbilight($ambilight2, $video2) {
addClass($ambilight2, "artplayer-plugin-ambilight");
$video2.parentNode.insertBefore($ambilight2, $video2);
setStyles($ambilight2, {
position: "absolute",
top: 0,
left: 0,
zIndex: 9,
inset: 0,
display: "grid",
gridTemplateColumns: "1fr 1fr 1fr",
gridTemplateRows: "1fr 1fr 1fr"
});
}
function setupGridItems(gridItems2, blur2, opacity2, duration2) {
gridItems2.forEach(($item) => {
setStyles($item, {
opacity: opacity2,
filter: `blur(${blur2})`,
transition: `background-color ${duration2}s ease`
});
});
}
function createColorUpdater($video2, gridItems2, frequency2) {
const canvas = createElement("canvas");
const ctx = canvas.getContext("2d");
canvas.width = 3;
canvas.height = 3;
function getAverageColor(x, y, w, h) {
ctx.drawImage($video2, x, y, w, h, 0, 0, 1, 1);
const [r, g, b] = ctx.getImageData(0, 0, 1, 1).data;
return `rgb(${r}, ${g}, ${b})`;
start() {
if (!closed)
loop?.start();
},
stop() {
if (!closed)
loop?.stop();
}
let lastUpdateTime = 0;
return function updateColors2() {
const now = performance.now();
if (now - lastUpdateTime < 1e3 / frequency2 || !art.playing) {
animationFrameId = requestAnimationFrame(updateColors2);
return;
}
lastUpdateTime = now;
const w = $video2.videoWidth / 3;
const h = $video2.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));
gridItems2.forEach(($item, index) => {
$item.style.backgroundColor = colors[index];
});
animationFrameId = requestAnimationFrame(updateColors2);
};
};
if (closed) {
host = null;
return result;
}
function releaseResources() {
const releases = [loop?.destroy, sampler?.destroy, view?.destroy];
loop = null;
sampler = null;
view = null;
const failures = [];
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 = [];
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, callback) {
subscriptions.push([event, callback]);
host?.on(event, callback);
}
try {
listen("destroy", destroy);
if (closed)
return result;
const { $video } = art.template;
const utils = art.constructor.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 {
}
try {
releaseResources();
} catch {
}
throw error;
}
return result;
};
}
export {
@@ -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 }
}
@@ -0,0 +1,5 @@
{
"extends": "../../tsconfig.base.json",
"compilerOptions": { "allowJs": false },
"include": ["src/**/*.ts", "types/**/*.d.ts"]
}