@videojs/spf 10.0.0-beta.14 → 10.0.0-beta.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (109) hide show
  1. package/dist/default/core/buffer/forward-buffer.js.map +1 -1
  2. package/dist/default/core/create-machine-actor.js +91 -0
  3. package/dist/default/core/create-machine-actor.js.map +1 -0
  4. package/dist/default/core/create-machine-reactor.js +83 -0
  5. package/dist/default/core/create-machine-reactor.js.map +1 -0
  6. package/dist/default/core/create-transition-actor.js +46 -0
  7. package/dist/default/core/create-transition-actor.js.map +1 -0
  8. package/dist/default/core/features/resolve-presentation.js +42 -46
  9. package/dist/default/core/features/resolve-presentation.js.map +1 -1
  10. package/dist/default/core/features/resolve-track.js +1 -1
  11. package/dist/default/core/machine.js +26 -0
  12. package/dist/default/core/machine.js.map +1 -0
  13. package/dist/default/core/signals/primitives.js +1 -1
  14. package/dist/default/core/task.js +76 -7
  15. package/dist/default/core/task.js.map +1 -1
  16. package/dist/default/core/types/index.js.map +1 -1
  17. package/dist/default/dom/features/end-of-stream.js +5 -5
  18. package/dist/default/dom/features/end-of-stream.js.map +1 -1
  19. package/dist/default/dom/features/load-text-track-cues.js +80 -137
  20. package/dist/default/dom/features/load-text-track-cues.js.map +1 -1
  21. package/dist/default/dom/features/segment-loader-actor.js +150 -103
  22. package/dist/default/dom/features/segment-loader-actor.js.map +1 -1
  23. package/dist/default/dom/features/sync-text-tracks.js +95 -0
  24. package/dist/default/dom/features/sync-text-tracks.js.map +1 -0
  25. package/dist/default/dom/features/text-track-segment-loader-actor.js +55 -0
  26. package/dist/default/dom/features/text-track-segment-loader-actor.js.map +1 -0
  27. package/dist/default/dom/features/text-tracks-actor.js +42 -0
  28. package/dist/default/dom/features/text-tracks-actor.js.map +1 -0
  29. package/dist/default/dom/features/track-playback-initiated.js +51 -38
  30. package/dist/default/dom/features/track-playback-initiated.js.map +1 -1
  31. package/dist/default/dom/media/source-buffer-actor.js +52 -91
  32. package/dist/default/dom/media/source-buffer-actor.js.map +1 -1
  33. package/dist/default/dom/network/fetch.js +0 -1
  34. package/dist/default/dom/network/fetch.js.map +1 -1
  35. package/dist/default/dom/playback-engine/engine.js +4 -13
  36. package/dist/default/dom/playback-engine/engine.js.map +1 -1
  37. package/dist/dev/core/actor.d.ts +19 -10
  38. package/dist/dev/core/actor.d.ts.map +1 -1
  39. package/dist/dev/core/buffer/forward-buffer.js.map +1 -1
  40. package/dist/dev/core/create-machine-actor.d.ts +12 -0
  41. package/dist/dev/core/create-machine-actor.d.ts.map +1 -0
  42. package/dist/dev/core/create-machine-actor.js +91 -0
  43. package/dist/dev/core/create-machine-actor.js.map +1 -0
  44. package/dist/dev/core/create-machine-reactor.d.ts +8 -0
  45. package/dist/dev/core/create-machine-reactor.d.ts.map +1 -0
  46. package/dist/dev/core/create-machine-reactor.js +83 -0
  47. package/dist/dev/core/create-machine-reactor.js.map +1 -0
  48. package/dist/dev/core/create-transition-actor.d.ts +23 -0
  49. package/dist/dev/core/create-transition-actor.d.ts.map +1 -0
  50. package/dist/dev/core/create-transition-actor.js +46 -0
  51. package/dist/dev/core/create-transition-actor.js.map +1 -0
  52. package/dist/dev/core/features/resolve-presentation.js +42 -46
  53. package/dist/dev/core/features/resolve-presentation.js.map +1 -1
  54. package/dist/dev/core/features/resolve-track.js +1 -1
  55. package/dist/dev/core/machine.d.ts +21 -0
  56. package/dist/dev/core/machine.d.ts.map +1 -0
  57. package/dist/dev/core/machine.js +26 -0
  58. package/dist/dev/core/machine.js.map +1 -0
  59. package/dist/dev/core/signals/primitives.js +1 -1
  60. package/dist/dev/core/task.js +76 -7
  61. package/dist/dev/core/task.js.map +1 -1
  62. package/dist/dev/core/types/index.d.ts +1 -8
  63. package/dist/dev/core/types/index.d.ts.map +1 -1
  64. package/dist/dev/core/types/index.js.map +1 -1
  65. package/dist/dev/dom/features/end-of-stream.js +5 -5
  66. package/dist/dev/dom/features/end-of-stream.js.map +1 -1
  67. package/dist/dev/dom/features/load-text-track-cues.d.ts +50 -26
  68. package/dist/dev/dom/features/load-text-track-cues.d.ts.map +1 -1
  69. package/dist/dev/dom/features/load-text-track-cues.js +80 -137
  70. package/dist/dev/dom/features/load-text-track-cues.js.map +1 -1
  71. package/dist/dev/dom/features/segment-loader-actor.js +150 -103
  72. package/dist/dev/dom/features/segment-loader-actor.js.map +1 -1
  73. package/dist/dev/dom/features/sync-text-tracks.js +95 -0
  74. package/dist/dev/dom/features/sync-text-tracks.js.map +1 -0
  75. package/dist/dev/dom/features/text-track-segment-loader-actor.d.ts +12 -0
  76. package/dist/dev/dom/features/text-track-segment-loader-actor.d.ts.map +1 -0
  77. package/dist/dev/dom/features/text-track-segment-loader-actor.js +55 -0
  78. package/dist/dev/dom/features/text-track-segment-loader-actor.js.map +1 -0
  79. package/dist/dev/dom/features/text-tracks-actor.d.ts +31 -0
  80. package/dist/dev/dom/features/text-tracks-actor.d.ts.map +1 -0
  81. package/dist/dev/dom/features/text-tracks-actor.js +42 -0
  82. package/dist/dev/dom/features/text-tracks-actor.js.map +1 -0
  83. package/dist/dev/dom/features/track-playback-initiated.d.ts +11 -9
  84. package/dist/dev/dom/features/track-playback-initiated.d.ts.map +1 -1
  85. package/dist/dev/dom/features/track-playback-initiated.js +51 -38
  86. package/dist/dev/dom/features/track-playback-initiated.js.map +1 -1
  87. package/dist/dev/dom/media/source-buffer-actor.d.ts +14 -8
  88. package/dist/dev/dom/media/source-buffer-actor.d.ts.map +1 -1
  89. package/dist/dev/dom/media/source-buffer-actor.js +52 -91
  90. package/dist/dev/dom/media/source-buffer-actor.js.map +1 -1
  91. package/dist/dev/dom/network/fetch.js +0 -1
  92. package/dist/dev/dom/network/fetch.js.map +1 -1
  93. package/dist/dev/dom/playback-engine/engine.d.ts +4 -3
  94. package/dist/dev/dom/playback-engine/engine.d.ts.map +1 -1
  95. package/dist/dev/dom/playback-engine/engine.js +4 -13
  96. package/dist/dev/dom/playback-engine/engine.js.map +1 -1
  97. package/package.json +5 -4
  98. package/dist/default/dom/features/setup-text-tracks.js +0 -67
  99. package/dist/default/dom/features/setup-text-tracks.js.map +0 -1
  100. package/dist/default/dom/features/sync-selected-text-track-from-dom.js +0 -54
  101. package/dist/default/dom/features/sync-selected-text-track-from-dom.js.map +0 -1
  102. package/dist/default/dom/features/sync-text-track-modes.js +0 -33
  103. package/dist/default/dom/features/sync-text-track-modes.js.map +0 -1
  104. package/dist/dev/dom/features/setup-text-tracks.js +0 -67
  105. package/dist/dev/dom/features/setup-text-tracks.js.map +0 -1
  106. package/dist/dev/dom/features/sync-selected-text-track-from-dom.js +0 -54
  107. package/dist/dev/dom/features/sync-selected-text-track-from-dom.js.map +0 -1
  108. package/dist/dev/dom/features/sync-text-track-modes.js +0 -33
  109. package/dist/dev/dom/features/sync-text-track-modes.js.map +0 -1
