@banou/media-player 0.8.3 → 0.8.5

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.
@@ -1,17 +1,19 @@
1
1
  /// <reference types="@emotion/react/types/css-prop" />
2
- import type { TrackChoice } from '../source-feature'
3
-
4
- import { useEffect, useRef, useState } from 'react'
2
+ import { useState } from 'react'
5
3
  import { css } from '@emotion/react'
6
4
  import { ChevronLeft, ChevronRight, Settings } from 'react-feather'
7
5
 
8
6
  import { usePlayer } from '../player'
9
7
  import { TooltipDisplay } from './tooltip-display'
10
- import { fonts } from '../../utils/fonts'
11
8
  import PlaybackSlider from './playback-slider'
9
+ import { TrackMenu, popoverStyle, useTrackMenu } from './track-menu'
12
10
 
13
11
  const style = css`
14
- position: relative;
12
+ /* Deliberately NOT a containing block. The menu anchors to the control bar instead, which is exactly
13
+ as wide as the player box and can therefore be clamped to it. The outside-click check uses
14
+ \`contains\` on the ref, which is DOM containment and needs no position, and the bar's other
15
+ tooltips already resolve against the control bar with no positioned wrapper of their own. */
16
+ position: static;
15
17
 
16
18
  .settings {
17
19
  /* the icon keeps its size, the pressable box grows around it */
@@ -24,96 +26,7 @@ position: relative;
24
26
  }
25
27
  }
26
28
 
27
- .popover {
28
- position: absolute;
29
- /* the gear sits a couple of buttons in from the right edge, so a centred popover hangs off that
30
- edge on a phone. It anchors to the button instead until the viewport has room to centre it. */
31
- right: 0;
32
- transform: none;
33
-
34
- overflow-y: auto;
35
- width: 180px;
36
- max-width: calc(100vw - 24px);
37
- height: 160px;
38
- top: -180px;
39
- @media (min-width: 768px) {
40
- right: 50%;
41
- transform: translateX(50%);
42
- width: 250px;
43
- height: 160px;
44
- }
45
-
46
- display: flex;
47
- flex-direction: column;
48
-
49
- border-radius: 8px;
50
- background-color: rgba(28,28,28,0.95);
51
- ${fonts.bMedium.regular}
52
-
53
- z-index: 4;
54
-
55
- > div {
56
- display: flex;
57
- align-items: center;
58
- justify-content: space-between;
59
-
60
- color: #fff;
61
- outline: none;
62
-
63
- padding: 8px 6px 8px 12px;
64
-
65
- width: 100%;
66
- /* rows keep their own height inside the fixed height column, so a long page scrolls rather than
67
- squashing every row into it */
68
- flex-shrink: 0;
69
- @media (pointer: coarse) {
70
- box-sizing: border-box;
71
- min-height: 44px;
72
- }
73
-
74
- :first-of-type {
75
- border-radius: 8px 8px 0 0;
76
- }
77
- :last-of-type {
78
- border-radius: 0 0 8px 8px;
79
- }
80
- :not(&.no-hover) {
81
- cursor: pointer;
82
- }
83
- :not(&.no-hover):hover {
84
- background-color: rgba(255,255,255,.1);
85
- }
86
-
87
- > div {
88
- display: flex;
89
- align-items: center;
90
- justify-content: center;
91
-
92
- .secondary {
93
- color: #eee;
94
- ${fonts.bSmall.regular}
95
- }
96
- }
97
- }
98
-
99
- .back {
100
- display: flex;
101
- align-items: center;
102
- justify-content: flex-start;
103
- border-bottom: 1px solid #4D4D4E;
104
-
105
- svg {
106
- transform: translateX(-4px);
107
- transition: transform 0.2s ease-in-out;
108
- }
109
-
110
- :hover {
111
- svg {
112
- transform: translateX(-8px);
113
- }
114
- }
115
- }
116
- }
29
+ ${popoverStyle}
117
30
 
118
31
  .playback-rate {
119
32
  .slider {
@@ -154,109 +67,35 @@ position: relative;
154
67
  }
155
68
  }
156
69
 
157
- .track-list {
158
- .description {
159
- word-break: break-word;
160
- }
161
- }
162
70
  `
163
71
 
164
72
  enum PopoverContent {
165
73
  Default,
166
74
  PlaybackRate,
167
- Subtitles,
168
75
  Audio
169
76
  }
170
77
 
