@banou/media-player 0.8.10 → 0.8.12

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.
@@ -0,0 +1,17 @@
1
+ import type { PlaybackErrorEntry } from '../source-feature';
2
+ /** How many failures there have been, which is not how many rows: a repeat folds into its row. */
3
+ export declare const countErrors: (errors: PlaybackErrorEntry[]) => number;
4
+ /** What lands on the clipboard: the same thing the panel shows, in the order it happened. */
5
+ export declare const formatErrors: (errors: PlaybackErrorEntry[]) => string;
6
+ /**
7
+ * The failures this session had, offered only once there has been one.
8
+ *
9
+ * It exists because the interesting failures are now the ones the viewer never sees: a media element
10
+ * that firefox wedged is rebuilt underneath them and playback carries on, which is the right
11
+ * behaviour and also erases the evidence. Without this, "it glitched and carried on" is unreportable.
12
+ *
13
+ * Copy is the point of the whole control. A decoder message is two hundred characters of C++ and
14
+ * nobody is going to retype it into an issue.
15
+ */
16
+ export declare const ErrorsAction: () => import("@emotion/react/jsx-runtime").JSX.Element | null;
17
+ export default ErrorsAction;
@@ -18,6 +18,7 @@ export declare const Player: import("@videojs/react").CreatePlayerResult<import(
18
18
  pictureInPictureMode: import("../engine").PictureInPictureMode | null;
19
19
  burnedInSubtitles: boolean;
20
20
  playbackError: unknown;
21
+ playbackErrors: import("./source-feature").PlaybackErrorEntry[];
21
22
  ready: boolean;
22
23
  setSourceState: (partial: Partial<import("./source-feature").SourceState>) => void;
23
24
  }>]>>;
@@ -18,6 +18,35 @@ export type TrackChoice = {
18
18
  */
19
19
  disabled?: boolean;
20
20
  };
21
+ /**
22
+ * One failure, kept so it can be read back and copied out long after playback recovered from it.
23
+ *
24
+ * Flattened to strings at the moment it happens rather than held as the `Error`: this is a report,
25
+ * the cause chain is most of what makes it useful, and an `Error` in a store is a live object whose
26
+ * `cause` may be a `MediaError` that reads differently once the element has moved on.
27
+ */
28
+ export type PlaybackErrorEntry = {
29
+ /** Wall clock of the FIRST of them, so the report can be read next to a console log. */
30
+ at: number;
31
+ /** Wall clock of the most recent one. Equal to `at` until a repeat has folded into this row. */
32
+ lastAt: number;
33
+ /**
34
+ * How many times in a row this exact failure happened.
35
+ *
36
+ * A source that stays broken reports the same sentence every few seconds for as long as it stays
37
+ * broken, so consecutive identical failures fold into one row rather than filling the panel with a
38
+ * wall of one message. This is also what keeps the list from growing without end in that case,
39
+ * since there is no ceiling on how many failures are kept.
40
+ */
41
+ count: number;
42
+ /** Seconds into the media, at the first of them, which is usually the first question asked. */
43
+ atMediaTime?: number;
44
+ message: string;
45
+ /** The `cause` chain, already unwound, one line per level. */
46
+ detail?: string;
47
+ /** Whether the pipeline came back from it by itself. */
48
+ recovered: boolean;
49
+ };
21
50
  /**
22
51
  * A byte span of the file the consumer has in hand, mapped onto the timeline through the keyframe
23
52
  * index, because a file's download percentage is not its playback percentage.
@@ -88,6 +117,15 @@ export type SourceState = {
88
117
  burnedInSubtitles: boolean;
89
118
  /** Set when the pipeline fails. Cleared when it recovers. */
90
119
  playbackError: unknown;
120
+ /**
121
+ * Every failure this source has had, oldest first, whether or not the viewer ever saw one.
122
+ *
123
+ * `playbackError` is the CURRENT state and is cleared on recovery, so on its own it hides exactly
124
+ * the failures worth knowing about: a media element that firefox wedged is rebuilt and playback
125
+ * carries on, leaving no trace anywhere. This is the record, and the control bar offers it only
126
+ * once there is something in it.
127
+ */
128
+ playbackErrors: PlaybackErrorEntry[];
91
129
  /** Whether the engine has produced its first media segment. */
