@banou/media-player 0.8.4 → 0.8.6

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.
@@ -10,13 +10,34 @@ import { usePlayer } from '../player'
10
10
  import { TooltipDisplay } from './tooltip-display'
11
11
  import { ProgressBar } from './progress-bar'
12
12
  import pictureInPicture from '../../assets/picture-in-picture.svg'
13
+ import { SubtitlesInPicture } from './icons'
13
14
  import SettingsAction from './settings'
15
+ import SubtitlesAction from './subtitles'
14
16
  import colors from '../../utils/colors'
15
17
  import Sound from './sound'
16
18
 
17
19
  const VOLUME_STEP = 0.05
18
20
  const SEEK_STEP = 5
19
21
 
22
+ /**
23
+ * Two stacked lines inside a tooltip, with a width to wrap against.
24
+ *
25
+ * Applied to the content rather than to the tooltip, because react-tooltip renders into a portal and
26
+ * an unconstrained tooltip grows to one long line that runs off the side of the player.
27
+ */
28
+ const tooltipLinesStyle = css`
29
+ display: flex;
30
+ flex-direction: column;
31
+ gap: calc(0.4 * var(--mp-unit));
32
+ max-width: calc(26 * var(--mp-unit));
33
+ white-space: normal;
34
+
35
+ .hint {
36
+ opacity: 0.72;
37
+ font-size: 0.9em;
38
+ }
39
+ `
40
+
20
41
  const style = css`
21
42
  position: absolute;
22
43
  bottom: 0;
@@ -67,9 +88,25 @@ const style = css`
67
88
  svg {
68
89
  stroke: #fff;
69
90
  }
91
+
92
+ /* Reads as unavailable rather than absent, so the bar does not reflow when tracks land. */
93
+ &:disabled {
94
+ cursor: default;
95
+ opacity: .4;
96
+ }
97
+ }
98
+
99
+ /**
100
+ * The burn-in control's own on state.
101
+ *
102
+ * Not \`colors.hover\`: the pointer is on the button at the moment of the click, so an on state
103
+ * drawn in the hover colour is invisible exactly when it is being looked for.
104
+ */
105
+ button[aria-pressed='true'] svg {
106
+ stroke: ${colors.accent};
70
107
  }
71
108
 
