/
mentoster
/
vk-stilus
Обзор
Документация
Войти
/
mentoster
/
vk-stilus
Код
Запросы
0
Задачи
Вики
Пакеты
0
Релизы
3
CI/CD
Аналитика
Безопасность
master
src/features/background/background-controller.ts
277 строк
11 KB
mentoster
fix(background): restore VK document playback
28 июл 2026, 18:27
28 июл 2026, 18:27
5a29cb7
Код
Авторство
О чём код?
import { verifyInvariant } from '../../core/invariant.js'; import { err, ok } from '../../core/result.js'; import { applyBackgroundLayout } from './background-layout.js'; import { buildBackgroundFilter, normalizeBackgroundSettings } from './background-model.js'; import { applyBackgroundPlayback } from './background-playback.js'; import type { BackgroundSettings, HlsAdapterPort, MediaDescriptor, MediaDocumentPort, MediaElementPort, MediaResult, MediaWindowPort, } from './background-types.js'; import { resolveMediaInput, updateBackgroundHistory } from './media-resolver.js'; const BACKGROUND_CONTEXT = Object.freeze({ owner: 'BackgroundController' }); const LAYER_ID = 'vk-stilus-background'; const MAX_MEDIA_RETRIES = 3; interface PreparedBackground { readonly media: MediaDescriptor; readonly settings: BackgroundSettings; readonly history: readonly string[]; } /** @assertion-exception Pure background error factory owns no lifecycle state. */ function backgroundError(code: string, message: string) { return Object.freeze({ code, message }); } /** @assertion-exception Pure media tag mapping owns no lifecycle state. */ function mediaTag(kind: MediaDescriptor['kind']): 'div' | 'iframe' | 'video' { if (kind === 'image') return 'div'; if (kind === 'youtube' || kind === 'vimeo' || kind === 'vk-video') return 'iframe'; return 'video'; } /** @assertion-exception Pure source projection owns no lifecycle state. */ function renderSource(media: MediaDescriptor): string { if (media.kind === 'youtube' || media.kind === 'vimeo') return media.embedUrl ?? media.source; if (media.kind === 'vk-video') { const [owner = '', id = ''] = (media.id ?? '').split('_'); return `https://vk.com/video_ext.php?oid=${owner}&id=${id}`; } return media.source; } /** @assertion-exception Pure event target narrowing follows an explicit runtime proof. */ function loadableTarget(event: Event): Readonly<{ load: () => void }> | null { const target: unknown = event.target; if (target === null || typeof target !== 'object' || Array.isArray(target)) return null; const record = target as Record<string, unknown>; return typeof record.load === 'function' ? (target as Readonly<{ load: () => void }>) : null; } /** * @classdesc Owns one validated background layer and its visibility/error lifecycle. * @responsibility Resolve user-selected media, render one extension-owned element, preserve last valid state, and dispose safely. * @nonresponsibility Resolve private VK documents, persist settings, or discover HLS implementations. * @trustBoundary Validates media input and background settings before DOM use. * @lifecycle Empty -> rendered -> replaced/reloaded -> disposed. * @invariant Exactly zero or one owned layer exists and one visibility listener is removed on disposal. * @resourceBudget One layer, one media child, one visibility listener, three retries, and 50 history entries. * @sideEffects Adds/removes extension-owned DOM, sets validated media URLs, and invokes HLS adapter methods. */ export class BackgroundController { readonly window: MediaWindowPort; readonly document: MediaDocumentPort; readonly hlsAdapter: HlsAdapterPort; layer: MediaElementPort | null = null; current: MediaDescriptor | null = null; history: readonly string[] = Object.freeze([]); lastError: unknown = null; retryCount = 0; listening = false; readonly visibilityHandler: () => void; readonly mediaErrorHandler: (event: Event) => void; constructor( options: Readonly<{ window: MediaWindowPort; document: MediaDocumentPort; hlsAdapter: HlsAdapterPort; }>, ) { this.window = options.window; this.document = options.document; this.hlsAdapter = options.hlsAdapter; this.visibilityHandler = this.handleVisibility.bind(this); this.mediaErrorHandler = this.handleMediaError.bind(this); const adapterValid = typeof this.hlsAdapter.attach === 'function'; const documentValid = typeof this.document.createElement === 'function'; const documentInvariant = verifyInvariant( documentValid, 'background.construct.document', BACKGROUND_CONTEXT, ); const adapterInvariant = verifyInvariant( adapterValid, 'background.construct.adapter', BACKGROUND_CONTEXT, ); if (!documentInvariant.ok || !adapterInvariant.ok) throw new TypeError('BackgroundController options invalid'); } /** @assertion-exception Listener ownership is checked by apply/dispose postconditions. */ ensureVisibilityListener(): void { if (this.listening) return; this.window.addEventListener('visibilitychange', this.visibilityHandler); this.listening = true; } /** @assertion-exception Visibility recovery delegates validation and invariants to apply(). */ handleVisibility(): void { if (this.document.visibilityState !== 'visible' || !this.current) return; const replayKinds = ['video', 'hls', 'vk-video', 'vk-document', 'youtube', 'vimeo']; if (!replayKinds.includes(this.current.kind)) return; const result = this.apply(this.current.source); if (!result.ok) this.lastError = result.error; } /** @assertion-exception Bounded retry handler mutates only retry diagnostics and current media element. */ handleMediaError(event: Event): void { this.retryCount += 1; const target = loadableTarget(event); if (this.retryCount <= MAX_MEDIA_RETRIES && target) { target.load(); return; } this.lastError = backgroundError('MEDIA_RETRY_EXHAUSTED', 'media retry limit reached'); } /** @assertion-exception DOM element factory is validated by commitLayer() postconditions. */ createLayer(media: MediaDescriptor): MediaElementPort { const layer = this.document.createElement('div'); layer.id = LAYER_ID; layer.dataset.kind = media.kind; layer.classList.add('vk-stilus-background'); const tag = mediaTag(media.kind); if (tag === 'div') { layer.style.setProperty('background-image', `url("${media.source}")`); return layer; } const child = this.document.createElement(tag); child.dataset.kind = media.kind; child.src = renderSource(media); child.loop = true; child.muted = true; child.addEventListener('error', this.mediaErrorHandler); layer.append(child); return layer; } /** @assertion-exception HLS attach is validated by adapter contract and apply() postconditions. */ attachHls(layer: MediaElementPort, media: MediaDescriptor): MediaResult<unknown> { if (media.kind !== 'hls') return ok(undefined); const child = layer.children[0]; if (!child) return err(backgroundError('BACKGROUND_MEDIA_MISSING', 'HLS media element missing')); return this.hlsAdapter.attach(child, media.source); } /** @assertion-exception Atomic DOM replacement is validated by apply() ownership invariants. */ commitLayer(layer: MediaElementPort): void { this.layer?.remove(); this.layer = layer; this.document.body?.append(layer); } /** @assertion-exception Fixed media playback delegate owns no independent lifecycle state. */ applyPlayback( layer: MediaElementPort, media: MediaDescriptor, settingsInput: unknown, ): MediaResult<unknown> { if (media.kind === 'image') return ok(undefined); const child = layer.children[0]; return child ? applyBackgroundPlayback(child, media, settingsInput) : err(backgroundError('BACKGROUND_MEDIA_MISSING', 'background media element missing')); } /** Publishes one fully attached background and verifies exact ownership. */ publishApplied( layer: MediaElementPort, prepared: PreparedBackground, ): MediaResult<ReturnType<BackgroundController['snapshot']>> { this.current = prepared.media; this.history = prepared.history; this.retryCount = 0; this.ensureVisibilityListener(); const layerOwned = this.document.getElementById(LAYER_ID) === layer; const listenerOwned = this.listening === true; const layerInvariant = verifyInvariant(layerOwned, 'background.apply.layer', BACKGROUND_CONTEXT); const listenerInvariant = verifyInvariant( listenerOwned, 'background.apply.listener', BACKGROUND_CONTEXT, ); if (!layerInvariant.ok) return err(layerInvariant.error); return listenerInvariant.ok ? ok(this.snapshot()) : err(listenerInvariant.error); } /** @assertion-exception Trust-boundary preparation delegates validation to resolver/model helpers. */ prepareApplication(input: unknown, settingsInput: unknown): MediaResult<PreparedBackground> { const resolved = resolveMediaInput(input); if (!resolved.ok) return resolved; const settings = normalizeBackgroundSettings(settingsInput); if (!settings.ok) return settings; const history = updateBackgroundHistory(this.history, input); if (!history.ok) return history; return ok(Object.freeze({ media: resolved.value, settings: settings.value, history: history.value })); } /** @assertion-exception Media attach delegates contract checks to HlsAdapter and cleans failed layer. */ attachPreparedLayer(layer: MediaElementPort, media: MediaDescriptor): MediaResult<unknown> { const hls = this.attachHls(layer, media); if (hls.ok) return hls; layer.remove(); return hls; } /** @assertion-exception Non-HLS release delegates lifecycle assertions to HlsAdapter.dispose(). */ releasePreviousHls(media: MediaDescriptor): MediaResult<unknown> { return media.kind === 'hls' ? ok(undefined) : this.hlsAdapter.dispose(); } apply(input: unknown, settingsInput: unknown = {}): MediaResult<ReturnType<BackgroundController['snapshot']>> { if (!this.document.body) { return err(backgroundError('BACKGROUND_BODY_UNAVAILABLE', 'document body is unavailable')); } const prepared = this.prepareApplication(input, settingsInput); if (!prepared.ok) return prepared; const layer = this.createLayer(prepared.value.media); applyBackgroundLayout(layer, prepared.value.media, prepared.value.settings); layer.style.setProperty('filter', buildBackgroundFilter(prepared.value.settings)); const attached = this.attachPreparedLayer(layer, prepared.value.media); if (!attached.ok) return attached; const released = this.releasePreviousHls(prepared.value.media); if (!released.ok) return released; this.commitLayer(layer); const playback = this.applyPlayback(layer, prepared.value.media, settingsInput); if (!playback.ok) return playback; return this.publishApplied(layer, prepared.value); } dispose(): MediaResult<void> { const released = this.hlsAdapter.dispose(); if (!released.ok) return released; this.layer?.remove(); this.layer = null; this.current = null; if (this.listening) this.window.removeEventListener('visibilitychange', this.visibilityHandler); this.listening = false; const layerGone = this.document.getElementById(LAYER_ID) === null; const listenerGone = this.listening === false; const layerInvariant = verifyInvariant(layerGone, 'background.dispose.layer', BACKGROUND_CONTEXT); const listenerInvariant = verifyInvariant( listenerGone, 'background.dispose.listener', BACKGROUND_CONTEXT, ); if (!layerInvariant.ok) return err(layerInvariant.error); return listenerInvariant.ok ? ok(undefined) : err(listenerInvariant.error); } /** @assertion-exception Pure immutable diagnostic snapshot owns no lifecycle transition. */ snapshot() { return Object.freeze({ current: this.current, history: this.history, retryCount: this.retryCount, listening: this.listening, lastError: this.lastError, }); } }