@@ -1 +1 @@
1
- {"version":3,"file":"segment-loader-actor.js","names":[],"sources":["../../../../src/dom/features/segment-loader-actor.ts"],"sourcesContent":["import { calculateBackBufferFlushPoint } from '../../core/buffer/back-buffer';\nimport { calculateForwardFlushPoint, getSegmentsToLoad } from '../../core/buffer/forward-buffer';\nimport type { AddressableObject, AudioTrack, Segment, VideoTrack } from '../../core/types';\nimport type {\n AppendInitMessage,\n AppendSegmentMessage,\n RemoveMessage,\n SourceBufferActor,\n} from '../media/source-buffer-actor';\n\n// ============================================================================\n// BUFFER STATE TYPES\n// ============================================================================\n\n/**\n * Buffer state for a single SourceBuffer.\n * Tracks which init segment and media segments are loaded.\n */\nexport interface SourceBufferState {\n /** Track ID of the loaded init segment */\n initTrackId?: string;\n /** Loaded media segments (unordered - selectors derive ordering) */\n segments: Array<{ id: string; trackId: string }>;\n}\n\n/**\n * Buffer state for all SourceBuffers.\n */\nexport interface BufferState {\n video?: SourceBufferState;\n audio?: SourceBufferState;\n}\n\n// ============================================================================\n// MESSAGE PROTOCOL\n// ============================================================================\n\n/** Track types that have SourceBuffers (video and audio only). */\nexport type SegmentLoaderTrack = VideoTrack | AudioTrack;\n\n/**\n * Message sent to a SegmentLoaderActor.\n *\n * `range` is optional to distinguish loading modes:\n * - No range: load init segment only (metadata preload mode)\n * - With range: load init + all segments overlapping [start, end]\n *\n * `start` and `end` are raw time values — no segment snapping.\n * The actor maps them onto segment boundaries internally.\n */\nexport type SegmentLoaderMessage = {\n type: 'load';\n track: SegmentLoaderTrack;\n range?: { start: number; end: number };\n};\n\n// ============================================================================\n// LOAD TASK\n// ============================================================================\n\n/**\n * A LoadTask is the intent to perform one unit of SegmentLoader work.\n * Unlike SourceBufferMessage, fetch-based tasks carry a URL rather than\n * pre-fetched data — the runner fetches and appends them in sequence.\n *\n * Derived from SourceBufferMessage types by removing `data` and adding\n * a fetch URL via AddressableObject.\n *\n * @todo Rename — \"LoadTask\" risks confusion with the `Task` class used for\n * SourceBufferActor scheduling. These are closer to operation descriptors or\n * messages than tasks in that sense.\n */\nexport type LoadTask =\n | (Omit<AppendInitMessage, 'data'> & AddressableObject)\n | (Omit<AppendSegmentMessage, 'data'> & AddressableObject)\n | RemoveMessage;\n\n// ============================================================================\n// ACTOR INTERFACE\n// ============================================================================\n\nexport interface SegmentLoaderActor {\n send(message: SegmentLoaderMessage): void;\n destroy(): void;\n}\n\n// ============================================================================\n// IMPLEMENTATION\n// ============================================================================\n\n/**\n * Creates a SegmentLoaderActor for one track type (video or audio).\n *\n * Receives load assignments via `send()` and owns all execution: planning,\n * removes, fetches, and appends. Coordinates with the SourceBufferActor for\n * all physical SourceBuffer operations.\n *\n * Planning (Cases 1–3) happens in `send()` on every incoming message, producing\n * an ordered LoadTask list. The runner drains that list sequentially. When a new\n * message arrives mid-run, send() replans and either continues the in-flight\n * operation (if still needed) or preempts it.\n *\n * @param sourceBufferActor - Shared SourceBufferActor reference (not owned)\n * @param fetchBytes - Tracked fetch closure (owns throughput sampling for segments).\n * Accepts an optional `minChunkSize` in options; init segments pass `Infinity`\n * so the entire body accumulates as one chunk before appending.\n */\nexport function createSegmentLoaderActor(\n sourceBufferActor: SourceBufferActor,\n fetchBytes: (\n addressable: AddressableObject,\n options?: RequestInit & { minChunkSize?: number }\n ) => Promise<AsyncIterable<Uint8Array>>\n): SegmentLoaderActor {\n let pendingTasks: LoadTask[] | null = null;\n let inFlightInitTrackId: string | null = null;\n let inFlightSegmentId: string | null = null;\n let abortController: AbortController | null = null;\n let running = false;\n let destroyed = false;\n\n const getBufferedSegments = (allSegments: readonly Segment[]): Segment[] => {\n // Exclude partial segments — they are still being streamed and must not be\n // treated as fully buffered for load planning or buffer window calculations.\n const bufferedIds = new Set(\n sourceBufferActor.snapshot\n .get()\n .context.segments.filter((s) => !s.partial)\n .map((s) => s.id)\n );\n return allSegments.filter((s) => bufferedIds.has(s.id));\n };\n\n /**\n * Translate a load message into an ordered LoadTask list based on committed\n * actor state. In-flight awareness is handled separately in send().\n *\n * @todo Rename alongside LoadTask (e.g. planOps).\n *\n * Case 1 — Removes: forward and back buffer flush points, segment-aligned.\n * No flush on track switch: appending new content overwrites existing buffer\n * ranges, and the actor's time-aligned deduplication keeps the segment model\n * accurate as new segments arrive.\n *\n * Case 2 — Init: schedule if not yet committed for this track.\n *\n * Case 3 — Segments: all segments in the load window not yet committed.\n */\n const planTasks = (message: SegmentLoaderMessage): LoadTask[] => {\n const { track, range } = message;\n const actorCtx = sourceBufferActor.snapshot.get().context;\n const bufferedSegments = getBufferedSegments(track.segments);\n const currentTime = range?.start ?? 0;\n const tasks: LoadTask[] = [];\n\n // Case 1: Removes\n if (range) {\n const forwardFlushStart = calculateForwardFlushPoint(bufferedSegments, currentTime);\n if (forwardFlushStart < Infinity) {\n tasks.push({ type: 'remove', start: forwardFlushStart, end: Infinity });\n }\n const backFlushEnd = calculateBackBufferFlushPoint(bufferedSegments, currentTime);\n if (backFlushEnd > 0) {\n tasks.push({ type: 'remove', start: 0, end: backFlushEnd });\n }\n }\n\n // Case 2: Init\n if (actorCtx.initTrackId !== track.id) {\n tasks.push({\n type: 'append-init',\n meta: { trackId: track.id },\n url: track.initialization.url,\n ...(track.initialization.byteRange !== undefined && { byteRange: track.initialization.byteRange }),\n });\n }\n\n // Case 3: Segments\n if (range) {\n const EPSILON = 0.0001;\n const segmentsToLoad = getSegmentsToLoad(track.segments, bufferedSegments, currentTime).filter((seg) => {\n // Quality-aware filter: skip segments already covered by equal-or-higher-quality\n // content in the actor context. Preserves buffered high-quality content during\n // ABR downgrades; loads during upgrades and for uncovered positions.\n const existing = actorCtx.segments.find((s) => Math.abs(s.startTime - seg.startTime) < EPSILON);\n // Partial segments are still streaming — treat as not buffered so they\n // are always re-planned (avoids relying on incomplete data).\n if (existing?.partial) return true;\n if (!existing?.trackBandwidth || !track.bandwidth) return true;\n return track.bandwidth > existing.trackBandwidth;\n });\n for (const segment of segmentsToLoad) {\n tasks.push({\n type: 'append-segment',\n meta: {\n id: segment.id,\n startTime: segment.startTime,\n duration: segment.duration,\n trackId: track.id,\n trackBandwidth: track.bandwidth,\n },\n url: segment.url,\n ...(segment.byteRange !== undefined && { byteRange: segment.byteRange }),\n });\n }\n }\n\n return tasks;\n };\n\n /**\n * Execute a single LoadTask: fetch (if needed) then forward to SourceBufferActor.\n * Sets/clears in-flight tracking around async operations so send() can make\n * accurate continue/preempt decisions at any point during execution.\n *\n * @todo Rename alongside LoadTask (e.g. executeOp).\n */\n const executeLoadTask = async (task: LoadTask): Promise<void> => {\n const signal = abortController!.signal;\n try {\n if (task.type === 'remove') {\n await sourceBufferActor.send(task, signal);\n return;\n }\n\n if (task.type === 'append-init') {\n inFlightInitTrackId = task.meta.trackId;\n if (!signal.aborted) {\n // Init segments are small and need the full body before the\n // same-track-seek vs track-switch commit decision can be made.\n // minChunkSize: Infinity causes ChunkedStreamIterable to accumulate\n // all chunks and yield exactly one — equivalent to arrayBuffer() but\n // through the same streaming path as media segments.\n const data = await fetchBytes(task, { signal, minChunkSize: Infinity });\n // For seeks on the same track: commit even if aborted — avoids re-fetching the\n // same init next time. For track switches: don't commit the old track's init;\n // the new track's init follows in pendingTasks.\n const isTrackSwitch = pendingTasks?.some(\n (t) => t.type === 'append-init' && t.meta.trackId !== task.meta.trackId\n );\n if (!signal.aborted || !isTrackSwitch) {\n const appendSignal = signal.aborted ? new AbortController().signal : signal;\n await sourceBufferActor.send({ type: 'append-init', data, meta: task.meta }, appendSignal);\n }\n }\n return;\n }\n\n // append-segment: await headers eagerly (starts the HTTP connection and\n // records the fetch in observers like tests), then pass the body stream\n // directly to the actor so chunks are appended as they arrive.\n inFlightSegmentId = task.meta.id;\n if (!signal.aborted) {\n const stream = await fetchBytes(task, { signal });\n if (!signal.aborted) {\n await sourceBufferActor.send({ type: 'append-segment', data: stream, meta: task.meta }, signal);\n }\n }\n } finally {\n inFlightInitTrackId = null;\n inFlightSegmentId = null;\n }\n };\n\n /**\n * Drain the scheduled task list sequentially.\n * After each task completes, checks for a pending replacement plan from send().\n * If the signal was aborted and no new plan arrived, stops immediately.\n */\n const runScheduled = async (initialTasks: LoadTask[]): Promise<void> => {\n running = true;\n abortController = new AbortController();\n let scheduled = initialTasks;\n\n while (scheduled.length > 0 && !destroyed) {\n const task = scheduled[0]!;\n scheduled = scheduled.slice(1);\n\n try {\n await executeLoadTask(task);\n } catch (error) {\n if (error instanceof Error && error.name === 'AbortError') {\n // Abort is handled in the post-task check below.\n } else {\n console.error('Unexpected error in segment loader:', error);\n // Non-abort error (e.g. network failure on init): don't continue with\n // remaining tasks in this sequence — they depend on the failed step.\n // A pending replacement plan (if any) will still be picked up below.\n scheduled = [];\n }\n }\n\n if (pendingTasks !== null) {\n // A new plan arrived while this task was running — switch to it.\n scheduled = pendingTasks;\n pendingTasks = null;\n abortController = new AbortController();\n } else if (abortController.signal.aborted) {\n // Aborted with no replacement plan — stop.\n break;\n }\n }\n\n abortController = null;\n running = false;\n };\n\n return {\n send(message: SegmentLoaderMessage) {\n if (destroyed) return;\n\n const allTasks = planTasks(message);\n\n if (!running) {\n if (allTasks.length === 0) return;\n runScheduled(allTasks);\n return;\n }\n\n // Determine whether the in-flight operation is still needed for the new plan.\n const inFlightStillNeeded =\n (inFlightSegmentId !== null &&\n allTasks.some((t) => t.type === 'append-segment' && t.meta.id === inFlightSegmentId)) ||\n (inFlightInitTrackId !== null &&\n allTasks.some((t) => t.type === 'append-init' && t.meta.trackId === inFlightInitTrackId));\n\n if (inFlightStillNeeded) {\n // Continue: the in-flight operation covers something the new plan needs.\n // Queue everything except the in-flight item — it will complete on its own.\n pendingTasks = allTasks.filter(\n (t) =>\n !(t.type === 'append-segment' && t.meta.id === inFlightSegmentId) &&\n !(t.type === 'append-init' && t.meta.trackId === inFlightInitTrackId)\n );\n } else {\n // Preempt: in-flight work is not needed for the new plan. Abort and replan.\n pendingTasks = allTasks;\n abortController?.abort();\n }\n },\n\n destroy() {\n destroyed = true;\n abortController?.abort();\n },\n };\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"segment-loader-actor.js","names":[],"sources":["../../../../src/dom/features/segment-loader-actor.ts"],"sourcesContent":["import { calculateBackBufferFlushPoint } from '../../core/buffer/back-buffer';\nimport { calculateForwardFlushPoint, getSegmentsToLoad } from '../../core/buffer/forward-buffer';\nimport { createMachineActor, type HandlerContext, type MessageActor } from '../../core/create-machine-actor';\nimport { effect } from '../../core/signals/effect';\nimport { SerialRunner, Task } from '../../core/task';\nimport type { AddressableObject, AudioTrack, Segment, VideoTrack } from '../../core/types';\nimport type {\n AppendInitMessage,\n AppendSegmentMessage,\n RemoveMessage,\n SourceBufferActor,\n} from '../media/source-buffer-actor';\n\n// ============================================================================\n// BUFFER STATE TYPES\n// ============================================================================\n\n/**\n * Buffer state for a single SourceBuffer.\n * Tracks which init segment and media segments are loaded.\n */\nexport interface SourceBufferState {\n /** Track ID of the loaded init segment */\n initTrackId?: string;\n /** Loaded media segments (unordered - selectors derive ordering) */\n segments: Array<{ id: string; trackId: string }>;\n}\n\n/**\n * Buffer state for all SourceBuffers.\n */\nexport interface BufferState {\n video?: SourceBufferState;\n audio?: SourceBufferState;\n}\n\n// ============================================================================\n// MESSAGE PROTOCOL\n// ============================================================================\n\n/** Track types that have SourceBuffers (video and audio only). */\nexport type SegmentLoaderTrack = VideoTrack | AudioTrack;\n\n/**\n * Message sent to a SegmentLoaderActor.\n *\n * `range` is optional to distinguish loading modes:\n * - No range: load init segment only (metadata preload mode)\n * - With range: load init + all segments overlapping [start, end]\n *\n * `start` and `end` are raw time values — no segment snapping.\n * The actor maps them onto segment boundaries internally.\n */\nexport type SegmentLoaderMessage = {\n type: 'load';\n track: SegmentLoaderTrack;\n range?: { start: number; end: number };\n};\n\n// ============================================================================\n// LOAD TASK\n// ============================================================================\n\n/**\n * A LoadTask is the intent to perform one unit of SegmentLoader work.\n * Unlike SourceBufferMessage, fetch-based tasks carry a URL rather than\n * pre-fetched data — the runner fetches and appends them in sequence.\n *\n * Derived from SourceBufferMessage types by removing `data` and adding\n * a fetch URL via AddressableObject.\n *\n * @todo Rename — \"LoadTask\" risks confusion with the `Task` class used for\n * SourceBufferActor scheduling. These are closer to operation descriptors or\n * messages than tasks in that sense.\n */\nexport type LoadTask =\n | (Omit<AppendInitMessage, 'data'> & AddressableObject)\n | (Omit<AppendSegmentMessage, 'data'> & AddressableObject)\n | RemoveMessage;\n\n// ============================================================================\n// ACTOR INTERFACE\n// ============================================================================\n\n/** Finite states of the actor. */\nexport type SegmentLoaderActorState = 'idle' | 'loading' | 'destroyed';\n\n/** Non-finite (extended) data managed by the actor. */\nexport interface SegmentLoaderActorContext {\n /** Track ID of the init segment currently being fetched/appended, or null. */\n inFlightInitTrackId: string | null;\n /** Segment ID currently being fetched/appended, or null. */\n inFlightSegmentId: string | null;\n}\n\nexport type SegmentLoaderActor = MessageActor<SegmentLoaderActorState, SegmentLoaderActorContext, SegmentLoaderMessage>;\n\n// ============================================================================\n// HELPERS\n// ============================================================================\n\ntype FetchBytes = (\n addressable: AddressableObject,\n options?: RequestInit & { minChunkSize?: number }\n) => Promise<AsyncIterable<Uint8Array>>;\n\n/**\n * Resolves when the SourceBufferActor snapshot reaches 'idle'.\n * Rejects if the signal is aborted or the actor is destroyed.\n *\n * Used to sequence SourceBufferActor operations without awaiting send()\n * directly — send() is fire-and-forget; callers observe completion via\n * state transition.\n */\nfunction waitForIdle(snapshot: SourceBufferActor['snapshot'], signal: AbortSignal): Promise<void> {\n return new Promise((resolve, reject) => {\n if (snapshot.get().value === 'idle') {\n resolve();\n return;\n }\n if (snapshot.get().value === 'destroyed') {\n reject(new DOMException('Aborted', 'AbortError'));\n return;\n }\n if (signal.aborted) {\n reject(signal.reason);\n return;\n }\n\n let stop: (() => void) | undefined;\n\n const cleanup = (fn: () => void) => {\n stop?.();\n signal.removeEventListener('abort', onAbort);\n fn();\n };\n\n const onAbort = () => cleanup(() => reject(signal.reason));\n\n stop = effect(() => {\n const value = snapshot.get().value;\n if (value === 'idle') cleanup(resolve);\n else if (value === 'destroyed') cleanup(() => reject(new DOMException('Aborted', 'AbortError')));\n });\n\n signal.addEventListener('abort', onAbort, { once: true });\n });\n}\n\n// ============================================================================\n// LOAD TASK FACTORY\n// ============================================================================\n\ninterface LoadTaskOptions {\n getContext: () => SegmentLoaderActorContext;\n setContext: (ctx: SegmentLoaderActorContext) => void;\n fetchBytes: FetchBytes;\n sourceBufferActor: SourceBufferActor;\n}\n\n/**\n * Wraps a LoadTask descriptor into a Task that fetches (if needed) and\n * forwards to SourceBufferActor. Updates in-flight context around async\n * operations so the loading handler can make accurate continue/preempt\n * decisions at any point.\n */\nfunction makeLoadTask(\n op: LoadTask,\n { getContext, setContext, fetchBytes, sourceBufferActor }: LoadTaskOptions\n): Task<void> {\n return new Task(async (taskSignal) => {\n if (taskSignal.aborted) return;\n\n if (op.type === 'remove') {\n sourceBufferActor.send(op);\n await waitForIdle(sourceBufferActor.snapshot, taskSignal);\n return;\n }\n\n if (op.type === 'append-init') {\n setContext({ ...getContext(), inFlightInitTrackId: op.meta.trackId });\n try {\n // Init segments are small and need the full body before appending.\n // minChunkSize: Infinity accumulates all chunks into one before yielding.\n const data = await fetchBytes(op, { signal: taskSignal, minChunkSize: Infinity });\n if (!taskSignal.aborted) {\n sourceBufferActor.send({ type: 'append-init', data, meta: op.meta });\n await waitForIdle(sourceBufferActor.snapshot, taskSignal);\n }\n } finally {\n setContext({ ...getContext(), inFlightInitTrackId: null });\n }\n return;\n }\n\n // append-segment: await headers eagerly (starts the HTTP connection and\n // records the fetch in observers like tests), then pass the body stream\n // directly to the actor so chunks are appended as they arrive.\n setContext({ ...getContext(), inFlightSegmentId: op.meta.id });\n try {\n const stream = await fetchBytes(op, { signal: taskSignal });\n if (!taskSignal.aborted) {\n sourceBufferActor.send({ type: 'append-segment', data: stream, meta: op.meta });\n await waitForIdle(sourceBufferActor.snapshot, taskSignal);\n }\n } finally {\n setContext({ ...getContext(), inFlightSegmentId: null });\n }\n });\n}\n\n// ============================================================================\n// IMPLEMENTATION\n// ============================================================================\n\n/**\n * Creates a SegmentLoaderActor for one track type (video or audio).\n *\n * Receives load assignments via `send()` and owns all execution: planning,\n * removes, fetches, and appends. Coordinates with the SourceBufferActor for\n * all physical SourceBuffer operations.\n *\n * Planning (Cases 1–3) happens in the `load` handler on every incoming\n * message, producing an ordered LoadTask list. The runner drains that list\n * sequentially via SerialRunner. When a new message arrives mid-run, the\n * handler replans and either continues the in-flight operation (abortPending\n * + schedule new remainder) or preempts it (abortAll + cancel SourceBuffer\n * if needed + schedule new plan).\n *\n * @param sourceBufferActor - Shared SourceBufferActor reference (not owned)\n * @param fetchBytes - Tracked fetch closure (owns throughput sampling for segments).\n * Accepts an optional `minChunkSize` in options; init segments pass `Infinity`\n * so the entire body accumulates as one chunk before appending.\n */\nexport function createSegmentLoaderActor(\n sourceBufferActor: SourceBufferActor,\n fetchBytes: FetchBytes\n): SegmentLoaderActor {\n type UserState = Exclude<SegmentLoaderActorState, 'destroyed'>;\n type Ctx = HandlerContext<UserState, SegmentLoaderActorContext, () => SerialRunner>;\n\n const getBufferedSegments = (allSegments: readonly Segment[]): Segment[] => {\n // Exclude partial segments — they are still being streamed and must not be\n // treated as fully buffered for load planning or buffer window calculations.\n const bufferedIds = new Set(\n sourceBufferActor.snapshot\n .get()\n .context.segments.filter((s) => !s.partial)\n .map((s) => s.id)\n );\n return allSegments.filter((s) => bufferedIds.has(s.id));\n };\n\n /**\n * Translate a load message into an ordered LoadTask list based on committed\n * actor state. In-flight awareness is handled separately in the load handler.\n *\n * @todo Rename alongside LoadTask (e.g. planOps).\n *\n * Case 1 — Removes: forward and back buffer flush points, segment-aligned.\n * No flush on track switch: appending new content overwrites existing buffer\n * ranges, and the actor's time-aligned deduplication keeps the segment model\n * accurate as new segments arrive.\n *\n * Case 2 — Init: schedule if not yet committed for this track.\n *\n * Case 3 — Segments: all segments in the load window not yet committed.\n */\n const planTasks = (message: SegmentLoaderMessage): LoadTask[] => {\n const { track, range } = message;\n const actorCtx = sourceBufferActor.snapshot.get().context;\n const bufferedSegments = getBufferedSegments(track.segments);\n const currentTime = range?.start ?? 0;\n const tasks: LoadTask[] = [];\n\n // Case 1: Removes\n if (range) {\n const forwardFlushStart = calculateForwardFlushPoint(bufferedSegments, currentTime);\n if (forwardFlushStart < Infinity) {\n tasks.push({ type: 'remove', start: forwardFlushStart, end: Infinity });\n }\n const backFlushEnd = calculateBackBufferFlushPoint(bufferedSegments, currentTime);\n if (backFlushEnd > 0) {\n tasks.push({ type: 'remove', start: 0, end: backFlushEnd });\n }\n }\n\n // Case 2: Init\n if (actorCtx.initTrackId !== track.id) {\n tasks.push({\n type: 'append-init',\n meta: { trackId: track.id },\n url: track.initialization.url,\n ...(track.initialization.byteRange !== undefined && { byteRange: track.initialization.byteRange }),\n });\n }\n\n // Case 3: Segments\n if (range) {\n const EPSILON = 0.0001;\n const segmentsToLoad = getSegmentsToLoad(track.segments, bufferedSegments, currentTime).filter((seg) => {\n // Quality-aware filter: skip segments already covered by equal-or-higher-quality\n // content in the actor context. Preserves buffered high-quality content during\n // ABR downgrades; loads during upgrades and for uncovered positions.\n const existing = actorCtx.segments.find((s) => Math.abs(s.startTime - seg.startTime) < EPSILON);\n // Partial segments are still streaming — treat as not buffered so they\n // are always re-planned (avoids relying on incomplete data).\n if (existing?.partial) return true;\n if (!existing?.trackBandwidth || !track.bandwidth) return true;\n return track.bandwidth > existing.trackBandwidth;\n });\n for (const segment of segmentsToLoad) {\n tasks.push({\n type: 'append-segment',\n meta: {\n id: segment.id,\n startTime: segment.startTime,\n duration: segment.duration,\n trackId: track.id,\n trackBandwidth: track.bandwidth,\n },\n url: segment.url,\n ...(segment.byteRange !== undefined && { byteRange: segment.byteRange }),\n });\n }\n }\n\n return tasks;\n };\n\n const scheduleAll = (tasks: LoadTask[], { getContext, setContext, runner }: Ctx): void => {\n tasks.forEach((op) => {\n runner\n .schedule(makeLoadTask(op, { getContext, setContext, fetchBytes, sourceBufferActor }))\n .then(undefined, (e: unknown) => {\n if (e instanceof Error && e.name === 'AbortError') return;\n // On unexpected fetch/append errors, abort remaining tasks so a failed\n // init doesn't cause segment fetches to proceed with no init segment.\n console.error('Unexpected error in segment loader:', e);\n runner.abortPending();\n });\n });\n };\n\n return createMachineActor<UserState, SegmentLoaderActorContext, SegmentLoaderMessage, () => SerialRunner>({\n runner: () => new SerialRunner(),\n initial: 'idle',\n context: { inFlightInitTrackId: null, inFlightSegmentId: null },\n states: {\n idle: {\n on: {\n load: (msg, ctx) => {\n const allTasks = planTasks(msg);\n if (allTasks.length === 0) return;\n ctx.transition('loading');\n scheduleAll(allTasks, ctx);\n },\n },\n },\n loading: {\n onSettled: 'idle',\n on: {\n load: (msg, ctx) => {\n const { context, runner } = ctx;\n const allTasks = planTasks(msg);\n\n // Determine whether the in-flight operation is still needed.\n const inFlightStillNeeded =\n (context.inFlightSegmentId !== null &&\n allTasks.some((t) => t.type === 'append-segment' && t.meta.id === context.inFlightSegmentId)) ||\n (context.inFlightInitTrackId !== null &&\n allTasks.some((t) => t.type === 'append-init' && t.meta.trackId === context.inFlightInitTrackId));\n\n if (inFlightStillNeeded) {\n // Continue: abort only the pending queue, let the in-flight task finish.\n // Schedule everything except the in-flight item — it covers that slot.\n runner.abortPending();\n scheduleAll(\n allTasks.filter(\n (t) =>\n !(t.type === 'append-segment' && t.meta.id === context.inFlightSegmentId) &&\n !(t.type === 'append-init' && t.meta.trackId === context.inFlightInitTrackId)\n ),\n ctx\n );\n } else {\n // Preempt: abort everything and replan.\n runner.abortAll();\n // Cancel SourceBufferActor tasks when a segment is in-flight (always\n // discard) or when a track switch is happening (new track's init follows).\n // For a same-track seek with an in-flight init, skip cancel — the task's\n // signal is not aborted (abortAll was called on the runner, but the init\n // task already completed or will complete via its own signal path).\n const cancelSourceBuffer =\n context.inFlightSegmentId !== null ||\n (context.inFlightInitTrackId !== null &&\n allTasks.some((t) => t.type === 'append-init' && t.meta.trackId !== context.inFlightInitTrackId));\n if (cancelSourceBuffer) {\n sourceBufferActor.send({ type: 'cancel' });\n }\n scheduleAll(allTasks, ctx);\n }\n },\n },\n },\n },\n });\n}\n"],"mappings":"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"}
@@ -0,0 +1,95 @@
1
+ import { computed, untrack, update } from "../../core/signals/primitives.js";
2
+ import { createMachineReactor } from "../../core/create-machine-reactor.js";
3
+ import { listen } from "@videojs/utils/dom";
4
+ //#region src/dom/features/sync-text-tracks.ts
5
+ function createTrackElement(track) {
6
+ const el = document.createElement("track");
7
+ el.id = track.id;
8
+ el.kind = track.kind;
9
+ el.label = track.label;
10
+ el.toggleAttribute("data-src-track", true);
11
+ if (track.language) el.srclang = track.language;
12
+ if (track.default) el.default = true;
13
+ return el;
14
+ }
15
+ function getModelTextTracks(presentation) {
16
+ return presentation?.selectionSets?.find((s) => s.type === "text")?.switchingSets[0]?.tracks;
17
+ }
18
+ function syncModes(textTracks, selectedId) {
19
+ for (let i = 0; i < textTracks.length; i++) {
20
+ const track = textTracks[i];
21
+ if (track.kind !== "subtitles" && track.kind !== "captions") continue;
22
+ track.mode = track.id === selectedId ? "showing" : "disabled";
23
+ }
24
+ }
25
+ /**
26
+ * Text track sync orchestration.
27
+ *
28
+ * A single `always` monitor keeps the reactor in sync with preconditions.
29
+ * `'set-up'` owns the full lifecycle of `<track>` elements:
30
+ *
31
+ * - **Effect 1** — creates `<track>` elements on entry; exit cleanup removes
32
+ * them and clears `selectedTextTrackId` on any outbound transition.
33
+ * - **Effect 2** — owns mode sync, the Chromium settling-window guard, and
34
+ * the `'change'` listener that bridges DOM state back to
35
+ * `selectedTextTrackId`.
36
+ *
37
+ * @example
38
+ * const reactor = syncTextTracks({ state, owners });
39
+ * // later:
40
+ * reactor.destroy();
41
+ */
42
+ function syncTextTracks({ state, owners }) {
43
+ const mediaElementSignal = computed(() => owners.get().mediaElement);
44
+ const modelTextTracksSignal = computed(() => getModelTextTracks(state.get().presentation), { equals(prevTextTracks, nextTextTracks) {
45
+ if (prevTextTracks === nextTextTracks) return true;
46
+ if (typeof prevTextTracks !== typeof nextTextTracks) return false;
47
+ if (prevTextTracks?.length !== nextTextTracks?.length) return false;
48
+ return !!nextTextTracks && nextTextTracks.every((nextTextTrack) => prevTextTracks?.some((prevTextTrack) => prevTextTrack.id === nextTextTrack.id));
49
+ } });
50
+ const selectedTextTrackIdSignal = computed(() => state.get().selectedTextTrackId);
51
+ const preconditionsMetSignal = computed(() => !!mediaElementSignal.get() && !!modelTextTracksSignal.get()?.length);
52
+ return createMachineReactor({
53
+ initial: "preconditions-unmet",
54
+ monitor: () => preconditionsMetSignal.get() ? "set-up" : "preconditions-unmet",
55
+ states: {
56
+ "preconditions-unmet": {},
57
+ "set-up": {
58
+ entry: () => {
59
+ const mediaElement = mediaElementSignal.get();
60
+ modelTextTracksSignal.get().forEach((track) => mediaElement.appendChild(createTrackElement(track)));
61
+ return () => {
62
+ mediaElement.querySelectorAll("track[data-src-track]:is([kind=\"subtitles\"],[kind=\"captions\"]").forEach((trackEl) => trackEl.remove());
63
+ update(state, { selectedTextTrackId: void 0 });
64
+ };
65
+ },
66
+ effects: () => {
67
+ const mediaElement = untrack(() => mediaElementSignal.get());
68
+ const selectedId = selectedTextTrackIdSignal.get();
69
+ syncModes(mediaElement.textTracks, selectedId);
70
+ let syncTimeout = setTimeout(() => {
71
+ syncTimeout = void 0;
72
+ }, 0);
73
+ const onChange = () => {
74
+ if (syncTimeout) {
75
+ syncModes(mediaElement.textTracks, untrack(() => selectedTextTrackIdSignal.get()));
76
+ return;
77
+ }
78
+ const newId = Array.from(mediaElement.textTracks).find((t) => t.mode === "showing" && (t.kind === "subtitles" || t.kind === "captions"))?.id;
79
+ if (newId === untrack(() => selectedTextTrackIdSignal.get())) return;
80
+ update(state, { selectedTextTrackId: newId });
81
+ };
82
+ const unlisten = listen(mediaElement.textTracks, "change", onChange);
83
+ return () => {
84
+ clearTimeout(syncTimeout ?? void 0);
85
+ unlisten();
86
+ };
87
+ }
88
+ }
89
+ }
90
+ });
91
+ }
92
+ //#endregion
93
+ export { syncTextTracks };
94
+
95
+ //# sourceMappingURL=sync-text-tracks.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sync-text-tracks.js","names":[],"sources":["../../../../src/dom/features/sync-text-tracks.ts"],"sourcesContent":["import { listen } from '@videojs/utils/dom';\nimport type { Reactor } from '../../core/create-machine-reactor';\nimport { createMachineReactor } from '../../core/create-machine-reactor';\nimport { computed, type Signal, untrack, update } from '../../core/signals/primitives';\nimport type { PartiallyResolvedTextTrack, Presentation, TextTrack } from '../../core/types';\n\n/**\n * FSM states for text track sync.\n *\n * ```\n * 'preconditions-unmet' ──── mediaElement + tracks available ────→ 'set-up'\n * ↑ |\n * └──────────────── preconditions lost (exit cleanup) ──────────┘\n *\n * any state ──── destroy() ────→ 'destroying' ────→ 'destroyed'\n * ```\n */\n/**\n * State shape for text track sync.\n */\nexport interface TextTrackSyncState {\n presentation?: Presentation | undefined;\n selectedTextTrackId?: string | undefined;\n}\n\n/**\n * Owners shape for text track sync.\n */\nexport interface TextTrackSyncOwners {\n mediaElement?: HTMLMediaElement | undefined;\n}\n\n// ============================================================================\n// Helpers\n// ============================================================================\n\nfunction createTrackElement(track: PartiallyResolvedTextTrack | TextTrack): HTMLTrackElement {\n const el = document.createElement('track');\n el.id = track.id;\n el.kind = track.kind;\n el.label = track.label;\n el.toggleAttribute('data-src-track', true);\n if (track.language) el.srclang = track.language;\n if (track.default) el.default = true;\n return el;\n}\n\nfunction getModelTextTracks(\n presentation: Presentation | undefined\n): (PartiallyResolvedTextTrack | TextTrack)[] | undefined {\n return presentation?.selectionSets?.find((s) => s.type === 'text')?.switchingSets[0]?.tracks;\n}\n\nfunction syncModes(textTracks: TextTrackList, selectedId: string | undefined): void {\n for (let i = 0; i < textTracks.length; i++) {\n const track = textTracks[i]!;\n if (track.kind !== 'subtitles' && track.kind !== 'captions') continue;\n track.mode = track.id === selectedId ? 'showing' : 'disabled';\n }\n}\n\n// ============================================================================\n// Main export\n// ============================================================================\n\n/**\n * Text track sync orchestration.\n *\n * A single `always` monitor keeps the reactor in sync with preconditions.\n * `'set-up'` owns the full lifecycle of `<track>` elements:\n *\n * - **Effect 1** — creates `<track>` elements on entry; exit cleanup removes\n * them and clears `selectedTextTrackId` on any outbound transition.\n * - **Effect 2** — owns mode sync, the Chromium settling-window guard, and\n * the `'change'` listener that bridges DOM state back to\n * `selectedTextTrackId`.\n *\n * @example\n * const reactor = syncTextTracks({ state, owners });\n * // later:\n * reactor.destroy();\n */\nexport function syncTextTracks<S extends TextTrackSyncState, O extends TextTrackSyncOwners>({\n state,\n owners,\n}: {\n state: Signal<S>;\n owners: Signal<O>;\n}): Reactor<'preconditions-unmet' | 'set-up' | 'destroying' | 'destroyed'> {\n const mediaElementSignal = computed(() => owners.get().mediaElement);\n const modelTextTracksSignal = computed(() => getModelTextTracks(state.get().presentation), {\n /** @TODO Make generic and abstract away for Array<T> | undefined (CJP) */\n equals(prevTextTracks, nextTextTracks) {\n if (prevTextTracks === nextTextTracks) return true;\n if (typeof prevTextTracks !== typeof nextTextTracks) return false;\n if (prevTextTracks?.length !== nextTextTracks?.length) return false;\n // NOTE: This could probably be optimized, but the set should generally be small (CJP)\n return (\n !!nextTextTracks &&\n nextTextTracks.every((nextTextTrack) =>\n prevTextTracks?.some((prevTextTrack) => prevTextTrack.id === nextTextTrack.id)\n )\n );\n },\n });\n const selectedTextTrackIdSignal = computed(() => state.get().selectedTextTrackId);\n const preconditionsMetSignal = computed(() => !!mediaElementSignal.get() && !!modelTextTracksSignal.get()?.length);\n\n return createMachineReactor<'preconditions-unmet' | 'set-up'>({\n initial: 'preconditions-unmet',\n monitor: () => (preconditionsMetSignal.get() ? 'set-up' : 'preconditions-unmet'),\n states: {\n 'preconditions-unmet': {},\n\n 'set-up': {\n // Entry: create <track> elements once on state entry; exit cleanup removes\n // them and clears selectedTextTrackId on any outbound transition.\n // The fn body is automatically untracked — no untrack() needed.\n entry: () => {\n const mediaElement = mediaElementSignal.get() as HTMLMediaElement;\n const modelTextTracks = modelTextTracksSignal.get() as PartiallyResolvedTextTrack[];\n modelTextTracks.forEach((track) => mediaElement.appendChild(createTrackElement(track)));\n return () => {\n mediaElement\n .querySelectorAll('track[data-src-track]:is([kind=\"subtitles\"],[kind=\"captions\"]')\n .forEach((trackEl) => trackEl.remove());\n update(state, { selectedTextTrackId: undefined } as Partial<S>);\n };\n },\n\n // Reaction: mode sync + DOM change listener; re-runs when selectedId changes.\n // mediaElement is read with untrack() since element changes go through the\n // always monitor (preconditions-unmet path), not this effect.\n effects: () => {\n const mediaElement = untrack(() => mediaElementSignal.get() as HTMLMediaElement);\n const selectedId = selectedTextTrackIdSignal.get();\n\n syncModes(mediaElement.textTracks, selectedId);\n let syncTimeout: ReturnType<typeof setTimeout> | undefined = setTimeout(() => {\n syncTimeout = undefined;\n }, 0);\n\n const onChange = () => {\n if (syncTimeout) {\n // Inside the settling window: browser auto-selection is overriding our\n // modes. Re-apply to restore the intended state without touching state.\n // change events are queued as tasks (async), so no re-entrancy risk.\n syncModes(\n mediaElement.textTracks,\n untrack(() => selectedTextTrackIdSignal.get())\n );\n return;\n }\n\n const showingTrack = Array.from(mediaElement.textTracks).find(\n (t) => t.mode === 'showing' && (t.kind === 'subtitles' || t.kind === 'captions')\n );\n\n // showingTrack.id matches the SPF track ID set by createTrackElement above.\n // Fall back to undefined for empty-string IDs (non-SPF-managed tracks).\n const newId = showingTrack?.id;\n const currentModelId = untrack(() => selectedTextTrackIdSignal.get());\n if (newId === currentModelId) return;\n update(state, { selectedTextTrackId: newId } as Partial<S>);\n };\n\n const unlisten = listen(mediaElement.textTracks, 'change', onChange);\n\n return () => {\n clearTimeout(syncTimeout ?? undefined);\n unlisten();\n };\n },\n },\n },\n });\n}\n"],"mappings":";;;;AAoCA,SAAS,mBAAmB,OAAiE;CAC3F,MAAM,KAAK,SAAS,cAAc,QAAQ;AAC1C,IAAG,KAAK,MAAM;AACd,IAAG,OAAO,MAAM;AAChB,IAAG,QAAQ,MAAM;AACjB,IAAG,gBAAgB,kBAAkB,KAAK;AAC1C,KAAI,MAAM,SAAU,IAAG,UAAU,MAAM;AACvC,KAAI,MAAM,QAAS,IAAG,UAAU;AAChC,QAAO;;AAGT,SAAS,mBACP,cACwD;AACxD,QAAO,cAAc,eAAe,MAAM,MAAM,EAAE,SAAS,OAAO,EAAE,cAAc,IAAI;;AAGxF,SAAS,UAAU,YAA2B,YAAsC;AAClF,MAAK,IAAI,IAAI,GAAG,IAAI,WAAW,QAAQ,KAAK;EAC1C,MAAM,QAAQ,WAAW;AACzB,MAAI,MAAM,SAAS,eAAe,MAAM,SAAS,WAAY;AAC7D,QAAM,OAAO,MAAM,OAAO,aAAa,YAAY;;;;;;;;;;;;;;;;;;;;AAyBvD,SAAgB,eAA4E,EAC1F,OACA,UAIyE;CACzE,MAAM,qBAAqB,eAAe,OAAO,KAAK,CAAC,aAAa;CACpE,MAAM,wBAAwB,eAAe,mBAAmB,MAAM,KAAK,CAAC,aAAa,EAAE,EAEzF,OAAO,gBAAgB,gBAAgB;AACrC,MAAI,mBAAmB,eAAgB,QAAO;AAC9C,MAAI,OAAO,mBAAmB,OAAO,eAAgB,QAAO;AAC5D,MAAI,gBAAgB,WAAW,gBAAgB,OAAQ,QAAO;AAE9D,SACE,CAAC,CAAC,kBACF,eAAe,OAAO,kBACpB,gBAAgB,MAAM,kBAAkB,cAAc,OAAO,cAAc,GAAG,CAC/E;IAGN,CAAC;CACF,MAAM,4BAA4B,eAAe,MAAM,KAAK,CAAC,oBAAoB;CACjF,MAAM,yBAAyB,eAAe,CAAC,CAAC,mBAAmB,KAAK,IAAI,CAAC,CAAC,sBAAsB,KAAK,EAAE,OAAO;AAElH,QAAO,qBAAuD;EAC5D,SAAS;EACT,eAAgB,uBAAuB,KAAK,GAAG,WAAW;EAC1D,QAAQ;GACN,uBAAuB,EAAE;GAEzB,UAAU;IAIR,aAAa;KACX,MAAM,eAAe,mBAAmB,KAAK;AACrB,2BAAsB,KAAK,CACnC,SAAS,UAAU,aAAa,YAAY,mBAAmB,MAAM,CAAC,CAAC;AACvF,kBAAa;AACX,mBACG,iBAAiB,oEAAgE,CACjF,SAAS,YAAY,QAAQ,QAAQ,CAAC;AACzC,aAAO,OAAO,EAAE,qBAAqB,KAAA,GAAW,CAAe;;;IAOnE,eAAe;KACb,MAAM,eAAe,cAAc,mBAAmB,KAAK,CAAqB;KAChF,MAAM,aAAa,0BAA0B,KAAK;AAElD,eAAU,aAAa,YAAY,WAAW;KAC9C,IAAI,cAAyD,iBAAiB;AAC5E,oBAAc,KAAA;QACb,EAAE;KAEL,MAAM,iBAAiB;AACrB,UAAI,aAAa;AAIf,iBACE,aAAa,YACb,cAAc,0BAA0B,KAAK,CAAC,CAC/C;AACD;;MASF,MAAM,QANe,MAAM,KAAK,aAAa,WAAW,CAAC,MACtD,MAAM,EAAE,SAAS,cAAc,EAAE,SAAS,eAAe,EAAE,SAAS,YACtE,EAI2B;AAE5B,UAAI,UADmB,cAAc,0BAA0B,KAAK,CAAC,CACvC;AAC9B,aAAO,OAAO,EAAE,qBAAqB,OAAO,CAAe;;KAG7D,MAAM,WAAW,OAAO,aAAa,YAAY,UAAU,SAAS;AAEpE,kBAAa;AACX,mBAAa,eAAe,KAAA,EAAU;AACtC,gBAAU;;;IAGf;GACF;EACF,CAAC"}
@@ -0,0 +1,12 @@
1
+ import { TextTrack } from "../../core/types/index.js";
2
+ import { CallbackActor } from "../../core/actor.js";
3
+ //#region src/dom/features/text-track-segment-loader-actor.d.ts
4
+ type TextTrackSegmentLoaderMessage = {
5
+ type: 'load';
6
+ track: TextTrack;
7
+ currentTime: number;
8
+ };
9
+ type TextTrackSegmentLoaderActor = CallbackActor<TextTrackSegmentLoaderMessage>;
10
+ //#endregion
11
+ export { TextTrackSegmentLoaderActor };
12
+ //# sourceMappingURL=text-track-segment-loader-actor.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"text-track-segment-loader-actor.d.ts","names":[],"sources":["../../../../src/dom/features/text-track-segment-loader-actor.ts"],"mappings":";;;KAYY,6BAAA;EACV,IAAA;EACA,KAAA,EAAO,SAAA;EACP,WAAA;AAAA;AAAA,KAGU,2BAAA,GAA8B,aAAA,CAAc,6BAAA"}
@@ -0,0 +1,55 @@
1
+ import { getSegmentsToLoad } from "../../core/buffer/forward-buffer.js";
2
+ import { untrack } from "../../core/signals/primitives.js";
3
+ import { SerialRunner, Task } from "../../core/task.js";
4
+ import { parseVttSegment } from "../text/parse-vtt-segment.js";
5
+ //#region src/dom/features/text-track-segment-loader-actor.ts
6
+ /**
7
+ * Loads VTT segments for a text track and delegates cue management to a
8
+ * TextTracksActor. Mirrors the SegmentLoaderActor/SourceBufferActor pattern
9
+ * for the text track equivalent.
10
+ *
11
+ * Planning is done in the load handler: segments already recorded in
12
+ * TextTracksActor's context are skipped. Each load preempts in-flight work
13
+ * via abortAll() before scheduling fresh tasks.
14
+ */
15
+ function createTextTrackSegmentLoaderActor(textTracksActor) {
16
+ const runner = new SerialRunner();
17
+ let destroyed = false;
18
+ return {
19
+ send({ track, currentTime }) {
20
+ if (destroyed) return;
21
+ const trackId = track.id;
22
+ const bufferedSegments = untrack(() => textTracksActor.snapshot.get().context.segments[trackId] ?? []);
23
+ const segmentsToLoad = getSegmentsToLoad(track.segments, bufferedSegments, currentTime);
24
+ runner.abortAll();
25
+ for (const segment of segmentsToLoad) runner.schedule(new Task(async (signal) => {
26
+ if (signal.aborted) return;
27
+ try {
28
+ const cues = await parseVttSegment(segment.url);
29
+ if (signal.aborted) return;
30
+ textTracksActor.send({
31
+ type: "add-cues",
32
+ meta: {
33
+ trackId,
34
+ id: segment.id,
35
+ startTime: segment.startTime,
36
+ duration: segment.duration
37
+ },
38
+ cues
39
+ });
40
+ } catch (error) {
41
+ console.error("Failed to load VTT segment:", error);
42
+ }
43
+ }));
44
+ },
45
+ destroy() {
46
+ if (destroyed) return;
47
+ destroyed = true;
48
+ runner.destroy();
49
+ }
50
+ };
51
+ }
52
+ //#endregion
53
+ export { createTextTrackSegmentLoaderActor };
54
+
55
+ //# sourceMappingURL=text-track-segment-loader-actor.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"text-track-segment-loader-actor.js","names":[],"sources":["../../../../src/dom/features/text-track-segment-loader-actor.ts"],"sourcesContent":["import type { CallbackActor } from '../../core/actor';\nimport { getSegmentsToLoad } from '../../core/buffer/forward-buffer';\nimport { untrack } from '../../core/signals/primitives';\nimport { SerialRunner, Task } from '../../core/task';\nimport type { TextTrack } from '../../core/types';\nimport { parseVttSegment } from '../text/parse-vtt-segment';\nimport type { TextTracksActor } from './text-tracks-actor';\n\n// =============================================================================\n// Types\n// =============================================================================\n\nexport type TextTrackSegmentLoaderMessage = {\n type: 'load';\n track: TextTrack;\n currentTime: number;\n};\n\nexport type TextTrackSegmentLoaderActor = CallbackActor<TextTrackSegmentLoaderMessage>;\n\n// =============================================================================\n// Implementation\n// =============================================================================\n\n/**\n * Loads VTT segments for a text track and delegates cue management to a\n * TextTracksActor. Mirrors the SegmentLoaderActor/SourceBufferActor pattern\n * for the text track equivalent.\n *\n * Planning is done in the load handler: segments already recorded in\n * TextTracksActor's context are skipped. Each load preempts in-flight work\n * via abortAll() before scheduling fresh tasks.\n */\nexport function createTextTrackSegmentLoaderActor(textTracksActor: TextTracksActor): TextTrackSegmentLoaderActor {\n const runner = new SerialRunner();\n let destroyed = false;\n\n return {\n send({ track, currentTime }: TextTrackSegmentLoaderMessage): void {\n if (destroyed) return;\n const trackId = track.id;\n const bufferedSegments = untrack(() => textTracksActor.snapshot.get().context.segments[trackId] ?? []);\n const segmentsToLoad = getSegmentsToLoad(track.segments, bufferedSegments, currentTime);\n\n runner.abortAll();\n for (const segment of segmentsToLoad) {\n runner.schedule(\n new Task(async (signal) => {\n if (signal.aborted) return;\n try {\n const cues = await parseVttSegment(segment.url);\n if (signal.aborted) return;\n textTracksActor.send({\n type: 'add-cues',\n meta: { trackId, id: segment.id, startTime: segment.startTime, duration: segment.duration },\n cues,\n });\n } catch (error) {\n // Graceful degradation: log and continue to the next segment.\n console.error('Failed to load VTT segment:', error);\n }\n })\n );\n }\n },\n\n destroy(): void {\n if (destroyed) return;\n destroyed = true;\n runner.destroy();\n },\n };\n}\n"],"mappings":";;;;;;;;;;;;;;AAiCA,SAAgB,kCAAkC,iBAA+D;CAC/G,MAAM,SAAS,IAAI,cAAc;CACjC,IAAI,YAAY;AAEhB,QAAO;EACL,KAAK,EAAE,OAAO,eAAoD;AAChE,OAAI,UAAW;GACf,MAAM,UAAU,MAAM;GACtB,MAAM,mBAAmB,cAAc,gBAAgB,SAAS,KAAK,CAAC,QAAQ,SAAS,YAAY,EAAE,CAAC;GACtG,MAAM,iBAAiB,kBAAkB,MAAM,UAAU,kBAAkB,YAAY;AAEvF,UAAO,UAAU;AACjB,QAAK,MAAM,WAAW,eACpB,QAAO,SACL,IAAI,KAAK,OAAO,WAAW;AACzB,QAAI,OAAO,QAAS;AACpB,QAAI;KACF,MAAM,OAAO,MAAM,gBAAgB,QAAQ,IAAI;AAC/C,SAAI,OAAO,QAAS;AACpB,qBAAgB,KAAK;MACnB,MAAM;MACN,MAAM;OAAE;OAAS,IAAI,QAAQ;OAAI,WAAW,QAAQ;OAAW,UAAU,QAAQ;OAAU;MAC3F;MACD,CAAC;aACK,OAAO;AAEd,aAAQ,MAAM,+BAA+B,MAAM;;KAErD,CACH;;EAIL,UAAgB;AACd,OAAI,UAAW;AACf,eAAY;AACZ,UAAO,SAAS;;EAEnB"}
@@ -0,0 +1,31 @@
1
+ import { Segment } from "../../core/types/index.js";
2
+ import { TransitionActor } from "../../core/create-transition-actor.js";
3
+
4
+ //#region src/dom/features/text-tracks-actor.d.ts
5
+ /** Minimal cue record — enough for deduplication and snapshot observability. */
6
+ interface CueRecord {
7
+ startTime: number;
8
+ endTime: number;
9
+ text: string;
10
+ }
11
+ /** Segment identity and timing — mirrors AppendSegmentMeta without trackId (keyed separately). */
12
+ type CueSegmentMeta = Pick<Segment, 'id' | 'startTime' | 'duration'> & {
13
+ trackId: string;
14
+ };
15
+ /** Non-finite (extended) data managed by the actor — the XState "context". */
16
+ interface TextTracksActorContext {
17
+ /** Cues added per track ID. Used for duplicate detection and snapshot observability. */
18
+ loaded: Record<string, CueRecord[]>;
19
+ /** Segments whose cues have been fully added, keyed by track ID. Used for load planning. */
20
+ segments: Record<string, Array<Pick<Segment, 'id' | 'startTime' | 'duration'>>>;
21
+ }
22
+ type AddCuesMessage = {
23
+ type: 'add-cues';
24
+ meta: CueSegmentMeta;
25
+ cues: VTTCue[];
26
+ };
27
+ type TextTracksActorMessage = AddCuesMessage;
28
+ type TextTracksActor = TransitionActor<TextTracksActorContext, TextTracksActorMessage>;
29
+ //#endregion
30
+ export { TextTracksActor };
31
+ //# sourceMappingURL=text-tracks-actor.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"text-tracks-actor.d.ts","names":[],"sources":["../../../../src/dom/features/text-tracks-actor.ts"],"mappings":";;;;;UASiB,SAAA;EACf,SAAA;EACA,OAAA;EACA,IAAA;AAAA;;KAIU,cAAA,GAAiB,IAAA,CAAK,OAAA;EAA8C,OAAA;AAAA;;UAG/D,sBAAA;EAHS;EAKxB,MAAA,EAAQ,MAAA,SAAe,SAAA;EALQ;EAO/B,QAAA,EAAU,MAAA,SAAe,KAAA,CAAM,IAAA,CAAK,OAAA;AAAA;AAAA,KAG1B,cAAA;EAAmB,IAAA;EAAkB,IAAA,EAAM,cAAA;EAAgB,IAAA,EAAM,MAAA;AAAA;AAAA,KACjE,sBAAA,GAAyB,cAAA;AAAA,KAEzB,eAAA,GAAkB,eAAA,CAAgB,sBAAA,EAAwB,sBAAA"}
@@ -0,0 +1,42 @@
1
+ import { createTransitionActor } from "../../core/create-transition-actor.js";
2
+ //#region src/dom/features/text-tracks-actor.ts
3
+ function isDuplicateCue(cue, existing) {
4
+ return existing.some((r) => r.startTime === cue.startTime && r.endTime === cue.endTime && r.text === cue.text);
5
+ }
6
+ /** TextTrack actor: wraps all text tracks on a media element, owns cue operations. */
7
+ function createTextTracksActor(mediaElement) {
8
+ return createTransitionActor({
9
+ loaded: {},
10
+ segments: {}
11
+ }, (context, message) => {
12
+ const { meta, cues } = message;
13
+ const { trackId, id: segmentId, startTime, duration } = meta;
14
+ const textTrack = Array.from(mediaElement.textTracks).find((t) => t.id === trackId);
15
+ if (!textTrack) return context;
16
+ const existingCues = context.loaded[trackId] ?? [];
17
+ const existingSegments = context.segments[trackId] ?? [];
18
+ const prunedCues = cues.filter((cue) => !isDuplicateCue(cue, existingCues));
19
+ const segmentAlreadyLoaded = existingSegments.some((s) => s.id === segmentId);
20
+ if (prunedCues.length === 0 && segmentAlreadyLoaded) return context;
21
+ for (const cue of prunedCues) textTrack.addCue(cue);
22
+ return {
23
+ ...context,
24
+ loaded: {
25
+ ...context.loaded,
26
+ [trackId]: [...existingCues, ...prunedCues]
27
+ },
28
+ segments: segmentAlreadyLoaded ? context.segments : {
29
+ ...context.segments,
30
+ [trackId]: [...existingSegments, {
31
+ id: segmentId,
32
+ startTime,
33
+ duration
34
+ }]
35
+ }
36
+ };
37
+ });
38
+ }
39
+ //#endregion
40
+ export { createTextTracksActor };
41
+
42
+ //# sourceMappingURL=text-tracks-actor.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"text-tracks-actor.js","names":[],"sources":["../../../../src/dom/features/text-tracks-actor.ts"],"sourcesContent":["import type { TransitionActor } from '../../core/create-transition-actor';\nimport { createTransitionActor } from '../../core/create-transition-actor';\nimport type { Segment } from '../../core/types';\n\n// =============================================================================\n// Types\n// =============================================================================\n\n/** Minimal cue record — enough for deduplication and snapshot observability. */\nexport interface CueRecord {\n startTime: number;\n endTime: number;\n text: string;\n}\n\n/** Segment identity and timing — mirrors AppendSegmentMeta without trackId (keyed separately). */\nexport type CueSegmentMeta = Pick<Segment, 'id' | 'startTime' | 'duration'> & { trackId: string };\n\n/** Non-finite (extended) data managed by the actor — the XState \"context\". */\nexport interface TextTracksActorContext {\n /** Cues added per track ID. Used for duplicate detection and snapshot observability. */\n loaded: Record<string, CueRecord[]>;\n /** Segments whose cues have been fully added, keyed by track ID. Used for load planning. */\n segments: Record<string, Array<Pick<Segment, 'id' | 'startTime' | 'duration'>>>;\n}\n\nexport type AddCuesMessage = { type: 'add-cues'; meta: CueSegmentMeta; cues: VTTCue[] };\nexport type TextTracksActorMessage = AddCuesMessage;\n\nexport type TextTracksActor = TransitionActor<TextTracksActorContext, TextTracksActorMessage>;\n\n// =============================================================================\n// Helpers\n// =============================================================================\n\nfunction isDuplicateCue(cue: VTTCue, existing: CueRecord[]): boolean {\n return existing.some((r) => r.startTime === cue.startTime && r.endTime === cue.endTime && r.text === cue.text);\n}\n\n// =============================================================================\n// Implementation\n// =============================================================================\n\n/** TextTrack actor: wraps all text tracks on a media element, owns cue operations. */\nexport function createTextTracksActor(mediaElement: HTMLMediaElement): TextTracksActor {\n return createTransitionActor({ loaded: {}, segments: {} } as TextTracksActorContext, (context, message) => {\n // NOTE: Currently assumes cues are applied to a non-disabled TextTrack. Discuss different approaches here, including:\n // - Making the message responsible for auto-selection of the textTrack (changes logic in sync-text-tracks)\n // - Silent gating/console warning + early bail\n // - throwing a domain-specific error\n // - accepting as is (which would result in errors, but also \"shouldn't ever happen\" unless a bug is introduced)\n // (CJP)\n const { meta, cues } = message;\n const { trackId, id: segmentId, startTime, duration } = meta;\n const textTrack = Array.from(mediaElement.textTracks).find((t) => t.id === trackId);\n if (!textTrack) return context;\n\n const existingCues = context.loaded[trackId] ?? [];\n const existingSegments = context.segments[trackId] ?? [];\n const prunedCues = cues.filter((cue) => !isDuplicateCue(cue, existingCues));\n const segmentAlreadyLoaded = existingSegments.some((s) => s.id === segmentId);\n\n if (prunedCues.length === 0 && segmentAlreadyLoaded) return context;\n\n for (const cue of prunedCues) textTrack.addCue(cue);\n return {\n ...context,\n loaded: {\n ...context.loaded,\n [trackId]: [...existingCues, ...prunedCues],\n },\n segments: segmentAlreadyLoaded\n ? context.segments\n : {\n ...context.segments,\n [trackId]: [...existingSegments, { id: segmentId, startTime, duration }],\n },\n };\n });\n}\n"],"mappings":";;AAmCA,SAAS,eAAe,KAAa,UAAgC;AACnE,QAAO,SAAS,MAAM,MAAM,EAAE,cAAc,IAAI,aAAa,EAAE,YAAY,IAAI,WAAW,EAAE,SAAS,IAAI,KAAK;;;AAQhH,SAAgB,sBAAsB,cAAiD;AACrF,QAAO,sBAAsB;EAAE,QAAQ,EAAE;EAAE,UAAU,EAAE;EAAE,GAA6B,SAAS,YAAY;EAOzG,MAAM,EAAE,MAAM,SAAS;EACvB,MAAM,EAAE,SAAS,IAAI,WAAW,WAAW,aAAa;EACxD,MAAM,YAAY,MAAM,KAAK,aAAa,WAAW,CAAC,MAAM,MAAM,EAAE,OAAO,QAAQ;AACnF,MAAI,CAAC,UAAW,QAAO;EAEvB,MAAM,eAAe,QAAQ,OAAO,YAAY,EAAE;EAClD,MAAM,mBAAmB,QAAQ,SAAS,YAAY,EAAE;EACxD,MAAM,aAAa,KAAK,QAAQ,QAAQ,CAAC,eAAe,KAAK,aAAa,CAAC;EAC3E,MAAM,uBAAuB,iBAAiB,MAAM,MAAM,EAAE,OAAO,UAAU;AAE7E,MAAI,WAAW,WAAW,KAAK,qBAAsB,QAAO;AAE5D,OAAK,MAAM,OAAO,WAAY,WAAU,OAAO,IAAI;AACnD,SAAO;GACL,GAAG;GACH,QAAQ;IACN,GAAG,QAAQ;KACV,UAAU,CAAC,GAAG,cAAc,GAAG,WAAW;IAC5C;GACD,UAAU,uBACN,QAAQ,WACR;IACE,GAAG,QAAQ;KACV,UAAU,CAAC,GAAG,kBAAkB;KAAE,IAAI;KAAW;KAAW;KAAU,CAAC;IACzE;GACN;GACD"}
@@ -1,4 +1,5 @@
1
1
  import { Signal } from "../../core/signals/primitives.js";
2
+ import { Reactor } from "../../core/create-machine-reactor.js";
2
3
 
3
4
  //#region src/dom/features/track-playback-initiated.d.ts
4
5
  /**
@@ -21,16 +22,17 @@ interface PlaybackInitiatedOwners {
21
22
  /**
22
23
  * Track whether playback has been initiated for the current presentation URL.
23
24
  *
24
- * Uses a local intermediate signal written by two effect streams:
25
- * - false stream: resets on URL change
26
- * - true stream: sets on play event
27
- *
28
- * A third merge effect reads the local signal and writes to state, reading
29
- * `state.get()` at merge time so the spread uses the up-to-date value after
30
- * the async forward bridge has run.
25
+ * A three-state Reactor FSM driven by `state.playbackInitiated` and the
26
+ * `deriveState` pattern:
27
+ * - `'preconditions-unmet'` no element or URL yet; no effects.
28
+ * - `'monitoring'` — checks `!el.paused` on entry; listens for `play`.
29
+ * - `'playback-initiated'` tracks element and URL; exit cleanup resets
30
+ * `state.playbackInitiated` to `false` on any change or lost preconditions.
31
31
  *
32
32
  * @example
33
- * const cleanup = trackPlaybackInitiated({ state, owners, events });
33
+ * const reactor = trackPlaybackInitiated({ state, owners });
34
+ * // later:
35
+ * reactor.destroy();
34
36
  */
35
37
  declare function trackPlaybackInitiated<S extends PlaybackInitiatedState, O extends PlaybackInitiatedOwners>({
36
38
  state,
@@ -38,7 +40,7 @@ declare function trackPlaybackInitiated<S extends PlaybackInitiatedState, O exte
38
40
  }: {
39
41
  state: Signal<S>;
40
42
  owners: Signal<O>;
41
- }): () => void;
43
+ }): Reactor<'preconditions-unmet' | 'monitoring' | 'playback-initiated' | 'destroying' | 'destroyed'>;
42
44
  //#endregion
