@banou/media-player 0.9.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.
- package/README.md +40 -4
- package/dist/engine/index.d.ts +1 -1
- package/dist/engine/index.js +1 -1
- package/dist/engine/playback.d.ts +17 -0
- package/dist/engine/thumbnails.d.ts +11 -0
- package/dist/{engine-DZrYu66u.js → engine-b5_5HscR.js} +36 -19
- package/dist/index.d.ts +1 -1
- package/dist/index.js +641 -337
- package/dist/react/components/skip-chapter.d.ts +10 -0
- package/dist/react/hooks/use-thumbnails.d.ts +12 -1
- package/dist/react/player.d.ts +2 -0
- package/dist/react/source-feature.d.ts +39 -1
- package/dist/react/video-player.d.ts +13 -0
- package/dist/utils/chapters.d.ts +37 -0
- package/package.json +1 -1
- package/src/lib/engine/index.ts +1 -1
- package/src/lib/engine/playback.ts +15 -0
- package/src/lib/engine/thumbnails.ts +80 -21
- package/src/lib/index.tsx +1 -0
- package/src/lib/react/components/chrome.tsx +2 -0
- package/src/lib/react/components/progress-bar.tsx +144 -29
- package/src/lib/react/components/skip-chapter.tsx +166 -0
- package/src/lib/react/hooks/use-playback.ts +17 -2
- package/src/lib/react/hooks/use-thumbnails.ts +19 -3
- package/src/lib/react/source-feature.ts +22 -1
- package/src/lib/react/video-player.tsx +32 -3
- package/src/lib/utils/chapters.ts +302 -0
- package/src/lib/utils/source.ts +12 -1
|
@@ -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;
|
|
@@ -9,4 +9,15 @@ export type UseSeekThumbnailsOptions = {
|
|
|
9
9
|
/** When omitted the whole file is treated as readable, which is the case for a local file. */
|
|
10
10
|
downloadedRanges?: DownloadedRange[];
|
|
11
11
|
};
|
|
12
|
-
export
|
|
12
|
+
export type SeekThumbnails = {
|
|
13
|
+
thumbnails: ThumbnailImage[];
|
|
14
|
+
/**
|
|
15
|
+
* Where the viewer is pointing on the seekbar, so that preview is decoded next.
|
|
16
|
+
*
|
|
17
|
+
* Stable for the life of the hook, so it can be published to the store once and called from a
|
|
18
|
+
* pointermove without re-rendering anything. A no-op until the generator boots, and on a source
|
|
19
|
+
* that brings its own storyboard.
|
|
20
|
+
*/
|
|
21
|
+
requestThumbnail: (time: number | undefined) => void;
|
|
22
|
+
};
|
|
23
|
+
export declare const useSeekThumbnails: ({ publicPath, workerUrl, length, read, downloadedRanges, }: UseSeekThumbnailsOptions) => SeekThumbnails;
|
package/dist/react/player.d.ts
CHANGED
|
@@ -3,8 +3,10 @@ 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;
|
|
9
|
+
requestThumbnail: (time: number | undefined) => void;
|
|
8
10
|
subtitleTracks: import("./source-feature").TrackChoice[];
|
|
9
11
|
selectedSubtitleTrack: string | number | undefined;
|
|
10
12
|
selectSubtitleTrack: (id: string | number | undefined) => void | Promise<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,12 +70,31 @@ 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.
|
|
76
84
|
* Falls back to scanning `thumbnails` when absent, which is what the engine's generator fills.
|
|
77
85
|
*/
|
|
78
86
|
thumbnailAt?: (time: number) => ThumbnailImage | undefined;
|
|
87
|
+
/**
|
|
88
|
+
* Where the pointer is on the seekbar, so the preview under it is generated before the rest.
|
|
89
|
+
*
|
|
90
|
+
* Generation otherwise walks the file start to end, so pointing at the last third of a long video
|
|
91
|
+
* means waiting out everything before it. Called on every pointermove, which is why it is a
|
|
92
|
+
* callback on the store rather than a piece of state: a hover time held in the store would
|
|
93
|
+
* re-render every subscriber for each move.
|
|
94
|
+
*
|
|
95
|
+
* A no-op on a source that brings its own storyboard, and until the generator has booted.
|
|
96
|
+
*/
|
|
97
|
+
requestThumbnail: (time: number | undefined) => void;
|
|
79
98
|
/**
|
|
80
99
|
* Both selectors may answer with a promise, and the menu waits on it.
|
|
81
100
|
*
|
|
@@ -157,12 +176,31 @@ export declare const sourceFeature: import("@videojs/react").PlayerFeature<{
|
|
|
157
176
|
downloadedRanges?: DownloadedRange[];
|
|
158
177
|
/** Keyframe index of the input, which turns a downloaded byte range into a time range. */
|
|
159
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[];
|
|
160
187
|
thumbnails: ThumbnailImage[];
|
|
161
188
|
/**
|
|
162
189
|
* Answers for one time directly, when the source has a storyboard it can index but not enumerate.
|
|
163
190
|
* Falls back to scanning `thumbnails` when absent, which is what the engine's generator fills.
|
|
164
191
|
*/
|
|
165
192
|
thumbnailAt?: (time: number) => ThumbnailImage | undefined;
|
|
193
|
+
/**
|
|
194
|
+
* Where the pointer is on the seekbar, so the preview under it is generated before the rest.
|
|
195
|
+
*
|
|
196
|
+
* Generation otherwise walks the file start to end, so pointing at the last third of a long video
|
|
197
|
+
* means waiting out everything before it. Called on every pointermove, which is why it is a
|
|
198
|
+
* callback on the store rather than a piece of state: a hover time held in the store would
|
|
199
|
+
* re-render every subscriber for each move.
|
|
200
|
+
*
|
|
201
|
+
* A no-op on a source that brings its own storyboard, and until the generator has booted.
|
|
202
|
+
*/
|
|
203
|
+
requestThumbnail: (time: number | undefined) => void;
|
|
166
204
|
/**
|
|
167
205
|
* Both selectors may answer with a promise, and the menu waits on it.
|
|
168
206
|
*
|
|
@@ -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
package/src/lib/engine/index.ts
CHANGED
|
@@ -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,
|
|
@@ -29,6 +29,17 @@ const KEYFRAME_TIMEOUT = 10_000
|
|
|
29
29
|
export type ThumbnailGenerator = {
|
|
30
30
|
/** Report which byte ranges are readable. Called with no argument when the whole file is. */
|
|
31
31
|
update: (ranges?: [number, number][]) => void
|
|
32
|
+
/**
|
|
33
|
+
* Where the viewer is pointing, so that preview is decoded next. `undefined` when they stop.
|
|
34
|
+
*
|
|
35
|
+
* Only the slot covering `time` jumps the queue, and only for as long as it is still waiting, so
|
|
36
|
+
* this moves one preview forward rather than re-ordering the run. Everything behind it keeps the
|
|
37
|
+
* order it was claimed in and carries on the moment the jumped slot is done.
|
|
38
|
+
*
|
|
39
|
+
* It cannot interrupt a decode that has already started, so the wait is the tail of the one in
|
|
40
|
+
* flight and not the whole backlog.
|
|
41
|
+
*/
|
|
42
|
+
prioritize: (time: number | undefined) => void
|
|
32
43
|
destroy: () => void
|
|
33
44
|
}
|
|
34
45
|
|
|
@@ -68,7 +79,35 @@ export const createThumbnailGenerator = async (options: ThumbnailGeneratorOption
|
|
|
68
79
|
|
|
69
80
|
let thumbnails: ThumbnailImage[] = []
|
|
70
81
|
let destroyed = false
|
|
71
|
-
|
|
82
|
+
|
|
83
|
+
/*
|
|
84
|
+
* Slots claimed for decoding but not yet started, in the order they were claimed.
|
|
85
|
+
*
|
|
86
|
+
* A promise chain used to be this queue, which fixed the running order at the moment each slot
|
|
87
|
+
* was chained on and left nothing a hover could reach: the preview under the pointer waited
|
|
88
|
+
* behind every slot already queued, which on a long file is the rest of the run. The order is
|
|
89
|
+
* the same, it is just held somewhere a pick can look into.
|
|
90
|
+
*/
|
|
91
|
+
const pending: Slot[] = []
|
|
92
|
+
let running = false
|
|
93
|
+
/** Where the pointer is on the seekbar, or undefined when it is off it. */
|
|
94
|
+
let priorityTime: number | undefined
|
|
95
|
+
|
|
96
|
+
/*
|
|
97
|
+
* The slot to decode next: the one under the pointer when it is still waiting, else the oldest claim.
|
|
98
|
+
*
|
|
99
|
+
* Requiring the slot to COVER the time is what keeps this a single jump rather than a re-sort
|
|
100
|
+
* around the cursor. Once that slot is decoded nothing covers the pointer any more, so the very
|
|
101
|
+
* next pick is the oldest claim again and the sequential walk carries on where it left off.
|
|
102
|
+
*/
|
|
103
|
+
const nextIndex = () => {
|
|
104
|
+
const at = priorityTime
|
|
105
|
+
if (at !== undefined) {
|
|
106
|
+
const hit = pending.findIndex(({ timestamp, endTime }) => timestamp <= at && at < endTime)
|
|
107
|
+
if (hit >= 0) return hit
|
|
108
|
+
}
|
|
109
|
+
return 0
|
|
110
|
+
}
|
|
72
111
|
|
|
73
112
|
// the slider assumes a gapless storyboard, so gaps get sentinels the UI hides
|
|
74
113
|
const emit = () => {
|
|
@@ -88,29 +127,44 @@ export const createThumbnailGenerator = async (options: ThumbnailGeneratorOption
|
|
|
88
127
|
onThumbnails(display)
|
|
89
128
|
}
|
|
90
129
|
|
|
91
|
-
const
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
.
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
130
|
+
const decode = async (slot: Slot) => {
|
|
131
|
+
if (destroyed) return
|
|
132
|
+
const png = await Promise.race([
|
|
133
|
+
remuxer.readKeyframe(slot.timestamp),
|
|
134
|
+
new Promise<never>((_, reject) => setTimeout(() => reject(new Error('timed out')), KEYFRAME_TIMEOUT)),
|
|
135
|
+
])
|
|
136
|
+
const bitmap = await createImageBitmap(new Blob([png], { type: 'image/png' }))
|
|
137
|
+
const canvas = new OffscreenCanvas(width, Math.max(1, Math.round(bitmap.height * (width / bitmap.width))))
|
|
138
|
+
canvas.getContext('2d')!.drawImage(bitmap, 0, 0, canvas.width, canvas.height)
|
|
139
|
+
bitmap.close()
|
|
140
|
+
const blob = await canvas.convertToBlob({ type: 'image/webp', quality: 0.7 })
|
|
141
|
+
if (destroyed) return
|
|
142
|
+
thumbnails = [...thumbnails, { url: URL.createObjectURL(blob), startTime: slot.timestamp, endTime: slot.endTime }]
|
|
143
|
+
.sort((a, b) => a.startTime - b.startTime)
|
|
144
|
+
emit()
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// one decode at a time, because there is one wasm worker behind them all
|
|
148
|
+
const pump = () => {
|
|
149
|
+
if (running || destroyed || !pending.length) return
|
|
150
|
+
const slot = pending.splice(nextIndex(), 1)[0]!
|
|
151
|
+
running = true
|
|
152
|
+
void decode(slot)
|
|
110
153
|
.catch(() => {
|
|
111
154
|
slot.attempts += 1
|
|
155
|
+
// left claimable again, so a later `update` retries it
|
|
112
156
|
slot.done = slot.attempts >= MAX_ATTEMPTS
|
|
113
157
|
})
|
|
158
|
+
.finally(() => {
|
|
159
|
+
running = false
|
|
160
|
+
pump()
|
|
161
|
+
})
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const claim = (slot: Slot) => {
|
|
165
|
+
slot.done = true
|
|
166
|
+
pending.push(slot)
|
|
167
|
+
pump()
|
|
114
168
|
}
|
|
115
169
|
|
|
116
170
|
emit()
|
|
@@ -120,11 +174,16 @@ export const createThumbnailGenerator = async (options: ThumbnailGeneratorOption
|
|
|
120
174
|
if (destroyed) return
|
|
121
175
|
for (const slot of slots) {
|
|
122
176
|
if (slot.done) continue
|
|
123
|
-
if (!ranges || ranges.some(([from, to]) => from <= slot.startByte && slot.endByte <= to))
|
|
177
|
+
if (!ranges || ranges.some(([from, to]) => from <= slot.startByte && slot.endByte <= to)) claim(slot)
|
|
124
178
|
}
|
|
125
179
|
},
|
|
180
|
+
prioritize: (time) => {
|
|
181
|
+
if (destroyed) return
|
|
182
|
+
priorityTime = time
|
|
183
|
+
},
|
|
126
184
|
destroy: () => {
|
|
127
185
|
destroyed = true
|
|
186
|
+
pending.length = 0
|
|
128
187
|
for (const t of thumbnails) URL.revokeObjectURL(t.url)
|
|
129
188
|
thumbnails = []
|
|
130
189
|
terminateRemuxer(remuxer)
|
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. */
|
|
@@ -194,12 +244,26 @@ export const ProgressBar = () => {
|
|
|
194
244
|
const indexes = usePlayer((state) => state.indexes)
|
|
195
245
|
const thumbnails = usePlayer((state) => state.thumbnails)
|
|
196
246
|
const thumbnailAt = usePlayer((state) => state.thumbnailAt)
|
|
247
|
+
const requestThumbnail = usePlayer((state) => state.requestThumbnail)
|
|
248
|
+
const chapters = usePlayer((state) => state.chapters)
|
|
197
249
|
|
|
198
250
|
const progressBarRef = useRef<HTMLDivElement>(null)
|
|
199
251
|
|
|
200
252
|
const [seekFraction, setSeekFraction] = useState<number | undefined>(undefined)
|
|
201
253
|
const [progressBarHoverTime, setProgressBarOverTime] = useState<number | undefined>(undefined)
|
|
202
254
|
|
|
255
|
+
/*
|
|
256
|
+
* Move the preview and the generator's next pick together.
|
|
257
|
+
*
|
|
258
|
+
* The frame under the pointer is both the one drawn and the one worth decoding first, and every
|
|
259
|
+
* place that opens or closes the preview goes through here so the two can never disagree. The
|
|
260
|
+
* request is a bare assignment inside the generator, so a pointermove costs nothing extra.
|
|
261
|
+
*/
|
|
262
|
+
const showPreviewAt = (time: number | undefined) => {
|
|
263
|
+
setProgressBarOverTime(time)
|
|
264
|
+
requestThumbnail(time)
|
|
265
|
+
}
|
|
266
|
+
|
|
203
267
|
// onChange reports a bare fraction, so the device that opened the gesture is recorded on press
|
|
204
268
|
const dragPointerType = useRef<string | undefined>(undefined)
|
|
205
269
|
|
|
@@ -235,7 +299,7 @@ export const ProgressBar = () => {
|
|
|
235
299
|
}
|
|
236
300
|
setSeekFraction(fraction)
|
|
237
301
|
if (dragPointerType.current === 'mouse') return
|
|
238
|
-
|
|
302
|
+
showPreviewAt(fraction * duration)
|
|
239
303
|
}
|
|
240
304
|
|
|
241
305
|
const { dragging, handlers } = useDragValue({ ref: progressBarRef, onChange: onSeekDrag })
|
|
@@ -269,7 +333,7 @@ export const ProgressBar = () => {
|
|
|
269
333
|
pressFraction.current = undefined
|
|
270
334
|
// a lifted finger leaves nothing over the bar, so the preview it opened closes with it
|
|
271
335
|
if (ev.pointerType === 'mouse') return
|
|
272
|
-
|
|
336
|
+
showPreviewAt(undefined)
|
|
273
337
|
}
|
|
274
338
|
|
|
275
339
|
// offsetX is relative to whichever child is under the pointer, and a captured pointer has none
|
|
@@ -281,12 +345,12 @@ export const ProgressBar = () => {
|
|
|
281
345
|
}
|
|
282
346
|
|
|
283
347
|
const onProgressBarOver: DOMAttributes<HTMLDivElement>['onMouseMove'] = (ev) => {
|
|
284
|
-
|
|
348
|
+
showPreviewAt(timeAtClientX(ev.clientX))
|
|
285
349
|
}
|
|
286
350
|
|
|
287
351
|
const hideProgressBarTime = () => {
|
|
288
352
|
if (!progressBarRef.current) return
|
|
289
|
-
|
|
353
|
+
showPreviewAt(undefined)
|
|
290
354
|
}
|
|
291
355
|
|
|
292
356
|
// duration is 0 until metadata lands, which is never a divisor
|
|
@@ -313,17 +377,6 @@ export const ProgressBar = () => {
|
|
|
313
377
|
[duration, indexes.length, downloadedRanges?.map(({ startByteOffset, endByteOffset }) => `${startByteOffset}/${endByteOffset}`).join(',')]
|
|
314
378
|
)
|
|
315
379
|
|
|
316
|
-
const cusorTimeString = useMemo(() => {
|
|
317
|
-
if (!progressBarHoverTime || progressBarHoverTime < 0) return undefined
|
|
318
|
-
const hours = Math.floor(progressBarHoverTime! / 3600)
|
|
319
|
-
const minutes = Math.floor((progressBarHoverTime! - hours * 3600) / 60)
|
|
320
|
-
const seconds = Math.floor(progressBarHoverTime! - hours * 3600 - minutes * 60)
|
|
321
|
-
const hoursString =
|
|
322
|
-
hours > 0
|
|
323
|
-
? `${hours}:`
|
|
324
|
-
: ''
|
|
325
|
-
return `${hoursString}${minutes < 10 ? '0' : ''}${minutes}:${seconds < 10 ? '0' : ''}${seconds}`
|
|
326
|
-
}, [progressBarHoverTime])
|
|
327
380
|
|
|
328
381
|
/*
|
|
329
382
|
* Follow the pointer while it is actually moving, and NOT on the press itself.
|
|
@@ -346,6 +399,38 @@ export const ProgressBar = () => {
|
|
|
346
399
|
: currentTime / duration
|
|
347
400
|
}, [duration, currentTime])
|
|
348
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
|
+
|
|
349
434
|
// an empty url is a gap sentinel, so it renders nothing
|
|
350
435
|
const thumbnail = useMemo(() => {
|
|
351
436
|
if (!progressBarHoverTime) return undefined
|
|
@@ -359,41 +444,71 @@ export const ProgressBar = () => {
|
|
|
359
444
|
)
|
|
360
445
|
}, [thumbnails, thumbnailAt, progressBarHoverTime])
|
|
361
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
|
+
|
|
362
471
|
return (
|
|
363
472
|
<div
|
|
364
473
|
css={style}
|
|
365
474
|
ref={progressBarRef}
|
|
366
|
-
className={
|
|
475
|
+
className={[
|
|
476
|
+
'progress-bar',
|
|
477
|
+
dragging ? 'dragging' : '',
|
|
478
|
+
segmented ? 'segmented' : '',
|
|
479
|
+
].filter(Boolean).join(' ')}
|
|
367
480
|
onMouseMove={onProgressBarOver}
|
|
368
481
|
onMouseOut={hideProgressBarTime}
|
|
369
482
|
>
|
|
370
|
-
|
|
483
|
+
{track(undefined, restMask)}
|
|
371
484
|
{
|
|
372
485
|
progressBarHoverTime
|
|
373
486
|
? (
|
|
374
487
|
<div
|
|
375
488
|
className="cursor-time"
|
|
376
489
|
/* the inset grew with the pill: content sized and centred, its half width is now the
|
|
377
|
-
padding plus the text, so the old 18px let a filled box hang past both ends
|
|
378
|
-
|
|
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
|
+
}}
|
|
379
499
|
>
|
|
380
|
-
{
|
|
500
|
+
<span className="cursor-time-value">{formatTime(progressBarHoverTime)}</span>
|
|
501
|
+
{hoveredChapter ? <span className="chapter-title">{hoveredChapter.title}</span> : undefined}
|
|
381
502
|
</div>
|
|
382
503
|
)
|
|
383
504
|
: undefined
|
|
384
505
|
}
|
|
385
506
|
<div className="progress"></div>
|
|
386
|
-
{/* bar showing the currently loaded progress */}
|
|
387
|
-
<div className="loaded">
|
|
388
|
-
{loadedParts}
|
|
389
|
-
</div>
|
|
390
507
|
{/* bar to show when hovering to potentially seek */}
|
|
391
508
|
<div className="hover"></div>
|
|
392
|
-
{/*
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
</div>
|
|
396
|
-
<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}
|
|
397
512
|
<div className="scrubber"></div>
|
|
398
513
|
<div
|
|
399
514
|
className="padding"
|