mirror of
https://github.com/zhw2590582/ArtPlayer.git
synced 2026-10-19 19:51: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
@@ -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}}});
|
||||
+1
-1
@@ -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"]
|
||||
}
|
||||
Reference in new issue
Block a user