92
130
  ready: boolean;
93
131
  /**
@@ -154,6 +192,15 @@ export declare const sourceFeature: import("@videojs/react").PlayerFeature<{
154
192
  burnedInSubtitles: boolean;
155
193
  /** Set when the pipeline fails. Cleared when it recovers. */
156
194
  playbackError: unknown;
195
+ /**
196
+ * Every failure this source has had, oldest first, whether or not the viewer ever saw one.
197
+ *
198
+ * `playbackError` is the CURRENT state and is cleared on recovery, so on its own it hides exactly
199
+ * the failures worth knowing about: a media element that firefox wedged is rebuilt and playback
200
+ * carries on, leaving no trace anywhere. This is the record, and the control bar offers it only
201
+ * once there is something in it.
202
+ */
203
+ playbackErrors: PlaybackErrorEntry[];
157
204
  /** Whether the engine has produced its first media segment. */
158
205
  ready: boolean;
159
206
  /**
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@banou/media-player",
3
- "version": "0.8.10",
3
+ "version": "0.8.12",
4
4
  "description": "A video player for containers and codecs the browser cannot play natively, remuxed on the fly",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
package/src/lib/index.tsx CHANGED
@@ -24,7 +24,7 @@ export type { PlayerStore } from './react/player'
24
24
  // Source state lives on the player store next to the built-in playback state, so `usePlayer` is the
25
25
  // only hook the chrome needs.
26
26
  export { sourceFeature } from './react/source-feature'
27
- export type { DownloadedRange, SourceState } from './react/source-feature'
27
+ export type { DownloadedRange, PlaybackErrorEntry, SourceState } from './react/source-feature'
28
28
 
29
29
  export { useSeekThumbnails } from './react/hooks/use-thumbnails'
30
30
  export { usePictureInPicture } from './react/hooks/use-picture-in-picture'
@@ -11,6 +11,7 @@ import { TooltipDisplay } from './tooltip-display'
11
11
  import { ProgressBar } from './progress-bar'
12
12
  import pictureInPicture from '../../assets/picture-in-picture.svg'
13
13
  import { SubtitlesInPicture } from './icons'
14
+ import ErrorsAction from './errors'
14
15
  import SettingsAction from './settings'
15
16
  import SubtitlesAction from './subtitles'
16
17
  import colors from '../../utils/colors'
@@ -106,7 +107,7 @@ const style = css`
106
107
  stroke: ${colors.accent};
107
108
  }
108
109
 
109
- .play, .sound, .time, .subtitles, .settings, .picture-in-picture, .full-screen {
110
+ .play, .sound, .time, .errors, .subtitles, .settings, .picture-in-picture, .full-screen {
110
111
  display: flex;
111
112
  align-items: center;
112
113
 
@@ -124,7 +125,7 @@ const style = css`
124
125
  }
125
126
  }
126
127
 
127
- .play, .sound, .subtitles, .settings, .picture-in-picture, .full-screen {
128
+ .play, .sound, .errors, .subtitles, .settings, .picture-in-picture, .full-screen {
128
129
  border-radius: 4px;
129
130
 
130
131
  padding: 8px;
@@ -307,6 +308,7 @@ export const ControlBar = () => {
307
308
  </div>
308
309
  </div>
309
310
  <div className='right'>
311
+ <ErrorsAction />
310
312
  <SubtitlesAction />
311
313
  <SettingsAction />
312
314
  {togglePictureInPicture
@@ -0,0 +1,213 @@
1
+ /// <reference types="@emotion/react/types/css-prop" />
2
+ import type { PlaybackErrorEntry } from '../source-feature'
3
+
4
+ import { useState } from 'react'
5
+ import { css } from '@emotion/react'
6
+ import { AlertTriangle, Check, Copy } from 'react-feather'
7
+
8
+ import { fonts } from '../../utils/fonts'
9
+ import { formatTime } from '../../utils/time'
10
+ import { usePlayer } from '../player'
11
+ import { TooltipDisplay } from './tooltip-display'
12
+ import { popoverStyle, useTrackMenu } from './track-menu'
13
+
14
+ const style = css`
15
+ /* Not a containing block, for the reason given on popoverStyle: the menu anchors to the control bar,
16
+ which is the width of the player box, so it can be clamped to it. */
17
+ position: static;
18
+
19
+ .errors {
20
+ /* the icon keeps its size, the pressable box grows around it */
21
+ @media (pointer: coarse) {
22
+ box-sizing: border-box;
23
+ justify-content: center;
24
+
25
+ min-width: 44px;
26
+ min-height: 44px;
27
+ }
28
+ }
29
+
30
+ ${popoverStyle}
31
+
32
+ .popover.error-list {
33
+ /* Wider than a track menu and taller, because these rows are sentences rather than labels, and a
34
+ decoder message that has been ellipsized is not worth copying. */
35
+ width: 420px;
36
+ max-height: min(300px, calc(100cqh - 100% - 16px));
37
+
38
+ /* track-menu's header is a left-aligned label row; here it carries a control too, so the two
39
+ ends go to the two ends */
40
+ .back {
41
+ justify-content: space-between;
42
+ gap: 8px;
43
+ }
44
+
45
+ .copy {
46
+ /* sits in the header row, which track-menu styles as .back */
47
+ display: flex;
48
+ align-items: center;
49
+ gap: 6px;
50
+
51
+ background: none;
52
+ border: none;
53
+ padding: 4px 6px;
54
+ border-radius: 4px;
55
+
56
+ color: #fff;
57
+ cursor: pointer;
58
+ ${fonts.bSmall.regular}
59
+
60
+ &:hover {
61
+ background-color: rgba(255,255,255,.1);
62
+ }
63
+
64
+ svg {
65
+ width: 14px;
66
+ height: 14px;
67
+ }
68
+ }
69
+
70
+ .entry {
71
+ /* one failure is a block of text, so it stacks instead of sitting on one line */
72
+ display: flex;
73
+ flex-direction: column;
74
+ align-items: flex-start;
75
+ gap: 2px;
76
+
77
+ .when {
78
+ color: #bbb;
79
+ ${fonts.bSmall.regular}
80
+
81
+ /* the one part of the line worth scanning for, so it is the one part at full brightness */
82
+ .count {
83
+ margin-left: 6px;
84
+ color: #fff;
85
+ }
86
+ }
87
+
88
+ .what {
89
+ /* the decoder's own words, which are long and must not be cut */
90
+ overflow-wrap: anywhere;
91
+ white-space: pre-wrap;
92
+ }
93
+
94
+ .cause {
95
+ color: #ddd;
96
+ overflow-wrap: anywhere;
97
+ white-space: pre-wrap;
98
+ ${fonts.bSmall.regular}
99
+ }
100
+ }
101
+ }
102
+ `
103
+
104
+ const clock = (at: number) => new Date(at).toLocaleTimeString()
105
+
106
+ // the chrome's own formatter, so a position in the log reads exactly like the one on the seekbar
107
+ const mediaTime = (seconds: number | undefined) =>
108
+ seconds === undefined || !Number.isFinite(seconds) ? undefined : formatTime(Math.max(0, seconds))
109
+
110
+ /** How many failures there have been, which is not how many rows: a repeat folds into its row. */
111
+ export const countErrors = (errors: PlaybackErrorEntry[]) =>
112
+ errors.reduce((total, entry) => total + entry.count, 0)
113
+
114
+ /** What lands on the clipboard: the same thing the panel shows, in the order it happened. */
115
+ export const formatErrors = (errors: PlaybackErrorEntry[]) =>
116
+ errors
117
+ .map((entry, index) => {
118
+ const at = mediaTime(entry.atMediaTime)
119
+ // A repeat carries the span it covers, so "40 times over two minutes" and "40 times in one
120
+ // second" are distinguishable in a report. Both happen, and they are different faults.
121
+ const when = entry.count > 1
122
+ ? `${new Date(entry.at).toISOString()} to ${new Date(entry.lastAt).toISOString()} ×${entry.count}`
123
+ : new Date(entry.at).toISOString()
124
+ const head = `${index + 1}. ${when}${at ? ` (at ${at})` : ''}${entry.recovered ? ' [recovered]' : ''}`
125
+ return [head, entry.message, entry.detail].filter(Boolean).join('\n')
126
+ })
127
+ .join('\n\n')
128
+
129
+ /**
130
+ * The failures this session had, offered only once there has been one.
131
+ *
132
+ * It exists because the interesting failures are now the ones the viewer never sees: a media element
133
+ * that firefox wedged is rebuilt underneath them and playback carries on, which is the right
134
+ * behaviour and also erases the evidence. Without this, "it glitched and carried on" is unreportable.
135
+ *
136
+ * Copy is the point of the whole control. A decoder message is two hundred characters of C++ and
137
+ * nobody is going to retype it into an issue.
138
+ */
139
+ export const ErrorsAction = () => {
140
+ const errors = usePlayer((state) => state.playbackErrors)
141
+ const { open, toggle, containerRef } = useTrackMenu()
142
+ const [copied, setCopied] = useState(false)
143
+
144
+ // After every hook, never before. Nothing to report means no button at all, rather than a control
145
+ // that is present and says "no errors": the bar is not the place to advertise that.
146
+ if (errors.length === 0) return null
147
+
148
+ const copy = () => {
149
+ // Deliberately unawaited state: the tick is feedback, not a promise the viewer waits on. A
150
+ // clipboard the browser refuses (no permission, insecure context) simply never ticks.
151
+ navigator.clipboard?.writeText(formatErrors(errors)).then(
152
+ () => {
153
+ setCopied(true)
154
+ setTimeout(() => setCopied(false), 2_000)
155
+ },
156
+ (error) => console.warn('[media-player] could not copy the errors:', error),
157
+ )
158
+ }
159
+
160
+ // counted in failures rather than in rows, because a row that says ×40 is forty of them
161
+ const label = `Playback errors (${countErrors(errors)})`
162
+
163
+ return (
164
+ <div css={style} ref={containerRef}>
165
+ <TooltipDisplay
166
+ id='errors'
167
+ disabled={open}
168
+ text={
169
+ <button
170
+ className='errors'
171
+ type='button'
172
+ onClick={toggle}
173
+ aria-label={label}
174
+ aria-expanded={open}
175
+ >
176
+ <AlertTriangle className='alert-triangle' />
177
+ </button>
178
+ }
179
+ toolTipText={<span>{label}</span>}
180
+ />
181
+ {
182
+ open && (
183
+ <div className='popover error-list'>
184
+ {/* `no-hover` because the header is a label with a control in it, not a row to click */}
185
+ <div className='back no-hover'>
186
+ <span>{label}</span>
187
+ <button className='copy' type='button' onClick={copy} aria-label='Copy the errors'>
188
+ {copied ? <Check /> : <Copy />}
189
+ <span>{copied ? 'Copied' : 'Copy'}</span>
190
+ </button>
191
+ </div>
192
+ {errors.map((entry, index) => (
193
+ <div className='entry no-hover' key={`${entry.at}-${index}`}>
194
+ <span className='when'>
195
+ {clock(entry.at)}
196
+ {/* the span, so a row that is still growing is distinguishable from one that stopped */}
197
+ {entry.count > 1 ? ` to ${clock(entry.lastAt)}` : ''}
198
+ {mediaTime(entry.atMediaTime) ? ` at ${mediaTime(entry.atMediaTime)}` : ''}
199
+ {entry.recovered ? ' recovered' : ''}
200
+ {entry.count > 1 ? <span className='count'>{`×${entry.count}`}</span> : null}
201
+ </span>
202
+ <span className='what'>{entry.message}</span>
203
+ {entry.detail ? <span className='cause'>{entry.detail}</span> : null}
204
+ </div>
205
+ ))}
206
+ </div>
207
+ )
208
+ }
209
+ </div>
210
+ )
211
+ }
212
+
213
+ export default ErrorsAction
@@ -83,10 +83,10 @@ export const popoverStyle = css`
83
83
  :last-of-type {
84
84
  border-radius: 0 0 8px 8px;
85
85
  }
