@banou/media-player 0.10.0 → 0.10.1

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,10 @@
1
+ /**
2
+ * Offers to jump past an opening or ending, when the chapter under the playhead looks like one.
3
+ *
4
+ * The whole feature is this button plus `classifyChapters`, and it is deliberately the weaker half
5
+ * of the pair: the classifier reads titles written by whoever muxed the file, so it is sometimes
6
+ * going to be wrong. Making the offer expire, and never acting on its own, is what makes being wrong
7
+ * cheap. See the classifier for what it matches and the sample it was measured against.
8
+ */
9
+ export declare const SkipChapter: () => import("@emotion/react/jsx-runtime").JSX.Element | null;
10
+ export default SkipChapter;
@@ -3,6 +3,7 @@ export declare const Player: import("@videojs/react").CreatePlayerResult<import(
3
3
  size?: number;
4
4
  downloadedRanges?: import("./source-feature").DownloadedRange[];
5
5
  indexes: import("..").MediaIndex[];
6
+ chapters: import("..").MediaChapter[];
6
7
  thumbnails: import("..").ThumbnailImage[];
7
8
  thumbnailAt?: (time: number) => import("..").ThumbnailImage | undefined;
8
9
  requestThumbnail: (time: number | undefined) => void;
@@ -1,4 +1,4 @@
1
- import type { MediaIndex, PictureInPictureMode, ThumbnailImage } from '../engine';
1
+ import type { MediaChapter, MediaIndex, PictureInPictureMode, ThumbnailImage } from '../engine';
2
2
  /**
3
3
  * One row of a track menu, already named.
4
4
  *
@@ -70,6 +70,14 @@ export type SourceState = {
70
70
  downloadedRanges?: DownloadedRange[];
71
71
  /** Keyframe index of the input, which turns a downloaded byte range into a time range. */
72
72
  indexes: MediaIndex[];
73
+ /**
74
+ * Named spans of the timeline, drawn as segments on the seekbar. Empty when the source has none.
75
+ *
76
+ * Ordered by start and non-overlapping, which is what the seekbar assumes. They need not cover the
77
+ * whole duration: the engine passes on whatever the container declared, and a caller-supplied list
78
+ * is whatever the caller knows.
79
+ */
80
+ chapters: MediaChapter[];
73
81
  thumbnails: ThumbnailImage[];
74
82
  /**
75
83
  * Answers for one time directly, when the source has a storyboard it can index but not enumerate.
@@ -168,6 +176,14 @@ export declare const sourceFeature: import("@videojs/react").PlayerFeature<{
168
176
  downloadedRanges?: DownloadedRange[];
169
177
  /** Keyframe index of the input, which turns a downloaded byte range into a time range. */
170
178
  indexes: MediaIndex[];
179
+ /**
180
+ * Named spans of the timeline, drawn as segments on the seekbar. Empty when the source has none.
181
+ *
182
+ * Ordered by start and non-overlapping, which is what the seekbar assumes. They need not cover the
183
+ * whole duration: the engine passes on whatever the container declared, and a caller-supplied list
184
+ * is whatever the caller knows.
185
+ */
186
+ chapters: MediaChapter[];
171
187
  thumbnails: ThumbnailImage[];
172
188
  /**
173
189
  * Answers for one time directly, when the source has a storyboard it can index but not enumerate.
@@ -1,4 +1,5 @@
1
1
  import type { ReactNode } from 'react';
2
+ import type { MediaChapter } from '../engine';
2
3
  import type { DownloadedRange } from './source-feature';
3
4
  import type { DelegatedTracks, ExternalThumbnails, PlayerMedia } from './media';
4
5
  import type { ExposePlayerOptions } from '../remote';
@@ -35,6 +36,18 @@ type CommonOptions = {
35
36
  */
36
37
  title?: string;
37
38
  autoplay?: boolean;
39
+ /**
40
+ * Named spans of the timeline, drawn as segments on the seekbar.
41
+ *
42
+ * Common to both arms rather than remote-only, unlike `thumbnails`. A local file is just as likely
43
+ * to declare none (mp4 and webm routinely carry no chapters at all), and a caller often knows
44
+ * chapters the container does not: skip-intro ranges from a metadata API are the usual case. Given
45
+ * here they WIN over whatever the container declared, and they paint on the first frame rather than
46
+ * waiting for the pipeline to boot.
47
+ *
48
+ * Expected in seconds, ordered by start and non-overlapping. They need not cover the duration.
49
+ */
50
+ chapters?: MediaChapter[];
38
51
  /**
39
52
  * Draw the control bar. Defaults to true.
40
53
  *
@@ -0,0 +1,37 @@
1
+ import type { MediaChapter } from '../engine';
2
+ /**
3
+ * Where the bar is broken, as percentages, including the two ends: [0, ...breaks, 100].
4
+ *
5
+ * Both edges of every chapter count, so chapters that leave un-named time between them break the
6
+ * bar on each side of the gap and the un-named span becomes a segment of its own. Returns nothing
7
+ * when there is no break worth drawing, which is what leaves a file with no chapters, or one whose
8
+ * single chapter spans the whole picture, rendering exactly as it did before chapters existed.
9
+ */
10
+ export declare const segmentBounds: (chapters: MediaChapter[], duration: number) => number[];
11
+ /**
12
+ * A mask painting the segments `keep` accepts, with a gap at every boundary between segments.
13
+ *
14
+ * The gap is cut from both sides of a boundary so it stays centred on it, and never from the two
15
+ * outer edges, where it would shorten the bar rather than divide it.
16
+ */
17
+ export declare const segmentMask: (bounds: number[], keep: (index: number) => boolean) => string;
18
+ /**
19
+ * What a chapter is, when its title says plainly enough to offer a skip.
20
+ *
21
+ * Only ever a suggestion: the player shows a button for a few seconds and does nothing unless it is
22
+ * pressed. That is what lets this be generous rather than careful. A chapter wrongly called an
23
+ * opening costs a button nobody presses, while an opening this fails to recognise costs the feature.
24
+ *
25
+ * Every rule below was checked against 192 real files (891 chapter markers, 79 distinct chapter
26
+ * sequences) rather than guessed, and the counts quoted are from that sample.
27
+ */
28
+ export type ChapterKind = 'opening' | 'ending';
29
+ /**
30
+ * What each chapter is, decided across the whole list rather than one title at a time.
31
+ *
32
+ * The list is what resolves the hedged words. A file whose chapters read Intro, OP, Episode, ED,
33
+ * Preview has already said which one the theme is, so its Intro is left alone; one that reads
34
+ * Episode, Intro, Episode, Credits has not, so its Intro is offered. Both shapes are in the sample,
35
+ * 13 files and 24 files.
36
+ */
37
+ export declare const classifyChapters: (chapters: MediaChapter[]) => (ChapterKind | undefined)[];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@banou/media-player",
3
- "version": "0.10.0",
3
+ "version": "0.10.1",
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",
@@ -1,5 +1,5 @@
1
1
  export { startPlayback, terminateRemuxer, MediaElementError, isMediaElementError, DEFAULT_BUFFER_SIZE } from './playback'
2
- export type { PlaybackOptions, PlaybackController, MediaIndex, AudioStream } from './playback'
2
+ export type { PlaybackOptions, PlaybackController, MediaIndex, MediaChapter, AudioStream } from './playback'
3
3
 
4
4
  export { createSubtitleRenderer, SUBTITLES_OFF } from './subtitles'
5
5
  export type { SubtitleRenderer, SubtitleRendererOptions, SubtitleStream } from './subtitles'
@@ -11,6 +11,18 @@ export type { AudioStream }
11
11
  /** A keyframe index entry: the byte offset a keyframe starts at, and the time it plays at. */
12
12
  export type MediaIndex = { pos: number, timestamp: number }
13
13
 
14
+ /**
15
+ * One named span of the timeline, in SECONDS.
16
+ *
17
+ * Chapters are not required to tile the duration: a container routinely declares a last chapter that
18
+ * ends fractionally before the file does, and nothing guarantees the first starts at zero. Anything
19
+ * drawing them has to treat the gaps as ordinary un-named time rather than assume full cover.
20
+ *
21
+ * libav also reports an `index`, dropped here the way `MediaIndex` drops it: array position already
22
+ * carries it, and a caller supplying their own chapters should not have to number them.
23
+ */
24
+ export type MediaChapter = { start: number, end: number, title: string }
25
+
14
26
  export type PlaybackOptions = {
15
27
  videoElement: HTMLVideoElement
16
28
  /**
@@ -55,6 +67,8 @@ export type PlaybackController = {
55
67
  selectSubtitleStream: (streamIndex: number | undefined) => void
56
68
  /** Keyframe index of the input, which is what maps a downloaded byte range onto the timeline. */
57
69
  indexes: MediaIndex[]
70
+ /** Named spans the container declared, empty when it declared none. */
71
+ chapters: MediaChapter[]
58
72
  duration: number
59
73
  videoMimeType: string
60
74
  audioMimeType: string
@@ -643,6 +657,7 @@ export const startPlayback = async (options: PlaybackOptions): Promise<PlaybackC
643
657
  videoElement,
644
658
  selectSubtitleStream: (streamIndex: number | undefined) => subtitles.selectStream(streamIndex),
645
659
  indexes: metadata.indexes ?? [],
660
+ chapters: metadata.chapters ?? [],
646
661
  duration: metadata.info.input.duration,
647
662
  videoMimeType: metadata.info.output.videoMimeType,
648
663
  audioMimeType: metadata.info.output.audioMimeType,
package/src/lib/index.tsx CHANGED
@@ -35,6 +35,7 @@ export type { RemuxerInput } from './utils/source'
35
35
  // The engine is also published on its own subpath for consumers that want the pipeline with no React.
36
36
  export type {
37
37
  AudioStream,
38
+ MediaChapter,
38
39
  MediaIndex,
39
40
  PictureInPictureController,
40
41
  PlaybackController,
@@ -8,6 +8,7 @@ import { usePlayer } from '../player'
8
8
  import { Overlay } from './overlay'
9
9
  import ControlBar from './control-bar'
10
10
  import BurnInHint from './burn-in-hint'
11
+ import SkipChapter from './skip-chapter'
11
12
 
12
13
  const AUTO_HIDE_DELAY = 3_000
13
14
 
@@ -233,6 +234,7 @@ export const Chrome = ({ ref, onVideoRef, onSubtitleRef, overlay, controls, chil
233
234
  {controls === false ? null : <ControlBar />}
234
235
  {/* Not tied to `hideUI`: it says what to do next, and it is on screen for nine seconds. */}
235
236
  <BurnInHint />
237
+ <SkipChapter />
236
238
  <div className="video" onClick={onVideoClick}>
237
239
  {onVideoRef ? <video ref={onVideoRef} playsInline /> : null}
238
240
  {children}
@@ -7,6 +7,8 @@ import { css } from '@emotion/react'
7
7
  import { usePlayer } from '../player'
8
8
  import { useDragValue } from '../hooks/use-drag-value'
9
9
  import { fonts } from '../../utils/fonts'
10
+ import { formatTime } from '../../utils/time'
11
+ import { segmentBounds, segmentMask } from '../../utils/chapters'
10
12
 
11
13
  const style = css`
12
14
  position: relative;
@@ -41,6 +43,37 @@ const style = css`
41
43
  }
42
44
  }
43
45
 
46
+ /*
47
+ * One copy of the track per mask, stacked.
48
+ *
49
+ * The rest track paints every segment except the one under the pointer, the focus track paints only that one and
50
+ * is the only element that grows. Both are full bar width, which is what keeps every percentage
51
+ * and every scaleX inside them meaning exactly what it meant before chapters existed: the gaps are
52
+ * cut by a mask, not by resizing anything.
53
+ *
54
+ * The mask sits HERE and never on .loaded-part or .play. Those two carry a scaleX, and a mask
55
+ * travels with its element's transform, so a gap drawn on them would slide with the fill.
56
+ */
57
+ .track {
58
+ position: absolute;
59
+ inset: 0;
60
+ /* never a hit target: .padding is the only one, and a second would break the drag gesture and
61
+ fire a bubbling mouseout at every boundary crossing */
62
+ pointer-events: none;
63
+ }
64
+
65
+ /*
66
+ * The segment under the pointer, on TOP of the growth the whole bar already got.
67
+ *
68
+ * Measured off YouTube's live player, hovering one chapter of five: every other segment goes 4px
69
+ * to 6px, and the one under the pointer goes to 10px. Both grow about the bar's centre line, so
70
+ * the emphasised segment bulges above and below its neighbours rather than sitting on them. 6 x
71
+ * 1.667 is 10, and the 1.5 the layers inside already carry gets this track from 4 to exactly that.
72
+ */
73
+ .track.focus {
74
+ transform: scaleY(1.667);
75
+ }
76
+
44
77
 
45
78
  .background-bar {
46
79
  position: absolute;
@@ -64,6 +97,23 @@ const style = css`
64
97
  text-shadow: 0 0 4px rgba(0, 0, 0, 1);
65
98
  ${fonts.bMedium.bold}
66
99
 
100
+ gap: calc(.6 * var(--mp-unit));
101
+
102
+ /*
103
+ * The chapter under the pointer, next to the time, which is where the reference puts it.
104
+ *
105
+ * It is the one part of this pill whose width the player does not control, so it is capped and
106
+ * ellipsised rather than allowed to push the box outside the picture. The cap and the clamp that
107
+ * positions the pill are set together on the element, since a centred box can only be kept
108
+ * inside the bar if the clamp knows its half width.
109
+ */
110
+ .chapter-title {
111
+ overflow: hidden;
112
+ text-overflow: ellipsis;
113
+ min-width: 0;
114
+ font-weight: normal;
115
+ }
116
+
67
117
  position: absolute;
68
118
  /* Anchored on its bottom edge rather than its top: now that it has a background its height
69
119
  follows the font size, and a top-anchored box would grow downward into the track. */
@@ -195,6 +245,7 @@ export const ProgressBar = () => {
195
245
  const thumbnails = usePlayer((state) => state.thumbnails)
196
246
  const thumbnailAt = usePlayer((state) => state.thumbnailAt)
197
247
  const requestThumbnail = usePlayer((state) => state.requestThumbnail)
248
+ const chapters = usePlayer((state) => state.chapters)
198
249
 
199
250
  const progressBarRef = useRef<HTMLDivElement>(null)
200
251
 
@@ -326,17 +377,6 @@ export const ProgressBar = () => {
326
377
  [duration, indexes.length, downloadedRanges?.map(({ startByteOffset, endByteOffset }) => `${startByteOffset}/${endByteOffset}`).join(',')]
327
378
  )
328
379
 
329
- const cusorTimeString = useMemo(() => {
330
- if (!progressBarHoverTime || progressBarHoverTime < 0) return undefined
331
- const hours = Math.floor(progressBarHoverTime! / 3600)
332
- const minutes = Math.floor((progressBarHoverTime! - hours * 3600) / 60)
333
- const seconds = Math.floor(progressBarHoverTime! - hours * 3600 - minutes * 60)
334
- const hoursString =
335
- hours > 0
336
- ? `${hours}:`
337
- : ''
338
- return `${hoursString}${minutes < 10 ? '0' : ''}${minutes}:${seconds < 10 ? '0' : ''}${seconds}`
339
- }, [progressBarHoverTime])
340
380
 
341
381
  /*
342
382
  * Follow the pointer while it is actually moving, and NOT on the press itself.
@@ -359,6 +399,38 @@ export const ProgressBar = () => {
359
399
  : currentTime / duration
360
400
  }, [duration, currentTime])
361
401
 
402
+ const bounds = useMemo(() => segmentBounds(chapters, duration), [chapters, duration])
403
+ const segmented = bounds.length > 0
404
+
405
+ /*
406
+ * Which segment the pointer is in, or -1.
407
+ *
408
+ * Compared against `undefined` rather than tested for truth: a hover at exactly time zero is a
409
+ * real hover, and the falsy check used elsewhere in this file silently drops it.
410
+ */
411
+ const focusedSegment = useMemo(() => {
412
+ if (!segmented || progressBarHoverTime === undefined || !duration) return -1
413
+ const at = (progressBarHoverTime / duration) * 100
414
+ const found = bounds.findIndex((from, i) => i < bounds.length - 1 && at >= from && at < bounds[i + 1]!)
415
+ // past the last boundary the pointer is in the final segment, which no `at < to` test catches
416
+ return found >= 0 ? found : bounds.length - 2
417
+ }, [segmented, bounds, progressBarHoverTime, duration])
418
+
419
+ const restMask = useMemo(
420
+ () => segmented ? segmentMask(bounds, (i) => i !== focusedSegment) : undefined,
421
+ [segmented, bounds, focusedSegment],
422
+ )
423
+ const focusMask = useMemo(
424
+ () => focusedSegment >= 0 ? segmentMask(bounds, (i) => i === focusedSegment) : undefined,
425
+ [bounds, focusedSegment],
426
+ )
427
+
428
+ /** The chapter the pointer is over. Absent while it is over un-named time between chapters. */
429
+ const hoveredChapter = useMemo(() => {
430
+ if (progressBarHoverTime === undefined) return undefined
431
+ return chapters.find(({ start, end }) => start <= progressBarHoverTime && progressBarHoverTime < end)
432
+ }, [chapters, progressBarHoverTime])
433
+
362
434
  // an empty url is a gap sentinel, so it renders nothing
363
435
  const thumbnail = useMemo(() => {
364
436
  if (!progressBarHoverTime) return undefined
@@ -372,41 +444,71 @@ export const ProgressBar = () => {
372
444
  )
373
445
  }, [thumbnails, thumbnailAt, progressBarHoverTime])
374
446
 
447
+ /*
448
+ * The whole track, drawn once per mask.
449
+ *
450
+ * Every layer inside is full bar width whichever mask it carries, so the loaded parts' percentage
451
+ * offsets and both scaleX transforms keep meaning what they meant before chapters existed. Only
452
+ * what is painted differs.
453
+ */
454
+ const track = (variant: 'focus' | undefined, mask: string | undefined) => (
455
+ <div
456
+ className={variant ? `track ${variant}` : 'track'}
457
+ style={mask ? { maskImage: mask } : undefined}
458
+ >
459
+ <div className="background-bar" />
460
+ {/* bar showing the currently loaded progress */}
461
+ <div className="loaded">
462
+ {loadedParts}
463
+ </div>
464
+ {/* bar displaying the current playback progress */}
465
+ <div className="play-container">
466
+ <div className="play" style={{ transform: `scaleX(${scaleX})` }}></div>
467
+ </div>
468
+ </div>
469
+ )
470
+
375
471
  return (
376
472
  <div
377
473
  css={style}
378
474
  ref={progressBarRef}
379
- className={dragging ? 'progress-bar dragging' : 'progress-bar'}
475
+ className={[
476
+ 'progress-bar',
477
+ dragging ? 'dragging' : '',
478
+ segmented ? 'segmented' : '',
479
+ ].filter(Boolean).join(' ')}
380
480
  onMouseMove={onProgressBarOver}
381
481
  onMouseOut={hideProgressBarTime}
382
482
  >
383
- <div className="background-bar" />
483
+ {track(undefined, restMask)}
384
484
  {
385
485
  progressBarHoverTime
386
486
  ? (
387
487
  <div
388
488
  className="cursor-time"
389
489
  /* the inset grew with the pill: content sized and centred, its half width is now the
390
- padding plus the text, so the old 18px let a filled box hang past both ends */
391
- style={{ left: `clamp(calc(3 * var(--mp-unit)), ${timePercentage(progressBarHoverTime)}%, calc(100% - calc(3 * var(--mp-unit))))` }}
490
+ padding plus the text, so the old 18px let a filled box hang past both ends. With a
491
+ chapter title the box is wider again and capped, so the clamp switches to half that
492
+ cap, which is the only width a centred box can be kept inside the bar by. */
493
+ style={{
494
+ left: hoveredChapter
495
+ ? `clamp(var(--thumbnail-half), ${timePercentage(progressBarHoverTime)}%, calc(100% - var(--thumbnail-half)))`
496
+ : `clamp(calc(3 * var(--mp-unit)), ${timePercentage(progressBarHoverTime)}%, calc(100% - calc(3 * var(--mp-unit))))`,
497
+ maxWidth: hoveredChapter ? 'var(--thumbnail-width)' : undefined,
498
+ }}
392
499
  >
393
- {cusorTimeString}
500
+ <span className="cursor-time-value">{formatTime(progressBarHoverTime)}</span>
501
+ {hoveredChapter ? <span className="chapter-title">{hoveredChapter.title}</span> : undefined}
394
502
  </div>
395
503
  )
396
504
  : undefined
397
505
  }
398
506
  <div className="progress"></div>
399
- {/* bar showing the currently loaded progress */}
400
- <div className="loaded">
401
- {loadedParts}
402
- </div>
403
507
  {/* bar to show when hovering to potentially seek */}
404
508
  <div className="hover"></div>
405
- {/* bar displaying the current playback progress */}
406
- <div className='play-container'>
407
- <div className="play" style={{ transform: `scaleX(${scaleX})` }}></div>
408
- </div>
409
- <div className="chapters"></div>
509
+ {/* the segment under the pointer, drawn taller. Sits after the flat track so it paints over
510
+ it, and before .padding so it never takes the press. */}
511
+ {focusMask ? track('focus', focusMask) : undefined}
410
512
  <div className="scrubber"></div>
411
513
  <div
412
514
  className="padding"
@@ -0,0 +1,166 @@
1
+ import { useEffect, useMemo, useRef, useState } from 'react'
2
+ import { css } from '@emotion/react'
3
+
4
+ import { fonts } from '../../utils/fonts'
5
+ import { classifyChapters } from '../../utils/chapters'
6
+ import { usePlayer } from '../player'
7
+
8
+ /**
9
+ * How far AHEAD of the chapter the offer appears, in seconds.
10
+ *
11
+ * It arrives just before the opening rather than during it, so the button is already on screen and
12
+ * readable at the moment the theme starts rather than turning up over it. Waiting until the boundary
13
+ * is crossed puts it a beat late, which is what this exists to fix.
14
+ */
15
+ const OFFER_LEAD_S = 1
16
+ /**
17
+ * How long the offer then stays on screen.
18
+ *
19
+ * Six, so that with the second of lead above about five of them fall inside the opening itself.
20
+ *
21
+ * Short on purpose. The button is a suggestion drawn from a chapter title, and a title can be wrong,
22
+ * so the cost of a mistake is capped at a few seconds of a button nobody wanted rather than a jump
23
+ * out of the episode. Nothing is ever skipped without a press.
24
+ */
25
+ const OFFER_MS = 6_000
26
+ /** A jump back by more than this is a seek rather than playback, and re-opens the offer. */
27
+ const SEEK_BACK_S = 1
28
+
29
+ const LABELS = { opening: 'Skip Opening', ending: 'Skip Ending' } as const
30
+
31
+ const style = css`
32
+ position: absolute;
33
+ inset: 0;
34
+ z-index: 2;
35
+ pointer-events: none;
36
+
37
+ button {
38
+ position: absolute;
39
+ /* clear of the control bar, which is 6 to 8px of padding plus a row of 18 to 28px controls and
40
+ the seekbar above them */
41
+ bottom: calc(7 * var(--mp-unit));
42
+ right: calc(2.4 * var(--mp-unit));
43
+
44
+ /* the one thing in this layer that can be pressed */
45
+ pointer-events: auto;
46
+ cursor: pointer;
47
+
48
+ ${fonts.bMedium.bold}
49
+ color: #fff;
50
+ padding: calc(1 * var(--mp-unit)) calc(1.8 * var(--mp-unit));
51
+ border: 1px solid rgba(255, 255, 255, .55);
52
+ border-radius: calc(.4 * var(--mp-unit));
53
+ background-color: rgba(20, 20, 22, .8);
54
+ box-shadow: 0 0 calc(1 * var(--mp-unit)) rgba(0, 0, 0, .5);
55
+
56
+ opacity: 0;
57
+ transform: translateY(calc(.6 * var(--mp-unit)));
58
+ /* visibility as well as opacity, so a faded button cannot be hovered or pressed */
59
+ visibility: hidden;
60
+ transition: opacity .18s ease, transform .18s ease, visibility .18s;
61
+
62
+ &:hover, &:focus-visible {
63
+ background-color: rgba(255, 255, 255, .92);
64
+ color: #111;
65
+ border-color: transparent;
66
+ }
67
+ }
68
+
69
+ &.show button {
70
+ opacity: 1;
71
+ transform: none;
72
+ visibility: visible;
73
+ }
74
+
75
+ @media (pointer: coarse) {
76
+ button {
77
+ /* a finger needs a target, and the control row below is already 44px */
78
+ min-height: 44px;
79
+ bottom: calc(9 * var(--mp-unit));
80
+ }
81
+ }
82
+ `
83
+
84
+ /**
85
+ * Offers to jump past an opening or ending, when the chapter under the playhead looks like one.
86
+ *
87
+ * The whole feature is this button plus `classifyChapters`, and it is deliberately the weaker half
88
+ * of the pair: the classifier reads titles written by whoever muxed the file, so it is sometimes
89
+ * going to be wrong. Making the offer expire, and never acting on its own, is what makes being wrong
90
+ * cheap. See the classifier for what it matches and the sample it was measured against.
91
+ */
92
+ export const SkipChapter = () => {
93
+ const chapters = usePlayer((state) => state.chapters)
94
+ const currentTime = usePlayer((state) => state.currentTime)
95
+ const requestSeek = usePlayer((state) => state.requestSeek)
96
+ const player = usePlayer()
97
+
98
+ const kinds = useMemo(() => classifyChapters(chapters), [chapters])
99
+
100
+ /**
101
+ * The chapter worth offering to skip, from a second before it starts until it ends.
102
+ *
103
+ * The window opens EARLY rather than on the boundary, which is the whole difference between the
104
+ * button being on screen when the theme arrives and turning up on top of it.
105
+ */
106
+ const skippable = useMemo(() => {
107
+ if (typeof currentTime !== 'number') return undefined
108
+ // only skippable chapters are searched: a second before one starts the playhead is still inside
109
+ // its neighbour, so looking for "the chapter containing the playhead" would find the wrong one
110
+ const index = chapters.findIndex((chapter, i) =>
111
+ kinds[i] !== undefined && chapter.start - OFFER_LEAD_S <= currentTime && currentTime < chapter.end)
112
+ const kind = index >= 0 ? kinds[index] : undefined
113
+ return kind ? { kind, end: chapters[index]!.end } : undefined
114
+ }, [chapters, kinds, currentTime])
115
+
116
+ /*
117
+ * A backwards jump re-opens the offer.
118
+ *
119
+ * Without it, seeking back to watch an opening again leaves no way to skip it a second time: the
120
+ * chapter has not changed, so the effect below would not re-run and the offer would stay closed.
121
+ */
122
+ const lastTime = useRef(0)
123
+ const [seekEpoch, setSeekEpoch] = useState(0)
124
+ useEffect(() => {
125
+ const time = typeof currentTime === 'number' ? currentTime : 0
126
+ const previous = lastTime.current
127
+ lastTime.current = time
128
+ if (time < previous - SEEK_BACK_S) setSeekEpoch((n) => n + 1)
129
+ }, [currentTime])
130
+
131
+ const [show, setShow] = useState(false)
132
+ // keyed on which chapter it is, so playing through one offer does not re-open it
133
+ const at = skippable ? `${skippable.kind}@${skippable.end}` : ''
134
+ useEffect(() => {
135
+ if (!at) {
136
+ setShow(false)
137
+ return
138
+ }
139
+ setShow(true)
140
+ const close = setTimeout(() => setShow(false), OFFER_MS)
141
+ return () => clearTimeout(close)
142
+ }, [at, seekEpoch])
143
+
144
+ // the offer closes the moment the playhead leaves, however it left
145
+ useEffect(() => { if (!skippable) setShow(false) }, [skippable])
146
+
147
+ if (!skippable) return null
148
+
149
+ const skip = () => {
150
+ setShow(false)
151
+ // the chrome's own seek, which puts the data in place first: landing on unbuffered ground is
152
+ // what wedges firefox's decoder, and the end of an opening is ground nothing has read yet
153
+ if (requestSeek) requestSeek(skippable.end)
154
+ else player.seek(skippable.end)
155
+ }
156
+
157
+ return (
158
+ <div css={style} className={show ? 'show' : ''}>
159
+ <button type='button' className='skip-chapter' onClick={skip}>
160
+ {LABELS[skippable.kind]}
161
+ </button>
162
+ </div>
163
+ )
164
+ }
165
+
166
+ export default SkipChapter
@@ -73,10 +73,20 @@ export const usePlayback = (
73
73
  // null, and the effect below returns before touching them in that case.
74
74
  const {
75
75
  read, size, publicPath = '', libavWorkerUrl = '', jassubWorkerUrl = '', jassubWasmUrl = '',
76
- jassubLegacyWasmUrl, defaultFontUrl, bufferSize, autoplay = false,
76
+ jassubLegacyWasmUrl, defaultFontUrl, bufferSize, autoplay = false, chapters,
77
77
  seekPrepareBudgetMs = SEEK_PREPARE_BUDGET_MS,
78
78
  } = options ?? ({} as Partial<MediaPlayerLocalOptions>)
79
79
 
80
+ /*
81
+ * Through a ref because a caller's array is a new identity every render.
82
+ *
83
+ * In the effect's dependencies it would tear down and restart the whole pipeline on each render.
84
+ * Only its value at publish time matters, and a later change is picked up by the effect in
85
+ * `video-player.tsx` that owns the other half of this precedence.
86
+ */
87
+ const chaptersRef = useRef(chapters)
88
+ chaptersRef.current = chapters
89
+
80
90
  // The track the viewer picked, which is what a restart is keyed on. Distinct from the store's
81
91
  // `selectedAudioStream`, which is whatever is playing right now.
82
92
  const [audioStreamIndex, setAudioStreamIndex] = useState<number | undefined>(undefined)
@@ -335,7 +345,12 @@ export const usePlayback = (
335
345
  return
336
346
  }
337
347
  controllerRef.current = controller
338
- player.setSourceState({ indexes: controller.indexes })
348
+ // a caller's chapters beat the container's, and this is the writer that would otherwise
349
+ // land last and overwrite them
350
+ player.setSourceState({
351
+ indexes: controller.indexes,
352
+ chapters: chaptersRef.current ?? controller.chapters,
353
+ })
339
354
  // a track chosen before this pipeline existed has to be re-applied to the new renderer
340
355
  const chosen = player.selectedSubtitleTrack
341
356
  if (typeof chosen === 'number') controller.selectSubtitleStream(chosen)
@@ -1,4 +1,4 @@
1
- import type { MediaIndex, PictureInPictureMode, ThumbnailImage } from '../engine'
1
+ import type { MediaChapter, MediaIndex, PictureInPictureMode, ThumbnailImage } from '../engine'
2
2
 
3
3
  import { definePlayerFeature } from '@videojs/core/dom'
4
4
 
@@ -77,6 +77,14 @@ export type SourceState = {
77
77
 
78
78
  /** Keyframe index of the input, which turns a downloaded byte range into a time range. */
79
79
  indexes: MediaIndex[]
80
+ /**
81
+ * Named spans of the timeline, drawn as segments on the seekbar. Empty when the source has none.
82
+ *
83
+ * Ordered by start and non-overlapping, which is what the seekbar assumes. They need not cover the
84
+ * whole duration: the engine passes on whatever the container declared, and a caller-supplied list
85
+ * is whatever the caller knows.
86
+ */
87
+ chapters: MediaChapter[]
80
88
  thumbnails: ThumbnailImage[]
81
89
  /**
82
90
  * Answers for one time directly, when the source has a storyboard it can index but not enumerate.
@@ -178,6 +186,7 @@ export type SourceState = {
178
186
 
179
187
  const initialState: SourceState = {
180
188
  indexes: [],
189
+ chapters: [],
181
190
  thumbnails: [],
182
191
  requestThumbnail: () => {},
183
192
  subtitleTracks: [],