43
45
  export { PlaybackInitiatedOwners, PlaybackInitiatedState, trackPlaybackInitiated };
44
46
  //# sourceMappingURL=track-playback-initiated.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"track-playback-initiated.d.ts","names":[],"sources":["../../../../src/dom/features/track-playback-initiated.ts"],"mappings":";;;;;AAOA;UAAiB,sBAAA;;EAEf,iBAAA;EAAA;EAEA,YAAA;IAAiB,GAAA;EAAA;AAAA;AAMnB;;;AAAA,UAAiB,uBAAA;EACf,YAAA,GAAe,gBAAA;AAAA;;;;;;;;;;;;;;;iBAiBD,sBAAA,WAAiC,sBAAA,YAAkC,uBAAA,CAAA,CAAA;EACjF,KAAA;EACA;AAAA;EAEA,KAAA,EAAO,MAAA,CAAO,CAAA;EACd,MAAA,EAAQ,MAAA,CAAO,CAAA;AAAA"}
1
+ {"version":3,"file":"track-playback-initiated.d.ts","names":[],"sources":["../../../../src/dom/features/track-playback-initiated.ts"],"mappings":";;;;;;AAQA;UAAiB,sBAAA;;EAEf,iBAAA;EAAA;EAEA,YAAA;IAAiB,GAAA;EAAA;AAAA;AAMnB;;;AAAA,UAAiB,uBAAA;EACf,YAAA,GAAe,gBAAA;AAAA;;;;;;;;;;;;;;;;iBAyCD,sBAAA,WAAiC,sBAAA,YAAkC,uBAAA,CAAA,CAAA;EACjF,KAAA;EACA;AAAA;EAEA,KAAA,EAAO,MAAA,CAAO,CAAA;EACd,MAAA,EAAQ,MAAA,CAAO,CAAA;AAAA,IACb,OAAA"}
@@ -1,51 +1,64 @@
1
- import { effect } from "../../core/signals/effect.js";
2
- import { computed, signal, update } from "../../core/signals/primitives.js";
1
+ import { computed, update } from "../../core/signals/primitives.js";
2
+ import { createMachineReactor } from "../../core/create-machine-reactor.js";
3
3
  import { listen } from "@videojs/utils/dom";
