@banou/media-player 0.10.17 → 0.10.19

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.
@@ -17,7 +17,7 @@ export declare const popoverStyle: import("@emotion/utils").SerializedStyles;
17
17
  */
18
18
  export declare const TrackMenu: ({ title, tracks, selected, onSelect, onBack, offLabel, pending, failed }: {
19
19
  title: string;
20
- tracks: TrackChoice[];
20
+ tracks: readonly TrackChoice[];
21
21
  selected: string | number | undefined;
22
22
  onSelect: (id: string | number | undefined) => void;
23
23
  /** Absent means the menu was opened straight from the bar, so the header is a label and no more. */
@@ -15,6 +15,7 @@ export declare const Player: import("@videojs/react").CreatePlayerResult<import(
15
15
  audioTracks: import("./source-feature").TrackChoice[];
16
16
  selectedAudioTrack: string | number | undefined;
17
17
  selectAudioTrack: (id: string | number) => void | Promise<void>;
18
+ quality: import("./media").DelegatedSelection | null;
18
19
  hideUI: boolean;
19
20
  setHideUI: (hide: boolean) => void;
20
21
  hasMedia: boolean;
@@ -1,4 +1,5 @@
1
1
  import type { MediaChapter, MediaIndex, PictureInPictureMode, ThumbnailImage } from '../engine';
2
+ import type { DelegatedSelection } from './media';
2
3
  /**
3
4
  * One row of a track menu, already named.
4
5
  *
@@ -162,6 +163,8 @@ export type SourceState = {
162
163
  audioTracks: TrackChoice[];
163
164
  selectedAudioTrack: string | number | undefined;
164
165
  selectAudioTrack: (id: string | number) => void | Promise<void>;
166
+ /** The settings menu's quality choices, from a media the player does not own. null when none are offered. */
167
+ quality: DelegatedSelection | null;
165
168
  /** Chrome auto-hide. True means the controls, title and cursor are hidden. */
166
169
  hideUI: boolean;
167
170
  setHideUI: (hide: boolean) => void;
@@ -307,6 +310,8 @@ export declare const sourceFeature: import("@videojs/react").PlayerFeature<{
307
310
  audioTracks: TrackChoice[];
308
311
  selectedAudioTrack: string | number | undefined;
309
312
  selectAudioTrack: (id: string | number) => void | Promise<void>;
313
+ /** The settings menu's quality choices, from a media the player does not own. null when none are offered. */
314
+ quality: DelegatedSelection | null;
310
315
  /** Chrome auto-hide. True means the controls, title and cursor are hidden. */
311
316
  hideUI: boolean;
312
317
  setHideUI: (hide: boolean) => void;
@@ -193,6 +193,12 @@ export type MediaPlayerRemoteOptions = CommonOptions & {
193
193
  /** The source renders these; the player draws the menu and reports the pick. */
194
194
  subtitles?: DelegatedTracks;
195
195
  audioTracks?: DelegatedTracks;
196
+ /**
197
+ * The source's own quality choices, as a row of the settings menu that shows the selected one.
198
+ * Offered only with two or more rows to choose between; `offLabel` names a row with no quality
199
+ * picked, such as an automatic one, whose id is null.
200
+ */
201
+ quality?: DelegatedTracks;
196
202
  /**
197
203
  * Offer picture in picture, entered by the host from inside the media's own document. Off by
198
204
  * default, and then there is no control at all. See `PassThroughPictureInPicture` for what the
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@banou/media-player",
3
- "version": "0.10.17",
3
+ "version": "0.10.19",
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",
@@ -709,8 +709,10 @@ export const startPlayback = async (options: PlaybackOptions): Promise<PlaybackC
709
709
  if (elementFailed || videoElement.error) return
710
710
  evict().catch(() => {})
711
711
  pump().catch((error) => reportError(error, false))
712
- // every remove() puts the MediaSource back to 'open', so the end has to be re-armed
713
- if (endInBuffer() && !pending && atEnd() && mediaSource.readyState === 'open') endOfStream().catch(() => {})
712
+ // Every remove() puts the MediaSource back to 'open', so the end has to be re-armed, though not while a remuxer
713
+ // seek runs: queued behind a remove() and run after a move had started one, it came before that seek's append over
714
+ // the end, and Firefox 151 then sometimes waited at the end for good (4 of 1060 end moves, measured 2026-10-12)
715
+ if (endInBuffer() && !pending && atEnd() && mediaSource.readyState === 'open') endOfStream(() => !seeking).catch(() => {})
714
716
  }, 100)
715
717
  teardown.push(() => clearInterval(interval))
716
718
 
@@ -50,10 +50,13 @@ export const updateSourceBuffer = (sourceBuffer: SourceBuffer, mediaSource: Medi
50
50
  const updateTimestampOffset = (timestampOffset: number) =>
51
51
  enqueue(async () => { sourceBuffer.timestampOffset = timestampOffset })
52
52
 
53
- // endOfStream throws mid-update and is undone by the next remove(), so the caller re-arms it
54
- const endOfStream = () =>
53
+ /**
54
+ * endOfStream throws mid-update and is undone by the next remove(), so the caller re-arms it.
55
+ * `allowed` is asked when the call reaches the front of the queue, not when it is queued.
56
+ */
57
+ const endOfStream = (allowed = () => true) =>
55
58
  enqueue(async () => {
56
- if (mediaSource.readyState !== 'open' || sourceBuffer.updating) return
59
+ if (mediaSource.readyState !== 'open' || sourceBuffer.updating || !allowed()) return
57
60
  try { mediaSource.endOfStream() } catch {}
58
61
  })
59
62
 
@@ -107,7 +107,8 @@ ${popoverStyle}
107
107
  enum PopoverContent {
108
108
  Default,
109
109
  PlaybackRate,
110
- Audio
110
+ Audio,
111
+ Quality
111
112
  }
112
113
 
113
114
  export const SettingsAction = () => {
@@ -116,6 +117,7 @@ export const SettingsAction = () => {
116
117
  const audioTracks = usePlayer((state) => state.audioTracks)
117
118
  const selectedAudioTrack = usePlayer((state) => state.selectedAudioTrack)
118
119
  const selectAudioTrack = usePlayer((state) => state.selectAudioTrack)
120
+ const quality = usePlayer((state) => state.quality)
119
121
  const volumeNormalizer = usePlayer((state) => state.volumeNormalizer)
120
122
 
121
123
  const [popoverContent, setPopoverContent] = useState(PopoverContent.Default)
@@ -134,6 +136,15 @@ export const SettingsAction = () => {
134
136
  runSelect(id, () => selectAudioTrack(id))
135
137
  }
136
138
 
139
+ const chooseQuality = (id: string | number | undefined) => {
140
+ if (!quality) return
141
+ runSelect(id ?? null, () => quality.select(id === undefined ? null : String(id)))
142
+ }
143
+ const qualityRows = quality ? quality.options.length + (quality.offLabel ? 1 : 0) : 0
144
+ const qualityLabel = quality?.selectedId == null
145
+ ? quality?.offLabel
146
+ : quality.options.find(({ id }) => id === quality.selectedId)?.label
147
+
137
148
  const changePopoverContent = (newPopoverContent: PopoverContent) => () => {
138
149
  setPopoverContent(newPopoverContent)
139
150
  }
@@ -179,6 +190,17 @@ export const SettingsAction = () => {
179
190
  </div>
180
191
  )
181
192
  : null}
193
+ {qualityRows > 1
194
+ ? (
195
+ <div onClick={changePopoverContent(PopoverContent.Quality)}>
196
+ <div>Quality</div>
197
+ <div>
198
+ {qualityLabel ? <span className='secondary'>{qualityLabel}</span> : null}
199
+ <ChevronRight />
200
+ </div>
201
+ </div>
202
+ )
203
+ : null}
182
204
  <div onClick={changePopoverContent(PopoverContent.PlaybackRate)}>
183
205
  <div>Playback speed</div>
184
206
  <div>
@@ -255,6 +277,20 @@ export const SettingsAction = () => {
255
277
  />
256
278
  )
257
279
  }
280
+ {
281
+ open && popoverContent === PopoverContent.Quality && quality && (
282
+ <TrackMenu
283
+ title='Quality'
284
+ tracks={quality.options}
285
+ selected={quality.selectedId ?? undefined}
286
+ onSelect={chooseQuality}
287
+ onBack={changePopoverContent(PopoverContent.Default)}
288
+ offLabel={quality.offLabel}
289
+ pending={pending}
290
+ failed={failed}
291
+ />
292
+ )
293
+ }
258
294
  </div>
259
295
  )
260
296
  }
@@ -154,7 +154,7 @@ export const popoverStyle = css`
154
154
  export const TrackMenu = (
155
155
  { title, tracks, selected, onSelect, onBack, offLabel, pending, failed }: {
156
156
  title: string
157
- tracks: TrackChoice[]
157
+ tracks: readonly TrackChoice[]
158
158
  selected: string | number | undefined
159
159
  onSelect: (id: string | number | undefined) => void
160
160
  /** Absent means the menu was opened straight from the bar, so the header is a label and no more. */
@@ -1,4 +1,5 @@
1
1
  import type { MediaChapter, MediaIndex, PictureInPictureMode, ThumbnailImage } from '../engine'
2
+ import type { DelegatedSelection } from './media'
2
3
 
3
4
  import { definePlayerFeature } from '@videojs/core/dom'
4
5
 
@@ -175,6 +176,9 @@ export type SourceState = {
175
176
  selectedAudioTrack: string | number | undefined
176
177
  selectAudioTrack: (id: string | number) => void | Promise<void>
177
178
 
179
+ /** The settings menu's quality choices, from a media the player does not own. null when none are offered. */
180
+ quality: DelegatedSelection | null
181
+
178
182
  /** Chrome auto-hide. True means the controls, title and cursor are hidden. */
179
183
  hideUI: boolean
180
184
  setHideUI: (hide: boolean) => void
@@ -283,6 +287,7 @@ const initialState: SourceState = {
283
287
  audioTracks: [],
284
288
  selectedAudioTrack: undefined,
285
289
  selectAudioTrack: () => {},
290
+ quality: null,
286
291
  hideUI: false,
287
292
  setHideUI: () => {},
288
293
  hasMedia: false,
@@ -220,6 +220,12 @@ export type MediaPlayerRemoteOptions =
220
220
  /** The source renders these; the player draws the menu and reports the pick. */
221
221
  subtitles?: DelegatedTracks
222
222
  audioTracks?: DelegatedTracks
223
+ /**
224
+ * The source's own quality choices, as a row of the settings menu that shows the selected one.
225
+ * Offered only with two or more rows to choose between; `offLabel` names a row with no quality
226
+ * picked, such as an automatic one, whose id is null.
227
+ */
228
+ quality?: DelegatedTracks
223
229
  /**
224
230
  * Offer picture in picture, entered by the host from inside the media's own document. Off by
225
231
  * default, and then there is no control at all. See `PassThroughPictureInPicture` for what the
@@ -397,6 +403,11 @@ const PlayerRoot = ({ options, children }: { options: MediaPlayerOptions, childr
397
403
  })
398
404
  }, [setSourceState, audio])
399
405
 
406
+ const quality = remote?.quality?.selection ?? null
407
+ useEffect(() => {
408
+ setSourceState({ quality })
409
+ }, [setSourceState, quality])
410
+
400
411
  /*
401
412
  * Guarded, and its own effect, for the reason the ones above are.
402
413
  *