初始化奇妙小屏幕控制器项目
This commit is contained in:
@@ -0,0 +1,466 @@
|
||||
import { apiJson } from "./core.js";
|
||||
import { animationPreviewFrameAtElapsed } from "./animation-ui-model.js";
|
||||
|
||||
export const ANIMATION_PREVIEW_PAGE_SIZE = 500;
|
||||
export const ANIMATION_PREVIEW_DEFAULT_MAX_CONCURRENT = 2;
|
||||
export const ANIMATION_PREVIEW_MIN_MAX_CONCURRENT = 1;
|
||||
export const ANIMATION_PREVIEW_MAX_MAX_CONCURRENT = 50;
|
||||
export const ANIMATION_PREVIEW_PRELOAD_COUNT = 3;
|
||||
|
||||
export function normalizeAnimationPreviewMaxConcurrent(value) {
|
||||
if (!Number.isInteger(value)
|
||||
|| value < ANIMATION_PREVIEW_MIN_MAX_CONCURRENT
|
||||
|| value > ANIMATION_PREVIEW_MAX_MAX_CONCURRENT) {
|
||||
throw new Error(
|
||||
`动态缩略图并发数量必须是 ${ANIMATION_PREVIEW_MIN_MAX_CONCURRENT} 到 ${ANIMATION_PREVIEW_MAX_MAX_CONCURRENT} 之间的整数`,
|
||||
);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function stalePreviewError() {
|
||||
const error = new Error("动图已在预览加载期间发生变化");
|
||||
error.code = "ANIMATION_PREVIEW_STALE";
|
||||
return error;
|
||||
}
|
||||
|
||||
export async function loadCompleteAnimationPreview(
|
||||
animation,
|
||||
{ fetchJson = apiJson, signal, pageSize = ANIMATION_PREVIEW_PAGE_SIZE } = {},
|
||||
) {
|
||||
const expectedRevision = String(animation?.revision || "");
|
||||
const expectedCount = Number(animation?.frame_count || 0);
|
||||
if (!animation?.id || !expectedRevision) throw new Error("动图预览缺少标识或修订值");
|
||||
if (!Number.isInteger(pageSize) || pageSize < 1 || pageSize > ANIMATION_PREVIEW_PAGE_SIZE) {
|
||||
throw new Error("动图预览分页大小无效");
|
||||
}
|
||||
if (expectedCount === 0) return [];
|
||||
|
||||
const frames = [];
|
||||
let offset = 0;
|
||||
let total = expectedCount;
|
||||
while (offset < total) {
|
||||
const query = new URLSearchParams({
|
||||
frame_offset: String(offset),
|
||||
frame_limit: String(pageSize),
|
||||
});
|
||||
const page = await fetchJson(`/api/animations/${animation.id}?${query}`, { signal });
|
||||
if (String(page?.revision || "") !== expectedRevision) throw stalePreviewError();
|
||||
if (!Array.isArray(page.frames)) throw new Error("动图预览帧列表无效");
|
||||
total = Number(page.frame_total ?? page.frame_count ?? page.frames.length);
|
||||
if (!Number.isInteger(total) || total < 0 || total !== expectedCount) throw stalePreviewError();
|
||||
if (!page.frames.length && offset < total) throw new Error("动图预览分页提前结束");
|
||||
frames.push(...page.frames);
|
||||
offset += page.frames.length;
|
||||
}
|
||||
if (frames.length !== expectedCount) throw stalePreviewError();
|
||||
return frames;
|
||||
}
|
||||
|
||||
class AnimationPreviewEngine {
|
||||
constructor() {
|
||||
this.records = new Set();
|
||||
this.initialQueue = [];
|
||||
this.activeInitialLoads = 0;
|
||||
this.queue = [];
|
||||
this.activeLoads = 0;
|
||||
this.maxConcurrent = ANIMATION_PREVIEW_DEFAULT_MAX_CONCURRENT;
|
||||
this.timer = null;
|
||||
this.cache = new Map();
|
||||
this.reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)");
|
||||
this.observer = typeof IntersectionObserver === "function"
|
||||
? new IntersectionObserver((entries) => this.handleIntersections(entries), { rootMargin: "0px" })
|
||||
: null;
|
||||
this.viewportSyncPending = false;
|
||||
this.handleFallbackViewport = () => {
|
||||
if (this.observer || this.viewportSyncPending) return;
|
||||
this.viewportSyncPending = true;
|
||||
window.requestAnimationFrame(() => {
|
||||
this.viewportSyncPending = false;
|
||||
this.syncAll();
|
||||
});
|
||||
};
|
||||
this.handleVisibility = () => this.syncAll();
|
||||
this.handleMotionChange = () => this.syncAll();
|
||||
document.addEventListener("visibilitychange", this.handleVisibility);
|
||||
this.reducedMotion.addEventListener?.("change", this.handleMotionChange);
|
||||
window.addEventListener("scroll", this.handleFallbackViewport, true);
|
||||
window.addEventListener("resize", this.handleFallbackViewport);
|
||||
}
|
||||
|
||||
cacheKey(animation) {
|
||||
return `${animation.id}:${animation.revision}`;
|
||||
}
|
||||
|
||||
setMaxConcurrent(value) {
|
||||
const normalized = normalizeAnimationPreviewMaxConcurrent(value);
|
||||
if (normalized === this.maxConcurrent) return normalized;
|
||||
this.maxConcurrent = normalized;
|
||||
this.syncAll();
|
||||
return normalized;
|
||||
}
|
||||
|
||||
createGroup() {
|
||||
const engine = this;
|
||||
const records = new Set();
|
||||
return {
|
||||
active: false,
|
||||
register(options) {
|
||||
const record = engine.register(this, options);
|
||||
records.add(record);
|
||||
return () => {
|
||||
records.delete(record);
|
||||
engine.disposeRecord(record);
|
||||
engine.syncAll();
|
||||
};
|
||||
},
|
||||
setActive(active) {
|
||||
this.active = Boolean(active);
|
||||
engine.syncAll();
|
||||
},
|
||||
clear() {
|
||||
const keys = new Set();
|
||||
for (const record of records) {
|
||||
keys.add(engine.cacheKey(record.animation));
|
||||
engine.disposeRecord(record);
|
||||
}
|
||||
records.clear();
|
||||
for (const key of keys) engine.cache.delete(key);
|
||||
engine.scheduleTick();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
register(group, {
|
||||
animation,
|
||||
image,
|
||||
initialUrl,
|
||||
target = image,
|
||||
onFrame = null,
|
||||
onLoad = null,
|
||||
onInitialError = null,
|
||||
}) {
|
||||
const record = {
|
||||
group,
|
||||
animation,
|
||||
image,
|
||||
initialUrl,
|
||||
target,
|
||||
onFrame,
|
||||
onLoad,
|
||||
onInitialError,
|
||||
visible: false,
|
||||
disposed: false,
|
||||
initialQueued: false,
|
||||
initialLoading: false,
|
||||
initialReady: false,
|
||||
initialFailed: false,
|
||||
queued: false,
|
||||
loading: false,
|
||||
loadController: null,
|
||||
schedule: null,
|
||||
startedAt: null,
|
||||
currentIndex: -1,
|
||||
preloads: new Map(),
|
||||
lastSuccessfulUrl: "",
|
||||
restoring: false,
|
||||
};
|
||||
record.handleLoad = () => {
|
||||
if (record.initialLoading) this.finishInitialLoad(record, true);
|
||||
record.lastSuccessfulUrl = image.currentSrc || image.src;
|
||||
record.restoring = false;
|
||||
onLoad?.();
|
||||
};
|
||||
record.handleError = () => {
|
||||
if (record.initialLoading) {
|
||||
this.finishInitialLoad(record, false);
|
||||
record.restoring = false;
|
||||
onInitialError?.();
|
||||
return;
|
||||
}
|
||||
const failedUrl = image.currentSrc || image.src;
|
||||
if (record.lastSuccessfulUrl && failedUrl !== record.lastSuccessfulUrl && !record.restoring) {
|
||||
record.restoring = true;
|
||||
image.src = record.lastSuccessfulUrl;
|
||||
return;
|
||||
}
|
||||
record.restoring = false;
|
||||
onInitialError?.();
|
||||
};
|
||||
image.addEventListener("load", record.handleLoad);
|
||||
image.addEventListener("error", record.handleError);
|
||||
this.records.add(record);
|
||||
this.observer?.observe(target);
|
||||
this.syncAll();
|
||||
return record;
|
||||
}
|
||||
|
||||
disposeRecord(record) {
|
||||
if (record.disposed) return;
|
||||
record.disposed = true;
|
||||
record.initialQueued = false;
|
||||
if (record.initialLoading) {
|
||||
record.initialLoading = false;
|
||||
this.activeInitialLoads = Math.max(0, this.activeInitialLoads - 1);
|
||||
record.image.removeAttribute("src");
|
||||
}
|
||||
record.loadController?.abort();
|
||||
record.preloads.clear();
|
||||
record.image.removeEventListener("load", record.handleLoad);
|
||||
record.image.removeEventListener("error", record.handleError);
|
||||
this.observer?.unobserve(record.target);
|
||||
this.records.delete(record);
|
||||
this.pumpInitialQueue();
|
||||
}
|
||||
|
||||
canLoadInitial(record) {
|
||||
return !record.disposed
|
||||
&& record.group.active
|
||||
&& this.isRecordVisible(record)
|
||||
&& document.visibilityState === "visible"
|
||||
&& Boolean(record.initialUrl);
|
||||
}
|
||||
|
||||
isRecordVisible(record) {
|
||||
if (this.observer) return record.visible;
|
||||
const rect = record.target.getBoundingClientRect();
|
||||
return rect.width > 0
|
||||
&& rect.height > 0
|
||||
&& rect.right > 0
|
||||
&& rect.bottom > 0
|
||||
&& rect.left < window.innerWidth
|
||||
&& rect.top < window.innerHeight;
|
||||
}
|
||||
|
||||
syncInitial(record) {
|
||||
if (!this.canLoadInitial(record)
|
||||
|| record.initialReady
|
||||
|| record.initialFailed
|
||||
|| record.initialLoading
|
||||
|| record.initialQueued) return;
|
||||
record.initialQueued = true;
|
||||
this.initialQueue.push(record);
|
||||
}
|
||||
|
||||
pumpInitialQueue() {
|
||||
while (this.activeInitialLoads < this.maxConcurrent
|
||||
&& this.initialQueue.length) {
|
||||
const record = this.initialQueue.shift();
|
||||
record.initialQueued = false;
|
||||
if (!this.canLoadInitial(record) || record.initialReady || record.initialFailed) continue;
|
||||
record.initialLoading = true;
|
||||
this.activeInitialLoads += 1;
|
||||
record.image.src = record.initialUrl;
|
||||
}
|
||||
}
|
||||
|
||||
finishInitialLoad(record, succeeded) {
|
||||
if (!record.initialLoading) return;
|
||||
record.initialLoading = false;
|
||||
this.activeInitialLoads = Math.max(0, this.activeInitialLoads - 1);
|
||||
record.initialReady = succeeded;
|
||||
record.initialFailed = !succeeded;
|
||||
this.pumpInitialQueue();
|
||||
this.syncAll();
|
||||
}
|
||||
|
||||
handleIntersections(entries) {
|
||||
for (const entry of entries) {
|
||||
for (const record of this.records) {
|
||||
if (record.target !== entry.target) continue;
|
||||
record.visible = entry.isIntersecting;
|
||||
}
|
||||
}
|
||||
this.syncAll();
|
||||
}
|
||||
|
||||
canRun(record) {
|
||||
return !record.disposed
|
||||
&& record.group.active
|
||||
&& this.isRecordVisible(record)
|
||||
&& document.visibilityState === "visible"
|
||||
&& !this.reducedMotion.matches
|
||||
&& Number(record.animation.frame_count) > 1;
|
||||
}
|
||||
|
||||
shouldRun(record) {
|
||||
let selected = 0;
|
||||
for (const candidate of this.records) {
|
||||
if (!this.canRun(candidate)) continue;
|
||||
if (candidate === record) return selected < this.maxConcurrent;
|
||||
selected += 1;
|
||||
if (selected >= this.maxConcurrent) return false;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
syncRecord(record) {
|
||||
if (!this.shouldRun(record)) {
|
||||
record.loadController?.abort();
|
||||
record.loadController = null;
|
||||
record.loading = false;
|
||||
record.queued = false;
|
||||
record.preloads.clear();
|
||||
if (record.schedule) this.cache.delete(this.cacheKey(record.animation));
|
||||
record.schedule = null;
|
||||
record.startedAt = null;
|
||||
record.currentIndex = -1;
|
||||
return;
|
||||
}
|
||||
if (!record.initialReady) return;
|
||||
if (record.schedule) return;
|
||||
const cached = this.cache.get(this.cacheKey(record.animation));
|
||||
if (cached) {
|
||||
record.schedule = cached;
|
||||
record.startedAt = performance.now();
|
||||
return;
|
||||
}
|
||||
if (!record.loading && !record.queued) {
|
||||
record.queued = true;
|
||||
this.queue.push(record);
|
||||
this.pumpQueue();
|
||||
}
|
||||
}
|
||||
|
||||
syncAll() {
|
||||
for (const record of this.records) this.syncInitial(record);
|
||||
this.pumpInitialQueue();
|
||||
for (const record of this.records) this.syncRecord(record);
|
||||
this.scheduleTick();
|
||||
}
|
||||
|
||||
pumpQueue() {
|
||||
while (this.activeLoads < this.maxConcurrent && this.queue.length) {
|
||||
const record = this.queue.shift();
|
||||
record.queued = false;
|
||||
if (!this.shouldRun(record) || record.disposed || record.schedule) continue;
|
||||
this.loadRecord(record);
|
||||
}
|
||||
}
|
||||
|
||||
async loadRecord(record) {
|
||||
record.loading = true;
|
||||
record.loadController = new AbortController();
|
||||
this.activeLoads += 1;
|
||||
try {
|
||||
const frames = await loadCompleteAnimationPreview(record.animation, {
|
||||
signal: record.loadController.signal,
|
||||
});
|
||||
if (record.disposed || !this.shouldRun(record)) return;
|
||||
record.schedule = frames;
|
||||
record.startedAt = performance.now();
|
||||
this.cache.set(this.cacheKey(record.animation), frames);
|
||||
} catch (error) {
|
||||
if (error?.name !== "AbortError" && error?.code !== "ANIMATION_PREVIEW_STALE") {
|
||||
// The first frame remains usable; a later refresh can retry the full schedule.
|
||||
}
|
||||
} finally {
|
||||
record.loading = false;
|
||||
record.loadController = null;
|
||||
this.activeLoads -= 1;
|
||||
this.pumpQueue();
|
||||
this.scheduleTick();
|
||||
}
|
||||
}
|
||||
|
||||
preloadAhead(record, index) {
|
||||
const wanted = new Set();
|
||||
const length = record.schedule.length;
|
||||
for (let step = 1; step <= ANIMATION_PREVIEW_PRELOAD_COUNT && step < length; step += 1) {
|
||||
const nextIndex = (index + step) % length;
|
||||
wanted.add(nextIndex);
|
||||
if (!record.preloads.has(nextIndex)) {
|
||||
const preload = new Image();
|
||||
preload.decoding = "async";
|
||||
preload.src = record.schedule[nextIndex].thumbnail_url;
|
||||
record.preloads.set(nextIndex, preload);
|
||||
}
|
||||
}
|
||||
for (const existing of record.preloads.keys()) {
|
||||
if (!wanted.has(existing)) record.preloads.delete(existing);
|
||||
}
|
||||
}
|
||||
|
||||
scheduleTick() {
|
||||
if (this.timer !== null) {
|
||||
window.clearTimeout(this.timer);
|
||||
this.timer = null;
|
||||
}
|
||||
let nextDelay = Infinity;
|
||||
const now = performance.now();
|
||||
for (const record of this.records) {
|
||||
if (!this.shouldRun(record) || !record.schedule?.length) continue;
|
||||
if (record.startedAt === null) record.startedAt = now;
|
||||
const selected = animationPreviewFrameAtElapsed(record.schedule, now - record.startedAt);
|
||||
if (!selected) continue;
|
||||
if (record.currentIndex !== selected.index) {
|
||||
record.currentIndex = selected.index;
|
||||
record.image.src = selected.frame.thumbnail_url;
|
||||
record.onFrame?.({
|
||||
frame: selected.frame,
|
||||
index: selected.index,
|
||||
total: record.schedule.length,
|
||||
});
|
||||
this.preloadAhead(record, selected.index);
|
||||
}
|
||||
nextDelay = Math.min(nextDelay, selected.remainingMs);
|
||||
}
|
||||
if (Number.isFinite(nextDelay)) {
|
||||
this.timer = window.setTimeout(() => {
|
||||
this.timer = null;
|
||||
this.scheduleTick();
|
||||
}, Math.max(16, Math.ceil(nextDelay)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
let sharedEngine = null;
|
||||
|
||||
function getSharedEngine() {
|
||||
if (!sharedEngine) sharedEngine = new AnimationPreviewEngine();
|
||||
return sharedEngine;
|
||||
}
|
||||
|
||||
export function createAnimationPreviewGroup() {
|
||||
return getSharedEngine().createGroup();
|
||||
}
|
||||
|
||||
export function setAnimationPreviewMaxConcurrent(value) {
|
||||
return getSharedEngine().setMaxConcurrent(value);
|
||||
}
|
||||
|
||||
export function createAnimationThumbnailBox(group, animation) {
|
||||
const imageBox = document.createElement("div");
|
||||
imageBox.className = "template-image-box";
|
||||
if (!animation.thumbnail_url || !animation.frame_count) {
|
||||
imageBox.classList.add("template-image-empty");
|
||||
imageBox.setAttribute("aria-label", "空动图");
|
||||
return imageBox;
|
||||
}
|
||||
|
||||
const image = document.createElement("img");
|
||||
image.className = "template-image";
|
||||
image.alt = `${animation.name} 动图缩略图`;
|
||||
image.width = 64;
|
||||
image.height = 64;
|
||||
image.draggable = false;
|
||||
image.loading = "lazy";
|
||||
imageBox.append(image);
|
||||
group.register({
|
||||
animation,
|
||||
image,
|
||||
initialUrl: animation.thumbnail_url,
|
||||
target: imageBox,
|
||||
onLoad: () => {
|
||||
image.hidden = false;
|
||||
imageBox.classList.remove("template-image-error");
|
||||
imageBox.removeAttribute("aria-label");
|
||||
},
|
||||
onInitialError: () => {
|
||||
image.hidden = true;
|
||||
imageBox.classList.add("template-image-error");
|
||||
imageBox.setAttribute("aria-label", "缩略图加载失败");
|
||||
},
|
||||
});
|
||||
return imageBox;
|
||||
}
|
||||
Reference in New Issue
Block a user