4
4
  //#region src/dom/features/track-playback-initiated.ts
5
5
  /**
6
- * Track whether playback has been initiated for the current presentation URL.
6
+ * FSM states for playback initiation tracking.
7
+ *
8
+ * ```
9
+ * 'preconditions-unmet' ──── element + URL ────→ 'monitoring'
10
+ * ↑ ↑ │
11
+ * │ preconditions lost │ play / !paused
12
+ * │ │ ↓
13
+ * └────────────── 'playback-initiated' ←──────┘
14
+ * (exit cleanup resets state.playbackInitiated → false)
7
15
  *
8
- * Uses a local intermediate signal written by two effect streams:
9
- * - false stream: resets on URL change
10
- * - true stream: sets on play event
16
+ * any state ──── destroy() ────→ 'destroying' ────→ 'destroyed'
17
+ * ```
18
+ */
19
+ function deriveState(state, owners) {
20
+ if (!owners.mediaElement || !state.presentation?.url) return "preconditions-unmet";
21
+ if (state.playbackInitiated) return "playback-initiated";
22
+ return "monitoring";
23
+ }
24
+ /**
25
+ * Track whether playback has been initiated for the current presentation URL.
11
26
  *
12
- * A third merge effect reads the local signal and writes to state, reading
13
- * `state.get()` at merge time so the spread uses the up-to-date value after
14
- * the async forward bridge has run.
27
+ * A three-state Reactor FSM driven by `state.playbackInitiated` and the
28
+ * `deriveState` pattern:
29
+ * - `'preconditions-unmet'` no element or URL yet; no effects.
30
+ * - `'monitoring'` — checks `!el.paused` on entry; listens for `play`.
31
+ * - `'playback-initiated'` — tracks element and URL; exit cleanup resets
32
+ * `state.playbackInitiated` to `false` on any change or lost preconditions.
15
33
  *
16
34
  * @example
17
- * const cleanup = trackPlaybackInitiated({ state, owners, events });
35
+ * const reactor = trackPlaybackInitiated({ state, owners });
36
+ * // later:
37
+ * reactor.destroy();
18
38
  */