86
- :not(&.no-hover) {
86
+ &:not(.no-hover) {
87
87
  cursor: pointer;
88
88
  }
89
- :not(&.no-hover):hover {
89
+ &:not(.no-hover):hover {
90
90
  background-color: rgba(255,255,255,.1);
91
91
  }
92
92
 
@@ -1,4 +1,5 @@
1
1
  import type { PlaybackController } from '../../engine'
2
+ import type { PlaybackErrorEntry } from '../source-feature'
2
3
  import type { MediaPlayerLocalOptions } from '../video-player'
3
4
 
4
5
  import { useCallback, useEffect, useRef, useState } from 'react'
@@ -7,10 +8,47 @@ import { isMediaElementError, startPlayback } from '../../engine'
7
8
  import { usePlayer } from '../player'
8
9
  import { toNamedTracks } from '../../utils/track-label'
9
10
 
10
- // A wedged element is rebuilt rather than reported, but a file that wedges over and over is a real
11
- // failure and has to reach the viewer instead of looping forever.
12
- const MAX_RESTARTS = 3
13
- const RESTART_WINDOW_MS = 60_000
11
+ /**
12
+ * A wedged element is rebuilt, however many times it takes.
13
+ *
14
+ * There is deliberately no ceiling. The failure this exists for is a firefox decoder that will not
15
+ * take another packet, it is not the file's fault, and a viewer forty minutes into an episode is
16
+ * not helped by a budget running out. Every rebuild is recorded instead, and the control bar offers
17
+ * the record, so a file that genuinely cannot play is visible as a wall of identical entries rather
18
+ * than hidden behind a counter.
19
+ *
20
+ * The backoff is the whole guard: rebuilds that keep failing immediately slow down, so a source that
21
+ * fails deterministically settles into a slow retry instead of a hot loop that pins a core and
22
+ * reloads libav as fast as it can.
23
+ */
24
+ const RESTART_BACKOFF_MS = [0, 250, 1_000, 3_000, 10_000]
25
+ // far enough back that an unrelated hiccup later in an episode starts from no delay again
26
+ const RESTART_SETTLED_MS = 60_000
27
+
28
+ const messageOf = (error: unknown) =>
29
+ error instanceof Error ? error.message : String(error)
30
+
31
+ /**
32
+ * The `cause` chain, unwound to text at the moment it happened.
33
+ *
34
+ * The reason a decode failure is worth copying out at all is almost always one level down: the top
35
+ * line says the media element failed, and the cause carries what the decoder actually said. A
36
+ * `MediaError` is not an `Error`, so it is read for its own two fields rather than skipped.
37
+ */
38
+ const causeChain = (error: unknown) => {
39
+ const lines: string[] = []
40
+ let cause: unknown = (error as { cause?: unknown })?.cause
41
+ // bounded, because a cause chain can be circular and this runs inside an error path
42
+ for (let depth = 0; cause != null && depth < 8; depth++) {
43
+ if (typeof MediaError !== 'undefined' && cause instanceof MediaError) {
44
+ lines.push(`MediaError code ${cause.code}${cause.message ? `: ${cause.message}` : ''}`)
45
+ break
46
+ }
47
+ lines.push(messageOf(cause))
48
+ cause = (cause as { cause?: unknown })?.cause
49
+ }
50
+ return lines.length ? lines.join('\n') : undefined
51
+ }
14
52
 
15
53
  /**
16
54
  * Owns the engine for the life of a source: start, teardown, and every piece of state the pipeline
@@ -39,18 +77,26 @@ export const usePlayback = (
39
77
  *
40
78
  * Firefox can wedge its own decoder: when the source buffer runs dry it drains the decoder so the
41
79
  * frames still inside it get shown, and clearing that drain needs a decoded sample to resume
42
- * from. A seek into an empty buffer over a slow source has none, so the drain is never cleared
43
- * and every packet after it comes back `avcodec_send_packet error: End of file`. The element is
44
- * finished at that point and no append can revive it.
80
+ * from. A seek into an empty buffer has none, so the drain is never cleared and every packet
81
+ * after it comes back `avcodec_send_packet error: End of file`. The element is finished at that
82
+ * point and no append can revive it. It is not ours: it reproduces on this player as it stood in
83
+ * October 2025, on a local file, and on every version since.
45
84
  *
46
85
  * Rebuilding is the cure, and this hook already does exactly that for an audio track change,
47
86
  * position and all, so the recovery is a dep rather than a second teardown path.
48
87
  */
49
88
  const [restartToken, setRestartToken] = useState(0)
50
89
  const restarts = useRef({ count: 0, at: 0 })
51
- // The budget belongs to one media, not to the player: a file that used it up must not leave the
52
- // next one with no recovery at all.
53
- useEffect(() => { restarts.current = { count: 0, at: 0 } }, [size])
90
+ const restartTimer = useRef<ReturnType<typeof setTimeout>>(undefined)
91
+ // The streak belongs to one media, not to the player, and a pending rebuild of the old one must
92
+ // not land on the new one.
93
+ useEffect(() => {
94
+ restarts.current = { count: 0, at: 0 }
95
+ player.setSourceState({ playbackErrors: [] })
96
+ return () => { if (restartTimer.current) clearTimeout(restartTimer.current) }
97
+ // `player` is stable; listing it would not re-run this, and the reset belongs to the media
98
+ // eslint-disable-next-line react-hooks/exhaustive-deps
99
+ }, [size])
54
100
 
55
101
  const controllerRef = useRef<PlaybackController | null>(null)
56
102
  /**
@@ -104,20 +150,60 @@ export const usePlayback = (
104
150
  if (!video || !canvas || !size || !read) return
105
151
  let cancelled = false
106
152
  player.setSourceState({ playbackError: null, ready: false })
153
+
154
+ /**
155
+ * Keep the failure, whether or not the viewer is about to be told about it.
156
+ *
157
+ * Appended rather than replaced, and never cleared by a recovery: a rebuild that works leaves
158
+ * `playbackError` null and would otherwise erase the only evidence that anything went wrong.
159
+ */
160
+ const record = (error: unknown, recovered: boolean) => {
161
+ const at = Date.now()
162
+ const message = messageOf(error)
163
+ const detail = causeChain(error)
164
+ const errors = player.playbackErrors
165
+ const last = errors[errors.length - 1]
166
+
167
+ // A source that stays broken repeats one sentence for as long as it stays broken, so a
168
+ // consecutive repeat folds into the row it repeats rather than adding another. Only
169
+ // CONSECUTIVE ones: an identical failure either side of a different one is a second episode,
170
+ // and collapsing the two would lose the order that makes a report readable.
171
+ if (last && last.message === message && last.detail === detail && last.recovered === recovered) {
172
+ const folded: PlaybackErrorEntry = { ...last, count: last.count + 1, lastAt: at }
173
+ player.setSourceState({ playbackErrors: [...errors.slice(0, -1), folded] })
174
+ return
175
+ }
176
+
177
+ const entry: PlaybackErrorEntry = {
178
+ at,
179
+ lastAt: at,
180
+ count: 1,
181
+ // where it STARTED, kept as the row grows, because that is the position worth reporting
182
+ atMediaTime: Number.isFinite(video.currentTime) ? video.currentTime : undefined,
183
+ message,
184
+ detail,
185
+ recovered,
186
+ }
187
+ player.setSourceState({ playbackErrors: [...errors, entry] })
188
+ }
107
189
  const fail = (error: unknown) => {
108
190
  if (cancelled) return
191
+ const recoverable = isMediaElementError(error)
192
+ record(error, recoverable)
109
193
  // Not something the viewer can act on and not something an append can survive: rebuild the
110
- // element instead of putting a dead player behind an error message. Counted in a window, so
111
- // a file that wedges again and again still reaches the viewer rather than looping.
112
- if (isMediaElementError(error)) {
194
+ // element instead of putting a dead player behind an error message.
195
+ if (recoverable) {
113
196
  const now = performance.now()
114
- if (now - restarts.current.at > RESTART_WINDOW_MS) restarts.current = { count: 0, at: now }
115
- if (restarts.current.count < MAX_RESTARTS) {
116
- restarts.current = { count: restarts.current.count + 1, at: now }
117
- console.warn('the media element failed; rebuilding the pipeline', error)
118
- setRestartToken((token) => token + 1)
119
- return
120
- }
197
+ // a failure long after the last one is not part of a streak, so it pays no delay
198
+ if (now - restarts.current.at > RESTART_SETTLED_MS) restarts.current = { count: 0, at: now }
199
+ const delay = RESTART_BACKOFF_MS[Math.min(restarts.current.count, RESTART_BACKOFF_MS.length - 1)]!
200
+ restarts.current = { count: restarts.current.count + 1, at: now }
201
+ console.warn(`the media element failed; rebuilding the pipeline${delay ? ` in ${delay}ms` : ''}`, error)
202
+ if (restartTimer.current) clearTimeout(restartTimer.current)
203
+ // still queued through a timer at zero delay, so the rebuild never runs inside the callback
204
+ // that reported the failure
205
+ restartTimer.current = setTimeout(() => setRestartToken((token) => token + 1), delay)
206
+ return
121
207
  }
122
208
  console.error('playback failed', error)
123
209
  player.setSourceState({ playbackError: error })
@@ -22,6 +22,36 @@ export type TrackChoice = {
22
22
  disabled?: boolean
23
23
  }
24
24
 
25
+ /**
26
+ * One failure, kept so it can be read back and copied out long after playback recovered from it.
27
+ *
28
+ * Flattened to strings at the moment it happens rather than held as the `Error`: this is a report,
29
+ * the cause chain is most of what makes it useful, and an `Error` in a store is a live object whose
30
+ * `cause` may be a `MediaError` that reads differently once the element has moved on.
31
+ */
32
+ export type PlaybackErrorEntry = {
33
+ /** Wall clock of the FIRST of them, so the report can be read next to a console log. */
34
+ at: number
35
+ /** Wall clock of the most recent one. Equal to `at` until a repeat has folded into this row. */
36
+ lastAt: number
37
+ /**
38
+ * How many times in a row this exact failure happened.
39
+ *
40
+ * A source that stays broken reports the same sentence every few seconds for as long as it stays
41
+ * broken, so consecutive identical failures fold into one row rather than filling the panel with a
42
+ * wall of one message. This is also what keeps the list from growing without end in that case,
43
+ * since there is no ceiling on how many failures are kept.
44
+ */
45
+ count: number
46
+ /** Seconds into the media, at the first of them, which is usually the first question asked. */
47
+ atMediaTime?: number
48
+ message: string
49
+ /** The `cause` chain, already unwound, one line per level. */
50
+ detail?: string
51
+ /** Whether the pipeline came back from it by itself. */
52
+ recovered: boolean
53
+ }
54
+
25
55
  /**
26
56
  * A byte span of the file the consumer has in hand, mapped onto the timeline through the keyframe
27
57
  * index, because a file's download percentage is not its playback percentage.
@@ -100,6 +130,15 @@ export type SourceState = {
100
130
 
101
131
  /** Set when the pipeline fails. Cleared when it recovers. */
102
132
  playbackError: unknown
133
+ /**
134
+ * Every failure this source has had, oldest first, whether or not the viewer ever saw one.
135
+ *
136
+ * `playbackError` is the CURRENT state and is cleared on recovery, so on its own it hides exactly
137
+ * the failures worth knowing about: a media element that firefox wedged is rebuilt and playback
138
+ * carries on, leaving no trace anywhere. This is the record, and the control bar offers it only
139
+ * once there is something in it.
140
+ */
141
+ playbackErrors: PlaybackErrorEntry[]
103
142
  /** Whether the engine has produced its first media segment. */
104
143
  ready: boolean
105
144
 
@@ -128,6 +167,7 @@ const initialState: SourceState = {
128
167
  pictureInPictureMode: null,
129
168
  burnedInSubtitles: false,
130
169
  playbackError: null,
170
+ playbackErrors: [],
131
171
  ready: false,
132
172
  setSourceState: () => {},
133
173
  }