171
- /** One track picker, shared by the subtitle and audio menus. */
172
- const TrackMenu = (
173
- { title, tracks, selected, onSelect, onBack, allowDisable }: {
174
- title: string
175
- tracks: TrackChoice[]
176
- selected: string | number | undefined
177
- onSelect: (id: string | number | undefined) => void
178
- onBack: () => void
179
- allowDisable?: boolean
180
- }
181
- ) => (
182
- <div className='popover track-list'>
183
- <div className="back" onClick={onBack}>
184
- <ChevronLeft />
185
- <span>{title}</span>
186
- </div>
187
- {
188
- allowDisable
189
- ? (
190
- <div onClick={() => onSelect(undefined)}>
191
- <span>Disable</span>
192
- <span>{selected === undefined ? '✓' : ''}</span>
193
- </div>
194
- )
195
- : null
196
- }
197
- {
198
- tracks.map(({ id, label }) => (
199
- <div
200
- key={id}
201
- onClick={() => onSelect(id)}
202
- className="description"
203
- >
204
- <span>{label}</span>
205
- <span>{selected === id ? '✓' : ''}</span>
206
- </div>
207
- ))
208
- }
209
- </div>
210
- )
211
-
212
78
  export const SettingsAction = () => {
213
79
  const player = usePlayer()
214
80
  const playbackRate = usePlayer((state) => state.playbackRate)
215
- const subtitleTracks = usePlayer((state) => state.subtitleTracks)
216
- const selectedSubtitleTrack = usePlayer((state) => state.selectedSubtitleTrack)
217
- const selectSubtitleTrack = usePlayer((state) => state.selectSubtitleTrack)
218
81
  const audioTracks = usePlayer((state) => state.audioTracks)
219
82
  const selectedAudioTrack = usePlayer((state) => state.selectedAudioTrack)
220
83
  const selectAudioTrack = usePlayer((state) => state.selectAudioTrack)
221
84
 
222
- const [isOpenPopover, setIsOpenPopover] = useState(false)
223
85
  const [popoverContent, setPopoverContent] = useState(PopoverContent.Default)
224
- const settingsContainerRef = useRef<HTMLDivElement>(null)
225
-
226
- const togglePopover = () => {
227
- setIsOpenPopover(!isOpenPopover)
228
- setPopoverContent(PopoverContent.Default)
229
- }
86
+ const { open, toggle, containerRef, pending, failed, runSelect } = useTrackMenu({
87
+ onReset: () => setPopoverContent(PopoverContent.Default),
88
+ })
230
89
 
231
90
  const setPlaybackRate = (rate: number) => {
232
91
  player.setPlaybackRate(rate)
233
92
  setPopoverContent(PopoverContent.Default)
234
93
  }
235
94
 
236
- useEffect(() => {
237
- const handleClickOutside = (ev: PointerEvent) => {
238
- if (
239
- isOpenPopover &&
240
- settingsContainerRef.current &&
241
- !settingsContainerRef.current.contains(ev.target as Node)
242
- ) {
243
- togglePopover()
244
- }
245
- }
246
- // pointerdown, because a touch device synthesizes mousedown too late to close reliably
247
- document.addEventListener('pointerdown', handleClickOutside)
248
- return () => document.removeEventListener('pointerdown', handleClickOutside)
249
- }, [isOpenPopover])
250
-
251
- const chooseSubtitle = (id: string | number | undefined) => {
252
- selectSubtitleTrack(id)
253
- togglePopover()
254
- }
255
-
95
+ // the audio menu offers no way off, so there is no undefined case to answer for
256
96
  const chooseAudio = (id: string | number | undefined) => {
257
- // the audio menu never offers "Disable"
258
- if (id !== undefined) selectAudioTrack(id)
259
- togglePopover()
97
+ if (id === undefined) return
98
+ runSelect(id, () => selectAudioTrack(id))
260
99
  }
261
100
 
262
101
  const changePopoverContent = (newPopoverContent: PopoverContent) => () => {
@@ -264,15 +103,15 @@ export const SettingsAction = () => {
264
103
  }
265
104
 
266
105
  return (
267
- <div css={style} ref={settingsContainerRef}>
106
+ <div css={style} ref={containerRef}>
268
107
  <TooltipDisplay
269
108
  id='settings'
270
- disabled={isOpenPopover}
109
+ disabled={open}
271
110
  text={
272
111
  <button
273
112
  className='settings'
274
113
  type='button'
275
- onClick={togglePopover}
114
+ onClick={toggle}
276
115
  >
277
116
  <Settings />
278
117
  </button>
@@ -284,12 +123,13 @@ export const SettingsAction = () => {
284
123
  }
285
124
  />
286
125
  {
287
- isOpenPopover && popoverContent === PopoverContent.Default && (
126
+ open && popoverContent === PopoverContent.Default && (
288
127
  <div className='popover menu'>
289
128
  {/*
290
- A row is offered only where there is more than one thing behind it to choose between.
291
- Audio needs two tracks for that; subtitles need one, because "Disable" is always a
292
- second option, and a file with no subtitles at all should not offer the menu.
129
+ A row is offered only where there is more than one thing behind it to choose between,
130
+ so audio needs two tracks. Subtitles is NOT here: it has its own button in the bar,
131
+ because it is the control in this player that gets reached for most and it does not
132
+ belong two clicks deep next to playback speed.
293
133
  */}
294
134
  {audioTracks.length > 1
295
135
  ? (
@@ -301,16 +141,6 @@ export const SettingsAction = () => {
301
141
  </div>
302
142
  )
303
143
  : null}
304
- {subtitleTracks.length > 0
305
- ? (
306
- <div onClick={changePopoverContent(PopoverContent.Subtitles)}>
307
- <div>Subtitles</div>
308
- <div>
309
- <ChevronRight />
310
- </div>
311
- </div>
312
- )
313
- : null}
314
144
  <div onClick={changePopoverContent(PopoverContent.PlaybackRate)}>
315
145
  <div>Playback speed</div>
316
146
  <div>
@@ -328,7 +158,7 @@ export const SettingsAction = () => {
328
158
  )
329
159
  }
330
160
  {
331
- isOpenPopover && popoverContent === PopoverContent.PlaybackRate && (
161
+ open && popoverContent === PopoverContent.PlaybackRate && (
332
162
  <div className='popover playback-rate'>
333
163
  <div className="back" onClick={changePopoverContent(PopoverContent.Default)}>
334
164
  <ChevronLeft />
@@ -355,25 +185,15 @@ export const SettingsAction = () => {
355
185
  )
356
186
  }
357
187
  {
358
- isOpenPopover && popoverContent === PopoverContent.Subtitles && (
359
- <TrackMenu
360
- title='Subtitles'
361
- tracks={subtitleTracks}
362
- selected={selectedSubtitleTrack}
363
- onSelect={chooseSubtitle}
364
- onBack={changePopoverContent(PopoverContent.Default)}
365
- allowDisable
366
- />
367
- )
368
- }
369
- {
370
- isOpenPopover && popoverContent === PopoverContent.Audio && (
188
+ open && popoverContent === PopoverContent.Audio && (
371
189
  <TrackMenu
372
190
  title='Audio'
373
191
  tracks={audioTracks}
374
192
  selected={selectedAudioTrack}
375
193
  onSelect={chooseAudio}
376
194
  onBack={changePopoverContent(PopoverContent.Default)}
195
+ pending={pending}
196
+ failed={failed}
377
197
  />
378
198
  )
379
199
  }
@@ -0,0 +1,80 @@
1
+ /// <reference types="@emotion/react/types/css-prop" />
2
+ import { css } from '@emotion/react'
3
+
4
+ import { usePlayer } from '../player'
5
+ import { TooltipDisplay } from './tooltip-display'
6
+ import { Captions, CaptionsOff } from './icons'
7
+ import { TrackMenu, popoverStyle, useTrackMenu } from './track-menu'
8
+
9
+ const style = css`
10
+ /* Not a containing block, for the reason given on popoverStyle: the menu anchors to the control bar,
11
+ which is the width of the player box, so it can be clamped to it. */
12
+ position: static;
13
+
14
+ .subtitles {
15
+ /* the icon keeps its size, the pressable box grows around it */
16
+ @media (pointer: coarse) {
17
+ box-sizing: border-box;
18
+ justify-content: center;
19
+
20
+ min-width: 44px;
21
+ min-height: 44px;
22
+ }
23
+ }
24
+
25
+ ${popoverStyle}
26
+ `
27
+
28
+ /**
29
+ * Subtitles, one click from the control bar.
30
+ *
31
+ * It used to be a row inside the settings menu, which put the most-reached control in the player two
32
+ * clicks deep behind a gear, next to playback speed. Audio stays in there: switching it is rare, and
33
+ * on a source that owns its own player it is slow enough to be a considered act.
34
+ */
35
+ export const SubtitlesAction = () => {
36
+ const subtitleTracks = usePlayer((state) => state.subtitleTracks)
37
+ const selectedSubtitleTrack = usePlayer((state) => state.selectedSubtitleTrack)
38
+ const selectSubtitleTrack = usePlayer((state) => state.selectSubtitleTrack)
39
+ const subtitleOffLabel = usePlayer((state) => state.subtitleOffLabel)
40
+
41
+ const { open, toggle, containerRef, pending, failed, runSelect } = useTrackMenu()
42
+
43
+ // After every hook, never before. One track is enough to offer the menu, because "off" is always a
44
+ // second option; a file carrying none should not offer it at all.
45
+ if (subtitleTracks.length === 0) return null
46
+
47
+ // Read from the store, never from `pending`: mid-switch the selection has not moved, and flipping
48
+ // the glyph early is the same lie a tick on the pending row would be.
49
+ const on = selectedSubtitleTrack !== undefined
50
+
51
+ return (
52
+ <div css={style} ref={containerRef}>
53
+ <TooltipDisplay
54
+ id='subtitles'
55
+ disabled={open}
56
+ text={
57
+ <button className='subtitles' type='button' onClick={toggle}>
58
+ {on ? <Captions className='captions' /> : <CaptionsOff className='captions-off' />}
59
+ </button>
60
+ }
61
+ toolTipText={<span>Subtitles</span>}
62
+ />
63
+ {
64
+ open && (
65
+ <TrackMenu
66
+ title='Subtitles'
67
+ tracks={subtitleTracks}
68
+ selected={selectedSubtitleTrack}
69
+ onSelect={(id) => runSelect(id ?? null, () => selectSubtitleTrack(id))}
70
+ offLabel={subtitleOffLabel ?? 'Disable'}
71
+ pending={pending}
72
+ failed={failed}
73
+ />
74
+ )
75
+ }
76
+ </div>
77
+ )
78
+ }
79
+
80
+ export default SubtitlesAction
@@ -0,0 +1,277 @@
1
+ /// <reference types="@emotion/react/types/css-prop" />
2
+ import type { TrackChoice } from '../source-feature'
3
+
4
+ import { useEffect, useRef, useState } from 'react'
5
+ import { css } from '@emotion/react'
6
+ import { ChevronLeft } from 'react-feather'
7
+
8
+ import { fonts } from '../../utils/fonts'
9
+
10
+ /**
11
+ * The popover surface and the track rows, shared by every menu in the control bar.
12
+ *
13
+ * Carries NO `position` of its own: the menu anchors to the control bar, which is the width of the
14
+ * player box, so each caller keeps `position: static` on its own wrapper. Anchoring to the button
15
+ * instead is what let the menu hang outside the box, where the root's `overflow: hidden` made it
16
+ * unreachable rather than merely ugly.
17
+ */
18
+ export const popoverStyle = css`
19
+ .popover {
20
+ position: absolute;
21
+ /* Anchored to the PLAYER's right edge, never to the gear. The gear is not the last control in the
22
+ bar, so a menu centred on it puts its own edges wherever the button happens to sit: with no
23
+ picture-in-picture button, which is every source whose media the player does not own, an edge
24
+ landed outside the box, and the root's \`overflow: hidden\` cut it off, taking the rows under it
25
+ out of reach. The inset matches the bar's own padding so the menu lines up with the last button. */
26
+ right: calc(12px + env(safe-area-inset-right, 0px));
27
+ bottom: calc(100% + 8px);
28
+
29
+ overflow-y: auto;
30
+ width: 180px;
31
+ /* \`100%\` is the control bar, which is the width of the PLAYER. The old \`100vw\` was the viewport,
32
+ which equals the player only when the player is the whole document, and it is the wrong kind of
33
+ guard besides: a max-width shrinks the box, it never moves it back inside. */
34
+ max-width: calc(100% - 24px);
35
+ /* Sized to its rows, capped at what fits. A fixed height left the settings menu two thirds empty
36
+ once subtitles moved out to its own button, and a long track list scrolls at the cap either way. */
37
+ height: max-content;
38
+ /* The room actually above the bar. A short player had the top of the menu clipped by that same
39
+ \`overflow: hidden\`. With no size container in the ancestry this resolves against the small
40
+ viewport, so it can only ever shrink the box, never grow it. */
41
+ max-height: min(160px, calc(100cqh - 100% - 16px));
42
+ @media (min-width: 768px) {
43
+ right: calc(16px + env(safe-area-inset-right, 0px));
44
+ width: 250px;
45
+ max-width: calc(100% - 32px);
46
+ }
47
+ @media (min-width: 2560px) {
48
+ right: calc(24px + env(safe-area-inset-right, 0px));
49
+ max-width: calc(100% - 48px);
50
+ }
51
+
52
+ display: flex;
53
+ flex-direction: column;
54
+
55
+ border-radius: 8px;
56
+ background-color: rgba(28,28,28,0.95);
57
+ ${fonts.bMedium.regular}
58
+
59
+ z-index: 4;
60
+
61
+ > div {
62
+ display: flex;
63
+ align-items: center;
64
+ justify-content: space-between;
65
+
66
+ color: #fff;
67
+ outline: none;
68
+
69
+ padding: 8px 6px 8px 12px;
70
+
71
+ width: 100%;
72
+ /* rows keep their own height inside the fixed height column, so a long page scrolls rather than
73
+ squashing every row into it */
74
+ flex-shrink: 0;
75
+ @media (pointer: coarse) {
76
+ box-sizing: border-box;
77
+ min-height: 44px;
78
+ }
79
+
80
+ :first-of-type {
81
+ border-radius: 8px 8px 0 0;
82
+ }
83
+ :last-of-type {
84
+ border-radius: 0 0 8px 8px;
85
+ }
86
+ :not(&.no-hover) {
87
+ cursor: pointer;
88
+ }
89
+ :not(&.no-hover):hover {
90
+ background-color: rgba(255,255,255,.1);
91
+ }
92
+
93
+ > div {
94
+ display: flex;
95
+ align-items: center;
96
+ justify-content: center;
97
+
98
+ .secondary {
99
+ color: #eee;
100
+ ${fonts.bSmall.regular}
101
+ }
102
+ }
103
+ }
104
+
105
+ .back {
106
+ display: flex;
107
+ align-items: center;
108
+ justify-content: flex-start;
109
+ border-bottom: 1px solid #4D4D4E;
110
+
111
+ svg {
112
+ transform: translateX(-4px);
113
+ transition: transform 0.2s ease-in-out;
114
+ }
115
+
116
+ :hover {
117
+ svg {
118
+ transform: translateX(-8px);
119
+ }
120
+ }
121
+ }
122
+ }
123
+
124
+ .track-list {
125
+ .description {
126
+ word-break: break-word;
127
+ }
128
+
129
+ /* Dimmed and inert, but still listed. A source that names a track it cannot serve right now is
130
+ saying the track exists, and dropping the row would read as the source having nothing. */
131
+ .unavailable {
132
+ opacity: 0.5;
133
+ cursor: default;
134
+ :hover {
135
+ background-color: transparent;
136
+ }
137
+ }
138
+
139
+ .failed {
140
+ border-bottom: 1px solid #4D4D4E;
141
+ color: #f66;
142
+ ${fonts.bSmall.regular}
143
+ }
144
+ }
145
+ `
146
+
147
+ /**
148
+ * One track picker, used by the subtitles button and by the settings menu's audio page.
149
+ *
150
+ * `pending` and `failed` describe the switch the viewer just asked for, not the menu: while a source
151
+ * is working, every row is inert and the one being switched to says so, because the selection has not
152
+ * moved yet and a tick next to it would be a lie.
153
+ */
154
+ export const TrackMenu = (
155
+ { title, tracks, selected, onSelect, onBack, offLabel, pending, failed }: {
156
+ title: string
157
+ tracks: TrackChoice[]
158
+ selected: string | number | undefined
159
+ onSelect: (id: string | number | undefined) => void
160
+ /** Absent means the menu was opened straight from the bar, so the header is a label and no more. */
161
+ onBack?: () => void
162
+ /** Absent means the menu offers no way off at all, which is what audio wants. */
163
+ offLabel?: string
164
+ /** The id currently being switched to, where `null` is the off row and undefined means idle. */
165
+ pending?: string | number | null
166
+ failed?: boolean
167
+ }
168
+ ) => {
169
+ const busy = pending !== undefined
170
+ const row = (id: string | number | null, label: string, className?: string) => {
171
+ const isPending = busy && pending === id
172
+ const disabled = busy || tracks.find((track) => track.id === id)?.disabled
173
+ return (
174
+ <div
175
+ key={id ?? '__off__'}
176
+ onClick={() => { if (!disabled) onSelect(id ?? undefined) }}
177
+ className={[className, disabled ? 'unavailable' : null].filter(Boolean).join(' ') || undefined}
178
+ >
179
+ <span>{label}</span>
180
+ <span>{isPending ? '\u2026' : (selected ?? null) === id ? '\u2713' : ''}</span>
181
+ </div>
182
+ )
183
+ }
184
+
185
+ return (
186
+ <div className='popover track-list'>
187
+ {/* The header keeps its name and its border either way. With no page to go back to there is
188
+ nothing for a chevron to point at, and `no-hover` stops the row offering a dead click. */}
189
+ <div className={onBack ? 'back' : 'back no-hover'} onClick={onBack}>
190
+ {onBack ? <ChevronLeft /> : null}
191
+ <span>{title}</span>
192
+ </div>
193
+ {failed ? <div className="no-hover failed">Could not switch. Try again.</div> : null}
194
+ {offLabel ? row(null, offLabel) : null}
195
+ {tracks.map(({ id, label }) => row(id, label, 'description'))}
196
+ </div>
197
+ )
198
+ }
199
+
200
+ /**
201
+ * The open/closed and in-flight state every track menu in the bar needs.
202
+ *
203
+ * One hook rather than two, because `runSelect` is what decides when the menu may close: splitting
204
+ * the popover state from the selection state would wire them in a circle.
205
+ */
206
+ export const useTrackMenu = ({ onReset }: { onReset?: () => void } = {}) => {
207
+ const containerRef = useRef<HTMLDivElement>(null)
208
+ const [open, setOpen] = useState(false)
209
+ // The id being switched to, or undefined when nothing is in flight. Null is the row that turns
210
+ // subtitles off, so this cannot be a plain id: undefined has to keep meaning idle.
211
+ const [pending, setPending] = useState<string | number | null | undefined>(undefined)
212
+ const [failed, setFailed] = useState(false)
213
+
214
+ // Held in a ref because the pointerdown listener and the select promise both outlive the render
215
+ // that handed the callback over.
216
+ const reset = useRef(onReset)
217
+ reset.current = onReset
218
+
219
+ const close = () => {
220
+ setOpen(false)
221
+ reset.current?.()
222
+ }
223
+
224
+ const toggle = () => {
225
+ setOpen((wasOpen) => !wasOpen)
226
+ setFailed(false)
227
+ reset.current?.()
228
+ }
229
+
230
+ useEffect(() => {
231
+ const handleClickOutside = (ev: PointerEvent) => {
232
+ if (open && containerRef.current && !containerRef.current.contains(ev.target as Node)) close()
233
+ }
234
+ // pointerdown, because a touch device synthesizes mousedown too late to close reliably
235
+ document.addEventListener('pointerdown', handleClickOutside)
236
+ return () => document.removeEventListener('pointerdown', handleClickOutside)
237
+ }, [open])
238
+
239
+ /**
240
+ * Runs a track switch and decides when the menu may close.
241
+ *
242
+ * A selector that returns nothing switched synchronously, so the click closes the menu as it always
243
+ * has. One that returns a promise owns a player this one cannot reach, and the menu stays open and
244
+ * inert until it answers: closing first would show a selection that has not happened yet, and
245
+ * dropping the promise would turn a failed switch into an unhandled rejection nobody ever sees.
246
+ */
247
+ const runSelect = (id: string | number | null, select: () => void | Promise<void>) => {
248
+ if (pending !== undefined) return
249
+ setFailed(false)
250
+ let result: void | Promise<void>
251
+ try {
252
+ result = select()
253
+ } catch (err) {
254
+ console.warn('[media-player] track selection failed:', err)
255
+ setFailed(true)
256
+ return
257
+ }
258
+ if (!(result instanceof Promise)) {
259
+ close()
260
+ return
261
+ }
262
+ setPending(id)
263
+ result.then(
264
+ () => {
265
+ setPending(undefined)
266
+ close()
267
+ },
268
+ (err) => {
269
+ console.warn('[media-player] track selection failed:', err)
270
+ setPending(undefined)
271
+ setFailed(true)
272
+ },
273
+ )
274
+ }
275
+
276
+ return { open, toggle, close, containerRef, pending, failed, runSelect }
277
+ }