72
- .play, .sound, .time, .settings, .picture-in-picture, .full-screen {
109
+ .play, .sound, .time, .subtitles, .settings, .picture-in-picture, .full-screen {
73
110
  display: flex;
74
111
  align-items: center;
75
112
 
@@ -87,7 +124,7 @@ const style = css`
87
124
  }
88
125
  }
89
126
 
90
- .play, .sound, .settings, .picture-in-picture, .full-screen {
127
+ .play, .sound, .subtitles, .settings, .picture-in-picture, .full-screen {
91
128
  border-radius: 4px;
92
129
 
93
130
  padding: 8px;
@@ -149,8 +186,17 @@ export const ControlBar = () => {
149
186
  const fullscreen = usePlayer((state) => state.fullscreen)
150
187
  const hideUI = usePlayer((state) => state.hideUI)
151
188
  const togglePictureInPicture = usePlayer((state) => state.togglePictureInPicture)
189
+ const pictureInPictureMode = usePlayer((state) => state.pictureInPictureMode)
190
+ const burnedInSubtitles = usePlayer((state) => state.burnedInSubtitles)
191
+ const subtitleTracks = usePlayer((state) => state.subtitleTracks)
152
192
  const [volumeElement, setVolumeElement] = useState<HTMLButtonElement | null>(null)
153
193
 
194
+ const burnIn = pictureInPictureMode === 'burn-in'
195
+ // Burning nothing in is a mode with no effect, so the control stays visible and goes dead rather
196
+ // than vanishing: the track list arrives from libav a moment after playback starts, and a button
197
+ // that pops in late shifts every control beside it a second time.
198
+ const hasSubtitles = subtitleTracks.length > 0
199
+
154
200
  // duration is 0 until metadata lands, so a bare equality would show replay before playback starts
155
201
  const ended = duration > 0 && currentTime === duration
156
202
 
@@ -258,23 +304,52 @@ export const ControlBar = () => {
258
304
  </div>
259
305
  </div>
260
306
  <div className='right'>
307
+ <SubtitlesAction />
261
308
  <SettingsAction />
262
309
  {togglePictureInPicture
263
310
  ? (
264
311
  <TooltipDisplay
265
312
  id='picture-in-picture'
313
+ // anchored to its end, or the two-line burn-in copy runs off the right of the player
314
+ tooltipPlace='top-end'
266
315
  text={
267
316
  <button
268
317
  className='picture-in-picture'
269
318
  type='button'
270
319
  onClick={togglePictureInPicture}
320
+ disabled={burnIn && !hasSubtitles}
321
+ // Static name with the state on `aria-pressed`, rather than a name that changes
322
+ // on activation: one announces the state once, the other announces it twice and
323
+ // renames the control while the pointer is on it.
324
+ aria-label={burnIn ? 'Put the subtitles in the video' : 'Picture in picture'}
325
+ aria-pressed={burnIn ? burnedInSubtitles : undefined}
271
326
  >
272
- <img src={pictureInPicture} />
327
+ {burnIn
328
+ ? <SubtitlesInPicture />
329
+ : <img src={pictureInPicture} alt='' />}
273
330
  </button>
274
331
  }
275
332
  toolTipText={
276
- <span>
277
- Picture in picture
333
+ // The tooltip is portaled out of this subtree, so the control bar's own rules
334
+ // never reach it: a bare `small` stays inline and runs straight on from the line
335
+ // above it. Both lines carry their layout themselves.
336
+ <span css={tooltipLinesStyle}>
337
+ <span className='lead'>
338
+ {!burnIn
339
+ ? 'Picture in picture'
340
+ : !hasSubtitles
341
+ ? 'This file has no subtitles'
342
+ : burnedInSubtitles
343
+ ? 'Subtitles are in the picture'
344
+ : 'Put the subtitles in the picture'}
345
+ </span>
346
+ {burnIn && hasSubtitles
347
+ ? (
348
+ <span className='hint'>
349
+ Then hover the video and click your browser&apos;s own pop out button
350
+ </span>
351
+ )
352
+ : null}
278
353
  </span>
279
354
  }
280
355
  />
@@ -0,0 +1,53 @@
1
+ import type { SVGProps } from 'react'
2
+
3
+ /**
4
+ * The captions glyph, drawn here because react-feather does not have one.
5
+ *
6
+ * Checked against react-feather 2.0.10's 287 icons: there is no captions, subtitles or CC glyph, and
7
+ * nothing it does have reads as one (`Type` is a text-formatting T, `MessageSquare` is a chat bubble,
8
+ * `FileText` is a document). Drawn on Feather's own 24 grid at stroke-width 2 with round caps and
9
+ * carrying no size or colour of its own, so the control bar's `svg` rules size and stroke it exactly
10
+ * as they do `Play` and `Settings`.
11
+ */
12
+ const iconProps = {
13
+ xmlns: 'http://www.w3.org/2000/svg',
14
+ width: 24,
15
+ height: 24,
16
+ viewBox: '0 0 24 24',
17
+ fill: 'none',
18
+ stroke: 'currentColor',
19
+ strokeWidth: 2,
20
+ strokeLinecap: 'round',
21
+ strokeLinejoin: 'round',
22
+ } as const
23
+
24
+ export const Captions = (props: SVGProps<SVGSVGElement>) => (
25
+ <svg {...iconProps} {...props}>
26
+ <rect x='3' y='5' width='18' height='14' rx='2' ry='2' />
27
+ <path d='M7 15h4m4 0h2M7 11h2m4 0h4' />
28
+ </svg>
29
+ )
30
+
31
+ /** The slash is Feather's own convention for an off state, the line `MicOff` and `BellOff` draw. */
32
+ export const CaptionsOff = (props: SVGProps<SVGSVGElement>) => (
33
+ <svg {...iconProps} {...props}>
34
+ <rect x='3' y='5' width='18' height='14' rx='2' ry='2' />
35
+ <path d='M7 15h4m4 0h2M7 11h2m4 0h4' />
36
+ <line x1='2' y1='2' x2='22' y2='22' />
37
+ </svg>
38
+ )
39
+
40
+ /**
41
+ * Subtitles inside the picture: the picture-in-picture frame with caption bars in the inset.
42
+ *
43
+ * A separate glyph on purpose. The control means something different on a browser that cannot open
44
+ * a window, and the same icon doing two things silently is the thing to avoid. It is not the plain
45
+ * captions glyph either, because the button beside it already is one.
46
+ */
47
+ export const SubtitlesInPicture = (props: SVGProps<SVGSVGElement>) => (
48
+ <svg {...iconProps} {...props}>
49
+ <path d='M21 11V7a2 2 0 0 0-2-2H5a2 2 0 0 0-2 2v10a2 2 0 0 0 2 2h5' />
50
+ <rect x='12' y='13' width='10' height='8' rx='1' ry='1' />
51
+ <path d='M14.5 18.5h2m2 0h1' />
52
+ </svg>
53
+ )
@@ -52,14 +52,28 @@ const style = css`
52
52
  display: flex;
53
53
  justify-content: center;
54
54
 
55
+ /* The seekbar's other hover affordance, the thumbnail below, gets a rounded box and a drop
56
+ shadow. A source that ships no thumbnails, which is every source whose media the player does
57
+ not own, leaves this readout as the entire preview, so it carries the same treatment instead
58
+ of sitting as bare text on the picture. The ink comes from the player root. */
59
+ background-color: rgba(28, 28, 28, .95);
60
+ border-radius: calc(.4 * var(--mp-unit));
61
+ padding: 0 calc(.6 * var(--mp-unit));
62
+ box-shadow: 0 0 calc(1 * var(--mp-unit)) rgba(0, 0, 0, .5);
63
+
55
64
  text-shadow: 0 0 4px rgba(0, 0, 0, 1);
56
65
  ${fonts.bMedium.bold}
57
66
 
58
67
  position: absolute;
59
- top: calc(-2.5 * var(--mp-unit));
60
- width: calc(5 * var(--mp-unit));
68
+ /* Anchored on its bottom edge rather than its top: now that it has a background its height
69
+ follows the font size, and a top-anchored box would grow downward into the track. */
70
+ bottom: calc(1.2 * var(--mp-unit));
71
+ /* Sized to its content, replacing a fixed 5 unit box that \`1:04:09\` overflows. Transparent, that
72
+ overflow was invisible; filled, it would not be. */
73
+ width: max-content;
74
+ white-space: nowrap;
75
+ transform: translateX(-50%);
61
76
 
62
- margin-left: calc(-2.5 * var(--mp-unit));
63
77
  pointer-events: none;
64
78
  }
65
79
 
@@ -297,7 +311,9 @@ export const ProgressBar = () => {
297
311
  ? (
298
312
  <div
299
313
  className="cursor-time"
300
- style={{ left: `clamp(calc(1.8 * var(--mp-unit)), ${timePercentage(progressBarHoverTime)}%, calc(100% - calc(1.8 * var(--mp-unit))))` }}
314
+ /* the inset grew with the pill: content sized and centred, its half width is now the
315
+ padding plus the text, so the old 18px let a filled box hang past both ends */
316
+ style={{ left: `clamp(calc(3 * var(--mp-unit)), ${timePercentage(progressBarHoverTime)}%, calc(100% - calc(3 * var(--mp-unit))))` }}
301
317
  >
302
318
  {cusorTimeString}
303
319
  </div>
@@ -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,179 +67,31 @@ position: relative;
154
67
  }
155
68
  }
156
69
 
157
- .track-list {
158
- .description {
159
- word-break: break-word;
160
- }
161
-
162
- /* Dimmed and inert, but still listed. A source that names a track it cannot serve right now is
163
- saying the track exists, and dropping the row would read as the source having nothing. */
164
- .unavailable {
165
- opacity: 0.5;
166
- cursor: default;
167
- :hover {
168
- background-color: transparent;
169
- }
170
- }
171
-
172
- .failed {
173
- border-bottom: 1px solid #4D4D4E;
174
- color: #f66;
175
- ${fonts.bSmall.regular}
176
- }
177
- }
178
70
  `
179
71
 
180
72
  enum PopoverContent {
181
73
  Default,
182
74
  PlaybackRate,
183
- Subtitles,
184
75
  Audio
185
76
  }
186
77
 
187
- /**
188
- * One track picker, shared by the subtitle and audio menus.
189
- *
190
- * `pending` and `failed` describe the switch the viewer just asked for, not the menu: while a source
191
- * is working, every row is inert and the one being switched to says so, because the selection has
192
- * not moved yet and a tick next to it would be a lie.
193
- */
194
- const TrackMenu = (
195
- { title, tracks, selected, onSelect, onBack, offLabel, pending, failed }: {
196
- title: string
197
- tracks: TrackChoice[]
198
- selected: string | number | undefined
199
- onSelect: (id: string | number | undefined) => void
200
- onBack: () => void
201
- /** Absent means the menu offers no way off at all, which is what audio wants. */
202
- offLabel?: string
203
- /** The id currently being switched to, where `null` is the off row and undefined means idle. */
204
- pending?: string | number | null
205
- failed?: boolean
206
- }
207
- ) => {
208
- const busy = pending !== undefined
209
- const row = (id: string | number | null, label: string, className?: string) => {
210
- const isPending = busy && pending === id
211
- const disabled = busy || tracks.find((track) => track.id === id)?.disabled
212
- return (
213
- <div
214
- key={id ?? '__off__'}
215
- onClick={() => { if (!disabled) onSelect(id ?? undefined) }}
216
- className={[className, disabled ? 'unavailable' : null].filter(Boolean).join(' ') || undefined}
217
- >
218
- <span>{label}</span>
219
- <span>{isPending ? '…' : (selected ?? null) === id ? '✓' : ''}</span>
220
- </div>
221
- )
222
- }
223
-
224
- return (
225
- <div className='popover track-list'>
226
- <div className="back" onClick={onBack}>
227
- <ChevronLeft />
228
- <span>{title}</span>
229
- </div>
230
- {failed ? <div className="no-hover failed">Could not switch. Try again.</div> : null}
231
- {offLabel ? row(null, offLabel) : null}
232
- {tracks.map(({ id, label }) => row(id, label, 'description'))}
233
- </div>
234
- )
235
- }
236
-
237
78
  export const SettingsAction = () => {
238
79
  const player = usePlayer()
239
80
  const playbackRate = usePlayer((state) => state.playbackRate)
240
- const subtitleTracks = usePlayer((state) => state.subtitleTracks)
241
- const selectedSubtitleTrack = usePlayer((state) => state.selectedSubtitleTrack)
242
- const selectSubtitleTrack = usePlayer((state) => state.selectSubtitleTrack)
243
81
  const audioTracks = usePlayer((state) => state.audioTracks)
244
82
  const selectedAudioTrack = usePlayer((state) => state.selectedAudioTrack)
245
83
  const selectAudioTrack = usePlayer((state) => state.selectAudioTrack)
246
84
 
247
- const subtitleOffLabel = usePlayer((state) => state.subtitleOffLabel)
248
-
249
- const [isOpenPopover, setIsOpenPopover] = useState(false)
250
85
  const [popoverContent, setPopoverContent] = useState(PopoverContent.Default)
251
- const settingsContainerRef = useRef<HTMLDivElement>(null)
252
- // The id being switched to, or undefined when nothing is in flight. Null is the row that turns
253
- // subtitles off, so this cannot be a plain id: undefined has to keep meaning idle.
254
- const [pending, setPending] = useState<string | number | null | undefined>(undefined)
255
- const [failed, setFailed] = useState(false)
256
-
257
- const togglePopover = () => {
258
- setIsOpenPopover(!isOpenPopover)
259
- setPopoverContent(PopoverContent.Default)
260
- setFailed(false)
261
- }
262
-
263
- // Not `togglePopover`: this runs from a promise, where the captured `isOpenPopover` is whatever it
264
- // was at click time, so a toggle could reopen a menu the viewer has already dismissed.
265
- const closePopover = () => {
266
- setIsOpenPopover(false)
267
- setPopoverContent(PopoverContent.Default)
268
- }
86
+ const { open, toggle, containerRef, pending, failed, runSelect } = useTrackMenu({
87
+ onReset: () => setPopoverContent(PopoverContent.Default),
88
+ })
269
89
 
270
90
  const setPlaybackRate = (rate: number) => {
271
91
  player.setPlaybackRate(rate)
272
92
  setPopoverContent(PopoverContent.Default)
273
93
  }
274
94
 
275
- useEffect(() => {
276
- const handleClickOutside = (ev: PointerEvent) => {
277
- if (
278
- isOpenPopover &&
279
- settingsContainerRef.current &&
280
- !settingsContainerRef.current.contains(ev.target as Node)
281
- ) {
282
- togglePopover()
283
- }
284
- }
285
- // pointerdown, because a touch device synthesizes mousedown too late to close reliably
286
- document.addEventListener('pointerdown', handleClickOutside)
287
- return () => document.removeEventListener('pointerdown', handleClickOutside)
288
- }, [isOpenPopover])
289
-
290
- /**
291
- * Runs a track switch and decides when the menu may close.
292
- *
293
- * A selector that returns nothing switched synchronously, so the click closes the menu as it always
294
- * has. One that returns a promise owns a player this one cannot reach, and the menu stays open and
295
- * inert until it answers: closing first would show a selection that has not happened yet, and
296
- * dropping the promise would turn a failed switch into an unhandled rejection nobody ever sees.
297
- */
298
- const runSelect = (id: string | number | null, select: () => void | Promise<void>) => {
299
- if (pending !== undefined) return
300
- setFailed(false)
301
- let result: void | Promise<void>
302
- try {
303
- result = select()
304
- } catch (err) {
305
- console.warn('[media-player] track selection failed:', err)
306
- setFailed(true)
307
- return
308
- }
309
- if (!(result instanceof Promise)) {
310
- closePopover()
311
- return
312
- }
313
- setPending(id)
314
- result.then(
315
- () => {
316
- setPending(undefined)
317
- closePopover()
318
- },
319
- (err) => {
320
- console.warn('[media-player] track selection failed:', err)
321
- setPending(undefined)
322
- setFailed(true)
323
- },
324
- )
325
- }
326
-
327
- const chooseSubtitle = (id: string | number | undefined) =>
328
- runSelect(id ?? null, () => selectSubtitleTrack(id))
329
-
330
95
  // the audio menu offers no way off, so there is no undefined case to answer for
331
96
  const chooseAudio = (id: string | number | undefined) => {
332
97
  if (id === undefined) return
@@ -338,15 +103,15 @@ export const SettingsAction = () => {
338
103
  }
339
104
 
340
105
  return (
341
- <div css={style} ref={settingsContainerRef}>
106
+ <div css={style} ref={containerRef}>
342
107
  <TooltipDisplay
343
108
  id='settings'
344
- disabled={isOpenPopover}
109
+ disabled={open}
345
110
  text={
346
111
  <button
347
112
  className='settings'
348
113
  type='button'
349
- onClick={togglePopover}
114
+ onClick={toggle}
350
115
  >
351
116
  <Settings />
352
117
  </button>
@@ -358,12 +123,13 @@ export const SettingsAction = () => {
358
123
  }
359
124
  />
360
125
  {
361
- isOpenPopover && popoverContent === PopoverContent.Default && (
126
+ open && popoverContent === PopoverContent.Default && (
362
127
  <div className='popover menu'>
363
128
  {/*
364
- A row is offered only where there is more than one thing behind it to choose between.
365
- Audio needs two tracks for that; subtitles need one, because "Disable" is always a
366
- 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.
367
133
  */}
368
134
  {audioTracks.length > 1
369
135
  ? (
@@ -375,16 +141,6 @@ export const SettingsAction = () => {
375
141
  </div>
376
142
  )
377
143
  : null}
378
- {subtitleTracks.length > 0
379
- ? (
380
- <div onClick={changePopoverContent(PopoverContent.Subtitles)}>
381
- <div>Subtitles</div>
382
- <div>
383
- <ChevronRight />
384
- </div>
385
- </div>
386
- )
387
- : null}
388
144
  <div onClick={changePopoverContent(PopoverContent.PlaybackRate)}>
389
145
  <div>Playback speed</div>
390
146
  <div>
@@ -402,7 +158,7 @@ export const SettingsAction = () => {
402
158
  )
403
159
  }
404
160
  {
405
- isOpenPopover && popoverContent === PopoverContent.PlaybackRate && (
161
+ open && popoverContent === PopoverContent.PlaybackRate && (
406
162
  <div className='popover playback-rate'>
407
163
  <div className="back" onClick={changePopoverContent(PopoverContent.Default)}>
408
164
  <ChevronLeft />
@@ -429,21 +185,7 @@ export const SettingsAction = () => {
429
185
  )
430
186
  }
431
187
  {
432
- isOpenPopover && popoverContent === PopoverContent.Subtitles && (
433
- <TrackMenu
434
- title='Subtitles'
435
- tracks={subtitleTracks}
436
- selected={selectedSubtitleTrack}
437
- onSelect={chooseSubtitle}
438
- onBack={changePopoverContent(PopoverContent.Default)}
439
- offLabel={subtitleOffLabel ?? 'Disable'}
440
- pending={pending}
441
- failed={failed}
442
- />
443
- )
444
- }
445
- {
446
- isOpenPopover && popoverContent === PopoverContent.Audio && (
188
+ open && popoverContent === PopoverContent.Audio && (
447
189
  <TrackMenu
448
190
  title='Audio'
449
191
  tracks={audioTracks}