19
39
  function trackPlaybackInitiated({ state, owners }) {
20
- const presentationUrl = computed(() => state.get().presentation?.url);
21
- const mediaElement = computed(() => owners.get().mediaElement);
22
- const playbackInitiated = signal(void 0);
23
- let lastPresentationUrl;
24
- let lastMediaElement;
25
- const cleanupResetEffect = effect(() => {
26
- const url = presentationUrl.get();
27
- const el = mediaElement.get();
28
- if (url !== lastPresentationUrl && lastPresentationUrl !== void 0 || el !== lastMediaElement && lastMediaElement !== void 0) playbackInitiated.set(false);
29
- lastPresentationUrl = url;
30
- lastMediaElement = el;
31
- });
32
- const cleanupPlayEffect = effect(() => {
33
- const el = mediaElement.get();
34
- if (!el) return;
35
- return listen(el, "play", () => {
36
- playbackInitiated.set(true);
37
- });
38
- });
39
- const cleanupMergeEffect = effect(() => {
40
- const pi = playbackInitiated.get();
41
- if (pi === void 0) return;
42
- if (state.get().playbackInitiated !== pi) update(state, { playbackInitiated: pi });
40
+ const derivedStateSignal = computed(() => deriveState(state.get(), owners.get()));
41
+ const mediaElementSignal = computed(() => owners.get().mediaElement);
42
+ const urlSignal = computed(() => state.get().presentation?.url);
43
+ return createMachineReactor({
44
+ initial: "preconditions-unmet",
45
+ monitor: () => derivedStateSignal.get(),
46
+ states: {
47
+ "preconditions-unmet": {},
48
+ monitoring: { entry: () => {
49
+ const el = mediaElementSignal.get();
50
+ update(state, { playbackInitiated: !el.paused });
51
+ return listen(el, "play", () => {
52
+ update(state, { playbackInitiated: !el.paused });
53
+ });
54
+ } },
55
+ "playback-initiated": { effects: () => {
56
+ mediaElementSignal.get();
57
+ urlSignal.get();
58
+ return () => update(state, { playbackInitiated: false });
59
+ } }
60
+ }
43
61
  });
44
- return () => {
45
- cleanupResetEffect();
46
- cleanupPlayEffect();
47
- cleanupMergeEffect();
48
- };
49
62
  }
50
63
  //#endregion
51
64
  export { trackPlaybackInitiated };
@@ -1 +1 @@
1
- {"version":3,"file":"track-playback-initiated.js","names":[],"sources":["../../../../src/dom/features/track-playback-initiated.ts"],"sourcesContent":["import { listen } from '@videojs/utils/dom';\nimport { effect } from '../../core/signals/effect';\nimport { computed, type Signal, signal, update } from '../../core/signals/primitives';\n\n/**\n * State shape for playback initiation tracking.\n */\nexport interface PlaybackInitiatedState {\n /** True once play has been requested for the current presentation URL. */\n playbackInitiated?: boolean;\n /** Current presentation — URL is used to detect source changes. */\n presentation?: { url?: string };\n}\n\n/**\n * Owners shape for playback initiation tracking.\n */\nexport interface PlaybackInitiatedOwners {\n mediaElement?: HTMLMediaElement | undefined;\n}\n\n/**\n * Track whether playback has been initiated for the current presentation URL.\n *\n * Uses a local intermediate signal written by two effect streams:\n * - false stream: resets on URL change\n * - true stream: sets on play event\n *\n * A third merge effect reads the local signal and writes to state, reading\n * `state.get()` at merge time so the spread uses the up-to-date value after\n * the async forward bridge has run.\n *\n * @example\n * const cleanup = trackPlaybackInitiated({ state, owners, events });\n */\nexport function trackPlaybackInitiated<S extends PlaybackInitiatedState, O extends PlaybackInitiatedOwners>({\n state,\n owners,\n}: {\n state: Signal<S>;\n owners: Signal<O>;\n}): () => void {\n const presentationUrl = computed(() => state.get().presentation?.url);\n const mediaElement = computed(() => owners.get().mediaElement);\n\n // Local signal: written by the URL effect (false) and the play listener (true).\n // undefined = not yet initialized (suppresses the merge effect on startup).\n const playbackInitiated = signal<boolean | undefined>(undefined);\n\n let lastPresentationUrl: string | undefined;\n let lastMediaElement: HTMLMediaElement | undefined;\n\n // False stream: reset on URL change or element swap.\n const cleanupResetEffect = effect(() => {\n const url = presentationUrl.get();\n const el = mediaElement.get();\n\n const urlChanged = url !== lastPresentationUrl;\n const elChanged = el !== lastMediaElement;\n\n if ((urlChanged && lastPresentationUrl !== undefined) || (elChanged && lastMediaElement !== undefined)) {\n playbackInitiated.set(false);\n }\n\n lastPresentationUrl = url;\n lastMediaElement = el;\n });\n\n // True stream: set on play event. Cleanup return removes the listener on element swap.\n const cleanupPlayEffect = effect(() => {\n const el = mediaElement.get();\n if (!el) return;\n return listen(el, 'play', () => {\n playbackInitiated.set(true);\n });\n });\n\n // Merge effect: bridge local signal state.\n // Reads state.get() at merge time (after the async forward bridge has flushed)\n // so the spread captures the current value rather than a stale event-time snapshot.\n // The guard makes the write idempotent.\n const cleanupMergeEffect = effect(() => {\n const pi = playbackInitiated.get();\n if (pi === undefined) return;\n const current = state.get();\n if (current.playbackInitiated !== pi) {\n const patch: Partial<PlaybackInitiatedState> = { playbackInitiated: pi };\n update(state, patch);\n }\n });\n\n return () => {\n cleanupResetEffect();\n cleanupPlayEffect();\n cleanupMergeEffect();\n };\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;AAmCA,SAAgB,uBAA4F,EAC1G,OACA,UAIa;CACb,MAAM,kBAAkB,eAAe,MAAM,KAAK,CAAC,cAAc,IAAI;CACrE,MAAM,eAAe,eAAe,OAAO,KAAK,CAAC,aAAa;CAI9D,MAAM,oBAAoB,OAA4B,KAAA,EAAU;CAEhE,IAAI;CACJ,IAAI;CAGJ,MAAM,qBAAqB,aAAa;EACtC,MAAM,MAAM,gBAAgB,KAAK;EACjC,MAAM,KAAK,aAAa,KAAK;AAK7B,MAHmB,QAAQ,uBAGR,wBAAwB,KAAA,KAFzB,OAAO,oBAE8C,qBAAqB,KAAA,EAC1F,mBAAkB,IAAI,MAAM;AAG9B,wBAAsB;AACtB,qBAAmB;GACnB;CAGF,MAAM,oBAAoB,aAAa;EACrC,MAAM,KAAK,aAAa,KAAK;AAC7B,MAAI,CAAC,GAAI;AACT,SAAO,OAAO,IAAI,cAAc;AAC9B,qBAAkB,IAAI,KAAK;IAC3B;GACF;CAMF,MAAM,qBAAqB,aAAa;EACtC,MAAM,KAAK,kBAAkB,KAAK;AAClC,MAAI,OAAO,KAAA,EAAW;AAEtB,MADgB,MAAM,KAAK,CACf,sBAAsB,GAEhC,QAAO,OADwC,EAAE,mBAAmB,IAAI,CACpD;GAEtB;AAEF,cAAa;AACX,sBAAoB;AACpB,qBAAmB;AACnB,sBAAoB"}
1
+ {"version":3,"file":"track-playback-initiated.js","names":[],"sources":["../../../../src/dom/features/track-playback-initiated.ts"],"sourcesContent":["import { listen } from '@videojs/utils/dom';\nimport type { Reactor } from '../../core/create-machine-reactor';\nimport { createMachineReactor } from '../../core/create-machine-reactor';\nimport { computed, type Signal, update } from '../../core/signals/primitives';\n\n/**\n * State shape for playback initiation tracking.\n */\nexport interface PlaybackInitiatedState {\n /** True once play has been requested for the current presentation URL. */\n playbackInitiated?: boolean;\n /** Current presentation — URL is used to detect source changes. */\n presentation?: { url?: string };\n}\n\n/**\n * Owners shape for playback initiation tracking.\n */\nexport interface PlaybackInitiatedOwners {\n mediaElement?: HTMLMediaElement | undefined;\n}\n\n/**\n * FSM states for playback initiation tracking.\n *\n * ```\n * 'preconditions-unmet' ──── element + URL ────→ 'monitoring'\n * ↑ ↑ │\n * │ preconditions lost │ play / !paused\n * │ │ ↓\n * └────────────── 'playback-initiated' ←──────┘\n * (exit cleanup resets state.playbackInitiated → false)\n *\n * any state ──── destroy() ────→ 'destroying' ────→ 'destroyed'\n * ```\n */\nfunction deriveState(\n state: PlaybackInitiatedState,\n owners: PlaybackInitiatedOwners\n): 'preconditions-unmet' | 'monitoring' | 'playback-initiated' {\n if (!owners.mediaElement || !state.presentation?.url) return 'preconditions-unmet';\n if (state.playbackInitiated) return 'playback-initiated';\n return 'monitoring';\n}\n\n/**\n * Track whether playback has been initiated for the current presentation URL.\n *\n * A three-state Reactor FSM driven by `state.playbackInitiated` and the\n * `deriveState` pattern:\n * - `'preconditions-unmet'` no element or URL yet; no effects.\n * - `'monitoring'` — checks `!el.paused` on entry; listens for `play`.\n * - `'playback-initiated'` — tracks element and URL; exit cleanup resets\n * `state.playbackInitiated` to `false` on any change or lost preconditions.\n *\n * @example\n * const reactor = trackPlaybackInitiated({ state, owners });\n * // later:\n * reactor.destroy();\n */\nexport function trackPlaybackInitiated<S extends PlaybackInitiatedState, O extends PlaybackInitiatedOwners>({\n state,\n owners,\n}: {\n state: Signal<S>;\n owners: Signal<O>;\n}): Reactor<'preconditions-unmet' | 'monitoring' | 'playback-initiated' | 'destroying' | 'destroyed'> {\n const derivedStateSignal = computed(() => deriveState(state.get(), owners.get()));\n const mediaElementSignal = computed(() => owners.get().mediaElement);\n const urlSignal = computed(() => state.get().presentation?.url);\n\n return createMachineReactor<'preconditions-unmet' | 'monitoring' | 'playback-initiated'>({\n initial: 'preconditions-unmet',\n monitor: () => derivedStateSignal.get(),\n states: {\n 'preconditions-unmet': {},\n\n monitoring: {\n // Entry: check if already playing; otherwise listen for play.\n // The fn body is automatically untracked el is read at entry time only.\n entry: () => {\n const el = mediaElementSignal.get()!;\n update(state, { playbackInitiated: !el.paused } as Partial<S>);\n return listen(el, 'play', () => {\n update(state, { playbackInitiated: !el.paused } as Partial<S>);\n });\n },\n },\n\n 'playback-initiated': {\n // Reaction: tracks element and URL while initiated. When either changes,\n // the effect re-runs the exit cleanup fires first, resetting\n // state.playbackInitiated to false. deriveState then returns 'monitoring'\n // on the next microtask, and the always monitor drives the transition.\n //\n // This covers both the preconditions-lost path (element/URL undefined,\n // which also triggers a deriveState → 'preconditions-unmet' transition)\n // and the URL-change / element-swap path (preconditions still met but\n // values changed, handled entirely by this effect's cleanup).\n effects: () => {\n mediaElementSignal.get(); // tracked re-run on element change\n urlSignal.get(); // tracked re-run on URL change\n return () => update(state, { playbackInitiated: false } as Partial<S>);\n },\n },\n },\n });\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;AAoCA,SAAS,YACP,OACA,QAC6D;AAC7D,KAAI,CAAC,OAAO,gBAAgB,CAAC,MAAM,cAAc,IAAK,QAAO;AAC7D,KAAI,MAAM,kBAAmB,QAAO;AACpC,QAAO;;;;;;;;;;;;;;;;;AAkBT,SAAgB,uBAA4F,EAC1G,OACA,UAIoG;CACpG,MAAM,qBAAqB,eAAe,YAAY,MAAM,KAAK,EAAE,OAAO,KAAK,CAAC,CAAC;CACjF,MAAM,qBAAqB,eAAe,OAAO,KAAK,CAAC,aAAa;CACpE,MAAM,YAAY,eAAe,MAAM,KAAK,CAAC,cAAc,IAAI;AAE/D,QAAO,qBAAkF;EACvF,SAAS;EACT,eAAe,mBAAmB,KAAK;EACvC,QAAQ;GACN,uBAAuB,EAAE;GAEzB,YAAY,EAGV,aAAa;IACX,MAAM,KAAK,mBAAmB,KAAK;AACnC,WAAO,OAAO,EAAE,mBAAmB,CAAC,GAAG,QAAQ,CAAe;AAC9D,WAAO,OAAO,IAAI,cAAc;AAC9B,YAAO,OAAO,EAAE,mBAAmB,CAAC,GAAG,QAAQ,CAAe;MAC9D;MAEL;GAED,sBAAsB,EAUpB,eAAe;AACb,uBAAmB,KAAK;AACxB,cAAU,KAAK;AACf,iBAAa,OAAO,OAAO,EAAE,mBAAmB,OAAO,CAAe;MAEzE;GACF;EACF,CAAC"}