@banou/media-player 0.8.9 → 0.8.11
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.
- package/dist/engine/index.d.ts +1 -1
- package/dist/engine/index.js +2 -2
- package/dist/engine/playback.d.ts +13 -0
- package/dist/{engine-BwK7ycv0.js → engine-Cownv8gD.js} +134 -98
- package/dist/index.d.ts +1 -1
- package/dist/index.js +652 -453
- package/dist/react/components/errors.d.ts +15 -0
- package/dist/react/player.d.ts +1 -0
- package/dist/react/source-feature.d.ts +36 -0
- package/dist/react/video-player.d.ts +9 -0
- package/package.json +1 -1
- package/src/lib/engine/index.ts +1 -1
- package/src/lib/engine/playback.ts +113 -6
- package/src/lib/index.tsx +1 -1
- package/src/lib/react/components/control-bar.tsx +4 -2
- package/src/lib/react/components/errors.tsx +194 -0
- package/src/lib/react/components/track-menu.tsx +2 -2
- package/src/lib/react/hooks/use-playback.ts +105 -2
- package/src/lib/react/source-feature.ts +29 -0
- package/src/lib/react/video-player.tsx +9 -0
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { PlaybackErrorEntry } from '../source-feature';
|
|
2
|
+
/** What lands on the clipboard: the same thing the panel shows, in the order it happened. */
|
|
3
|
+
export declare const formatErrors: (errors: PlaybackErrorEntry[]) => string;
|
|
4
|
+
/**
|
|
5
|
+
* The failures this session had, offered only once there has been one.
|
|
6
|
+
*
|
|
7
|
+
* It exists because the interesting failures are now the ones the viewer never sees: a media element
|
|
8
|
+
* that firefox wedged is rebuilt underneath them and playback carries on, which is the right
|
|
9
|
+
* behaviour and also erases the evidence. Without this, "it glitched and carried on" is unreportable.
|
|
10
|
+
*
|
|
11
|
+
* Copy is the point of the whole control. A decoder message is two hundred characters of C++ and
|
|
12
|
+
* nobody is going to retype it into an issue.
|
|
13
|
+
*/
|
|
14
|
+
export declare const ErrorsAction: () => import("@emotion/react/jsx-runtime").JSX.Element | null;
|
|
15
|
+
export default ErrorsAction;
|
package/dist/react/player.d.ts
CHANGED
|
@@ -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,24 @@ 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, so the report can be read next to a console log or a torrent's timeline. */
|
|
30
|
+
at: number;
|
|
31
|
+
/** Seconds into the media, which is usually the first question asked of a playback failure. */
|
|
32
|
+
atMediaTime?: number;
|
|
33
|
+
message: string;
|
|
34
|
+
/** The `cause` chain, already unwound, one line per level. */
|
|
35
|
+
detail?: string;
|
|
36
|
+
/** Whether the pipeline came back from it by itself. */
|
|
37
|
+
recovered: boolean;
|
|
38
|
+
};
|
|
21
39
|
/**
|
|
22
40
|
* A byte span of the file the consumer has in hand, mapped onto the timeline through the keyframe
|
|
23
41
|
* index, because a file's download percentage is not its playback percentage.
|
|
@@ -88,6 +106,15 @@ export type SourceState = {
|
|
|
88
106
|
burnedInSubtitles: boolean;
|
|
89
107
|
/** Set when the pipeline fails. Cleared when it recovers. */
|
|
90
108
|
playbackError: unknown;
|
|
109
|
+
/**
|
|
110
|
+
* Every failure this source has had, oldest first, whether or not the viewer ever saw one.
|
|
111
|
+
*
|
|
112
|
+
* `playbackError` is the CURRENT state and is cleared on recovery, so on its own it hides exactly
|
|
113
|
+
* the failures worth knowing about: a media element that firefox wedged is rebuilt and playback
|
|
114
|
+
* carries on, leaving no trace anywhere. This is the record, and the control bar offers it only
|
|
115
|
+
* once there is something in it.
|
|
116
|
+
*/
|
|
117
|
+
playbackErrors: PlaybackErrorEntry[];
|
|
91
118
|
/** Whether the engine has produced its first media segment. */
|
|
92
119
|
ready: boolean;
|
|
93
120
|
/**
|
|
@@ -154,6 +181,15 @@ export declare const sourceFeature: import("@videojs/react").PlayerFeature<{
|
|
|
154
181
|
burnedInSubtitles: boolean;
|
|
155
182
|
/** Set when the pipeline fails. Cleared when it recovers. */
|
|
156
183
|
playbackError: unknown;
|
|
184
|
+
/**
|
|
185
|
+
* Every failure this source has had, oldest first, whether or not the viewer ever saw one.
|
|
186
|
+
*
|
|
187
|
+
* `playbackError` is the CURRENT state and is cleared on recovery, so on its own it hides exactly
|
|
188
|
+
* the failures worth knowing about: a media element that firefox wedged is rebuilt and playback
|
|
189
|
+
* carries on, leaving no trace anywhere. This is the record, and the control bar offers it only
|
|
190
|
+
* once there is something in it.
|
|
191
|
+
*/
|
|
192
|
+
playbackErrors: PlaybackErrorEntry[];
|
|
157
193
|
/** Whether the engine has produced its first media segment. */
|
|
158
194
|
ready: boolean;
|
|
159
195
|
/**
|
|
@@ -59,6 +59,15 @@ type CommonOptions = {
|
|
|
59
59
|
* `pointer-events: auto` on itself. `children` land next to the media instead, below the chrome.
|
|
60
60
|
*/
|
|
61
61
|
overlay?: ReactNode;
|
|
62
|
+
/**
|
|
63
|
+
* Where the viewer is heading, as a 0..1 fraction, for a source that fetches on demand.
|
|
64
|
+
*
|
|
65
|
+
* Throttled, and deliberately: the chrome moves the element on every pointermove, so a drag
|
|
66
|
+
* across the bar is dozens of positions a second, and a consumer that reprioritises its download
|
|
67
|
+
* window on each one never finishes anything it starts. This fires on the leading edge, so a
|
|
68
|
+
* single seek moves the window at once, and again on the trailing edge, so the position the drag
|
|
69
|
+
* ended on is the one that sticks. It is a heading, not an event log.
|
|
70
|
+
*/
|
|
62
71
|
onSeek?: (fraction: number) => void;
|
|
63
72
|
onPlaybackError?: (error: unknown) => void;
|
|
64
73
|
};
|
package/package.json
CHANGED
package/src/lib/engine/index.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
export { startPlayback, terminateRemuxer, DEFAULT_BUFFER_SIZE } from './playback'
|
|
1
|
+
export { startPlayback, terminateRemuxer, MediaElementError, isMediaElementError, DEFAULT_BUFFER_SIZE } from './playback'
|
|
2
2
|
export type { PlaybackOptions, PlaybackController, MediaIndex, AudioStream } from './playback'
|
|
3
3
|
|
|
4
4
|
export { createSubtitleRenderer, SUBTITLES_OFF } from './subtitles'
|
|
@@ -44,6 +44,25 @@ export type PlaybackController = {
|
|
|
44
44
|
audioMimeType: string
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
+
/**
|
|
48
|
+
* A terminal failure of the media element itself, as opposed to anything this pipeline did.
|
|
49
|
+
*
|
|
50
|
+
* It is worth its own type because it is the one error class that says something about the CURE:
|
|
51
|
+
* the element is finished and no append will ever succeed against it again, so reporting it to the
|
|
52
|
+
* viewer is pointless and only a rebuilt element clears it. The flag rather than an `instanceof`
|
|
53
|
+
* is what survives the error crossing a module boundary.
|
|
54
|
+
*/
|
|
55
|
+
export class MediaElementError extends Error {
|
|
56
|
+
readonly mediaElement = true
|
|
57
|
+
constructor(message: string, options?: ErrorOptions) {
|
|
58
|
+
super(message, options)
|
|
59
|
+
this.name = 'MediaElementError'
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export const isMediaElementError = (error: unknown): boolean =>
|
|
64
|
+
!!error && typeof error === 'object' && (error as { mediaElement?: boolean }).mediaElement === true
|
|
65
|
+
|
|
47
66
|
// ~20s behind and ~60s ahead of the playhead, refilled when the forward buffer dips under 30s
|
|
48
67
|
const PRE_EVICT = -20
|
|
49
68
|
const POST_EVICT = 60
|
|
@@ -55,6 +74,10 @@ const MAX_APPEND_ATTEMPTS = 5
|
|
|
55
74
|
const SOURCE_OPEN_TIMEOUT = 15_000
|
|
56
75
|
// how far past the playhead a range may start and still count as the one holding it
|
|
57
76
|
const BOUNDARY_SLACK = 1
|
|
77
|
+
// the fastest a drag may move the consumer's download window
|
|
78
|
+
const SEEK_REPORT_MS = 200
|
|
79
|
+
// quiet time that ends a drag: pointermoves arrive every few ms, so this cannot cut one in half
|
|
80
|
+
const DRAG_SETTLE_MS = 250
|
|
58
81
|
export const DEFAULT_BUFFER_SIZE = 2_500_000
|
|
59
82
|
|
|
60
83
|
// destroy() only terminates after a round trip into the wasm, so terminate on our own clock too
|
|
@@ -199,11 +222,16 @@ export const startPlayback = async (options: PlaybackOptions): Promise<PlaybackC
|
|
|
199
222
|
let lastSeekPosition = 0
|
|
200
223
|
|
|
201
224
|
// 'Cancelled' covers both an aborted task and a read that gave up, so it is only noise mid-seek
|
|
202
|
-
const reportError = (error: unknown, aborted: boolean) => {
|
|
225
|
+
const reportError = (error: unknown, aborted: boolean, terminal = false) => {
|
|
203
226
|
const cancelled = (error as Error)?.message === 'Cancelled'
|
|
204
227
|
if (aborted && cancelled) return
|
|
205
228
|
console.error(error)
|
|
206
|
-
|
|
229
|
+
// A terminal failure of the element outranks whatever was already outstanding. It is the one
|
|
230
|
+
// error whose HANDLING differs, and the sequence that produces it starts with a starved
|
|
231
|
+
// buffer, which is also when a read is most likely to have reported first. Swallowing it
|
|
232
|
+
// behind that earlier report would leave the caller holding a dead element, able to fix it
|
|
233
|
+
// and never told to.
|
|
234
|
+
if (outstandingError && !terminal) return
|
|
207
235
|
outstandingError = true
|
|
208
236
|
onError?.(cancelled ? new Error('Reading the video file failed', { cause: error }) : error)
|
|
209
237
|
}
|
|
@@ -305,7 +333,9 @@ export const startPlayback = async (options: PlaybackOptions): Promise<PlaybackC
|
|
|
305
333
|
}
|
|
306
334
|
|
|
307
335
|
const pump = async () => {
|
|
308
|
-
|
|
336
|
+
// a settling drag is about to reposition the remuxer, so reading forward from where it
|
|
337
|
+
// happens to sit is throwing a read away
|
|
338
|
+
if (reading || seeking || dragTimer !== undefined || destroyed) return
|
|
309
339
|
if (!pending && (finished || !needsData())) return
|
|
310
340
|
const generation = ++readGeneration
|
|
311
341
|
const seekAtStart = seekGeneration
|
|
@@ -345,13 +375,84 @@ export const startPlayback = async (options: PlaybackOptions): Promise<PlaybackC
|
|
|
345
375
|
*/
|
|
346
376
|
const alreadyPlayable = (time: number) => time >= lastSeekPosition && !!playheadRange()
|
|
347
377
|
|
|
378
|
+
/**
|
|
379
|
+
* Where a drag reaches the consumer, at a rate a consumer can act on.
|
|
380
|
+
*
|
|
381
|
+
* `onSeek` moves the reader's download window, and the chrome moves the element on every
|
|
382
|
+
* pointermove, so one drag across the bar used to reprioritise the source dozens of times a
|
|
383
|
+
* second. Nothing a torrent starts survives being re-anchored at that rate, which is most of
|
|
384
|
+
* why a scrub could leave the buffer empty for half a minute. Leading edge so a single seek
|
|
385
|
+
* still moves the window at once, trailing edge so the position the drag ENDED on is the one
|
|
386
|
+
* that sticks.
|
|
387
|
+
*/
|
|
388
|
+
let lastSeekReport = 0
|
|
389
|
+
let trailingFraction: number | null = null
|
|
390
|
+
let trailingTimer: ReturnType<typeof setTimeout> | undefined
|
|
391
|
+
const reportSeek = (fraction: number) => {
|
|
392
|
+
const now = performance.now()
|
|
393
|
+
const since = now - lastSeekReport
|
|
394
|
+
if (since >= SEEK_REPORT_MS) {
|
|
395
|
+
lastSeekReport = now
|
|
396
|
+
trailingFraction = null
|
|
397
|
+
onSeek?.(fraction)
|
|
398
|
+
return
|
|
399
|
+
}
|
|
400
|
+
trailingFraction = fraction
|
|
401
|
+
if (trailingTimer) return
|
|
402
|
+
trailingTimer = setTimeout(() => {
|
|
403
|
+
trailingTimer = undefined
|
|
404
|
+
const pendingFraction = trailingFraction
|
|
405
|
+
trailingFraction = null
|
|
406
|
+
if (pendingFraction === null || destroyed) return
|
|
407
|
+
lastSeekReport = performance.now()
|
|
408
|
+
onSeek?.(pendingFraction)
|
|
409
|
+
}, SEEK_REPORT_MS - since)
|
|
410
|
+
}
|
|
411
|
+
teardown.push(() => { if (trailingTimer) clearTimeout(trailingTimer) })
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* A drag is not a seek per pointermove, however many the element reports.
|
|
415
|
+
*
|
|
416
|
+
* The chrome moves the element on every pointermove, and every one of those used to start a
|
|
417
|
+
* remuxer seek, which ABORTS the one already running. So during a drag none of them ever
|
|
418
|
+
* finished: a measured drag over a torrent produced 315 seeks, zero `seeked`, and thirty
|
|
419
|
+
* seconds in which not one byte reached the source buffer. An empty buffer for that long is
|
|
420
|
+
* also what wedges firefox's decoder, so this is not only wasted work.
|
|
421
|
+
*
|
|
422
|
+
* A move that arrives on its own still seeks AT ONCE, so a click on the bar costs nothing and
|
|
423
|
+
* nothing waits out a read that can run for tens of seconds over a torrent. Only a run of
|
|
424
|
+
* moves is a drag, and a drag gets one seek when it settles, to wherever it actually stopped.
|
|
425
|
+
*/
|
|
426
|
+
let lastSeekingAt = 0
|
|
427
|
+
let dragTimer: ReturnType<typeof setTimeout> | undefined
|
|
428
|
+
teardown.push(() => { if (dragTimer) clearTimeout(dragTimer) })
|
|
429
|
+
|
|
348
430
|
const onSeeking = () => {
|
|
349
431
|
const time = videoElement.currentTime
|
|
350
432
|
const duration = metadata.info.input.duration || videoElement.duration
|
|
351
|
-
if (duration > 0)
|
|
433
|
+
if (duration > 0) reportSeek(Math.min(Math.max(time / duration, 0), 1))
|
|
434
|
+
// stamped before the playable check, so a drag that crosses buffered ground and comes out
|
|
435
|
+
// the far side is still recognised as one drag rather than as a fresh click
|
|
436
|
+
const now = performance.now()
|
|
437
|
+
const dragging = now - lastSeekingAt < DRAG_SETTLE_MS
|
|
438
|
+
lastSeekingAt = now
|
|
352
439
|
if (alreadyPlayable(time)) return
|
|
353
440
|
finished = false
|
|
354
|
-
|
|
441
|
+
if (dragTimer) clearTimeout(dragTimer)
|
|
442
|
+
if (!dragging) {
|
|
443
|
+
dragTimer = undefined
|
|
444
|
+
void seekTo(time)
|
|
445
|
+
return
|
|
446
|
+
}
|
|
447
|
+
dragTimer = setTimeout(() => {
|
|
448
|
+
dragTimer = undefined
|
|
449
|
+
if (destroyed) return
|
|
450
|
+
// where the drag ENDED, which is the only position anyone is waiting on
|
|
451
|
+
const settled = videoElement.currentTime
|
|
452
|
+
if (alreadyPlayable(settled)) return
|
|
453
|
+
finished = false
|
|
454
|
+
void seekTo(settled)
|
|
455
|
+
}, DRAG_SETTLE_MS)
|
|
355
456
|
}
|
|
356
457
|
videoElement.addEventListener('seeking', onSeeking)
|
|
357
458
|
teardown.push(() => videoElement.removeEventListener('seeking', onSeeking))
|
|
@@ -368,6 +469,11 @@ export const startPlayback = async (options: PlaybackOptions): Promise<PlaybackC
|
|
|
368
469
|
*
|
|
369
470
|
* That cost a session: a decode failure presented as an append bug, which is a completely
|
|
370
471
|
* different place to look. Report what actually happened, then stop.
|
|
472
|
+
*
|
|
473
|
+
* It is reported as a MediaElementError because the caller can do something about this one that
|
|
474
|
+
* it cannot do about any other: nothing appended here will ever decode again, but a rebuilt
|
|
475
|
+
* element gets a fresh decoder, and the pipeline already knows how to come back at the same
|
|
476
|
+
* position. See `isMediaElementError`.
|
|
371
477
|
*/
|
|
372
478
|
let elementFailed = false
|
|
373
479
|
const onElementError = () => {
|
|
@@ -375,8 +481,9 @@ export const startPlayback = async (options: PlaybackOptions): Promise<PlaybackC
|
|
|
375
481
|
elementFailed = true
|
|
376
482
|
const error = videoElement.error
|
|
377
483
|
reportError(
|
|
378
|
-
new
|
|
484
|
+
new MediaElementError(`the media element failed: ${error?.message ?? 'unknown'}`, { cause: error }),
|
|
379
485
|
false,
|
|
486
|
+
true,
|
|
380
487
|
)
|
|
381
488
|
}
|
|
382
489
|
videoElement.addEventListener('error', onElementError)
|
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,194 @@
|
|
|
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
|
+
|
|
82
|
+
.what {
|
|
83
|
+
/* the decoder's own words, which are long and must not be cut */
|
|
84
|
+
overflow-wrap: anywhere;
|
|
85
|
+
white-space: pre-wrap;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
.cause {
|
|
89
|
+
color: #ddd;
|
|
90
|
+
overflow-wrap: anywhere;
|
|
91
|
+
white-space: pre-wrap;
|
|
92
|
+
${fonts.bSmall.regular}
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
`
|
|
97
|
+
|
|
98
|
+
const clock = (at: number) => new Date(at).toLocaleTimeString()
|
|
99
|
+
|
|
100
|
+
// the chrome's own formatter, so a position in the log reads exactly like the one on the seekbar
|
|
101
|
+
const mediaTime = (seconds: number | undefined) =>
|
|
102
|
+
seconds === undefined || !Number.isFinite(seconds) ? undefined : formatTime(Math.max(0, seconds))
|
|
103
|
+
|
|
104
|
+
/** What lands on the clipboard: the same thing the panel shows, in the order it happened. */
|
|
105
|
+
export const formatErrors = (errors: PlaybackErrorEntry[]) =>
|
|
106
|
+
errors
|
|
107
|
+
.map((entry, index) => {
|
|
108
|
+
const at = mediaTime(entry.atMediaTime)
|
|
109
|
+
const head = `${index + 1}. ${new Date(entry.at).toISOString()}${at ? ` (at ${at})` : ''}${entry.recovered ? ' [recovered]' : ''}`
|
|
110
|
+
return [head, entry.message, entry.detail].filter(Boolean).join('\n')
|
|
111
|
+
})
|
|
112
|
+
.join('\n\n')
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* The failures this session had, offered only once there has been one.
|
|
116
|
+
*
|
|
117
|
+
* It exists because the interesting failures are now the ones the viewer never sees: a media element
|
|
118
|
+
* that firefox wedged is rebuilt underneath them and playback carries on, which is the right
|
|
119
|
+
* behaviour and also erases the evidence. Without this, "it glitched and carried on" is unreportable.
|
|
120
|
+
*
|
|
121
|
+
* Copy is the point of the whole control. A decoder message is two hundred characters of C++ and
|
|
122
|
+
* nobody is going to retype it into an issue.
|
|
123
|
+
*/
|
|
124
|
+
export const ErrorsAction = () => {
|
|
125
|
+
const errors = usePlayer((state) => state.playbackErrors)
|
|
126
|
+
const { open, toggle, containerRef } = useTrackMenu()
|
|
127
|
+
const [copied, setCopied] = useState(false)
|
|
128
|
+
|
|
129
|
+
// After every hook, never before. Nothing to report means no button at all, rather than a control
|
|
130
|
+
// that is present and says "no errors": the bar is not the place to advertise that.
|
|
131
|
+
if (errors.length === 0) return null
|
|
132
|
+
|
|
133
|
+
const copy = () => {
|
|
134
|
+
// Deliberately unawaited state: the tick is feedback, not a promise the viewer waits on. A
|
|
135
|
+
// clipboard the browser refuses (no permission, insecure context) simply never ticks.
|
|
136
|
+
navigator.clipboard?.writeText(formatErrors(errors)).then(
|
|
137
|
+
() => {
|
|
138
|
+
setCopied(true)
|
|
139
|
+
setTimeout(() => setCopied(false), 2_000)
|
|
140
|
+
},
|
|
141
|
+
(error) => console.warn('[media-player] could not copy the errors:', error),
|
|
142
|
+
)
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const label = `Playback errors (${errors.length})`
|
|
146
|
+
|
|
147
|
+
return (
|
|
148
|
+
<div css={style} ref={containerRef}>
|
|
149
|
+
<TooltipDisplay
|
|
150
|
+
id='errors'
|
|
151
|
+
disabled={open}
|
|
152
|
+
text={
|
|
153
|
+
<button
|
|
154
|
+
className='errors'
|
|
155
|
+
type='button'
|
|
156
|
+
onClick={toggle}
|
|
157
|
+
aria-label={label}
|
|
158
|
+
aria-expanded={open}
|
|
159
|
+
>
|
|
160
|
+
<AlertTriangle className='alert-triangle' />
|
|
161
|
+
</button>
|
|
162
|
+
}
|
|
163
|
+
toolTipText={<span>{label}</span>}
|
|
164
|
+
/>
|
|
165
|
+
{
|
|
166
|
+
open && (
|
|
167
|
+
<div className='popover error-list'>
|
|
168
|
+
{/* `no-hover` because the header is a label with a control in it, not a row to click */}
|
|
169
|
+
<div className='back no-hover'>
|
|
170
|
+
<span>{label}</span>
|
|
171
|
+
<button className='copy' type='button' onClick={copy} aria-label='Copy the errors'>
|
|
172
|
+
{copied ? <Check /> : <Copy />}
|
|
173
|
+
<span>{copied ? 'Copied' : 'Copy'}</span>
|
|
174
|
+
</button>
|
|
175
|
+
</div>
|
|
176
|
+
{errors.map((entry, index) => (
|
|
177
|
+
<div className='entry no-hover' key={`${entry.at}-${index}`}>
|
|
178
|
+
<span className='when'>
|
|
179
|
+
{clock(entry.at)}
|
|
180
|
+
{mediaTime(entry.atMediaTime) ? ` at ${mediaTime(entry.atMediaTime)}` : ''}
|
|
181
|
+
{entry.recovered ? ' recovered' : ''}
|
|
182
|
+
</span>
|
|
183
|
+
<span className='what'>{entry.message}</span>
|
|
184
|
+
{entry.detail ? <span className='cause'>{entry.detail}</span> : null}
|
|
185
|
+
</div>
|
|
186
|
+
))}
|
|
187
|
+
</div>
|
|
188
|
+
)
|
|
189
|
+
}
|
|
190
|
+
</div>
|
|
191
|
+
)
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
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
|
-
|
|
86
|
+
&:not(.no-hover) {
|
|
87
87
|
cursor: pointer;
|
|
88
88
|
}
|
|
89
|
-
|
|
89
|
+
&:not(.no-hover):hover {
|
|
90
90
|
background-color: rgba(255,255,255,.1);
|
|
91
91
|
}
|
|
92
92
|
|