@livepeer-frameworks/player-react 0.0.3

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.
Files changed (88) hide show
  1. package/dist/cjs/index.js +2 -0
  2. package/dist/cjs/index.js.map +1 -0
  3. package/dist/esm/index.js +2 -0
  4. package/dist/esm/index.js.map +1 -0
  5. package/dist/types/components/DevModePanel.d.ts +47 -0
  6. package/dist/types/components/DvdLogo.d.ts +4 -0
  7. package/dist/types/components/Icons.d.ts +33 -0
  8. package/dist/types/components/IdleScreen.d.ts +16 -0
  9. package/dist/types/components/LoadingScreen.d.ts +6 -0
  10. package/dist/types/components/LogoOverlay.d.ts +11 -0
  11. package/dist/types/components/Player.d.ts +11 -0
  12. package/dist/types/components/PlayerControls.d.ts +60 -0
  13. package/dist/types/components/PlayerErrorBoundary.d.ts +23 -0
  14. package/dist/types/components/SeekBar.d.ts +33 -0
  15. package/dist/types/components/SkipIndicator.d.ts +14 -0
  16. package/dist/types/components/SpeedIndicator.d.ts +12 -0
  17. package/dist/types/components/StatsPanel.d.ts +31 -0
  18. package/dist/types/components/StreamStateOverlay.d.ts +24 -0
  19. package/dist/types/components/SubtitleRenderer.d.ts +69 -0
  20. package/dist/types/components/ThumbnailOverlay.d.ts +4 -0
  21. package/dist/types/components/TitleOverlay.d.ts +13 -0
  22. package/dist/types/components/players/DashJsPlayer.d.ts +18 -0
  23. package/dist/types/components/players/HlsJsPlayer.d.ts +18 -0
  24. package/dist/types/components/players/MewsWsPlayer/index.d.ts +18 -0
  25. package/dist/types/components/players/MistPlayer.d.ts +20 -0
  26. package/dist/types/components/players/MistWebRTCPlayer/index.d.ts +20 -0
  27. package/dist/types/components/players/NativePlayer.d.ts +19 -0
  28. package/dist/types/components/players/VideoJsPlayer.d.ts +18 -0
  29. package/dist/types/context/PlayerContext.d.ts +40 -0
  30. package/dist/types/context/index.d.ts +5 -0
  31. package/dist/types/hooks/useMetaTrack.d.ts +54 -0
  32. package/dist/types/hooks/usePlaybackQuality.d.ts +42 -0
  33. package/dist/types/hooks/usePlayerController.d.ts +163 -0
  34. package/dist/types/hooks/usePlayerSelection.d.ts +47 -0
  35. package/dist/types/hooks/useStreamState.d.ts +27 -0
  36. package/dist/types/hooks/useTelemetry.d.ts +57 -0
  37. package/dist/types/hooks/useViewerEndpoints.d.ts +14 -0
  38. package/dist/types/index.d.ts +33 -0
  39. package/dist/types/types.d.ts +94 -0
  40. package/dist/types/ui/badge.d.ts +9 -0
  41. package/dist/types/ui/button.d.ts +11 -0
  42. package/dist/types/ui/context-menu.d.ts +27 -0
  43. package/dist/types/ui/select.d.ts +10 -0
  44. package/dist/types/ui/slider.d.ts +13 -0
  45. package/package.json +71 -0
  46. package/src/assets/logomark.svg +56 -0
  47. package/src/components/DevModePanel.tsx +822 -0
  48. package/src/components/DvdLogo.tsx +201 -0
  49. package/src/components/Icons.tsx +282 -0
  50. package/src/components/IdleScreen.tsx +664 -0
  51. package/src/components/LoadingScreen.tsx +710 -0
  52. package/src/components/LogoOverlay.tsx +75 -0
  53. package/src/components/Player.tsx +419 -0
  54. package/src/components/PlayerControls.tsx +820 -0
  55. package/src/components/PlayerErrorBoundary.tsx +70 -0
  56. package/src/components/SeekBar.tsx +291 -0
  57. package/src/components/SkipIndicator.tsx +113 -0
  58. package/src/components/SpeedIndicator.tsx +57 -0
  59. package/src/components/StatsPanel.tsx +150 -0
  60. package/src/components/StreamStateOverlay.tsx +200 -0
  61. package/src/components/SubtitleRenderer.tsx +235 -0
  62. package/src/components/ThumbnailOverlay.tsx +90 -0
  63. package/src/components/TitleOverlay.tsx +48 -0
  64. package/src/components/players/DashJsPlayer.tsx +56 -0
  65. package/src/components/players/HlsJsPlayer.tsx +56 -0
  66. package/src/components/players/MewsWsPlayer/index.tsx +56 -0
  67. package/src/components/players/MistPlayer.tsx +60 -0
  68. package/src/components/players/MistWebRTCPlayer/index.tsx +59 -0
  69. package/src/components/players/NativePlayer.tsx +58 -0
  70. package/src/components/players/VideoJsPlayer.tsx +56 -0
  71. package/src/context/PlayerContext.tsx +71 -0
  72. package/src/context/index.ts +11 -0
  73. package/src/global.d.ts +4 -0
  74. package/src/hooks/useMetaTrack.ts +187 -0
  75. package/src/hooks/usePlaybackQuality.ts +126 -0
  76. package/src/hooks/usePlayerController.ts +525 -0
  77. package/src/hooks/usePlayerSelection.ts +117 -0
  78. package/src/hooks/useStreamState.ts +381 -0
  79. package/src/hooks/useTelemetry.ts +138 -0
  80. package/src/hooks/useViewerEndpoints.ts +120 -0
  81. package/src/index.tsx +75 -0
  82. package/src/player.css +2 -0
  83. package/src/types.ts +135 -0
  84. package/src/ui/badge.tsx +27 -0
  85. package/src/ui/button.tsx +47 -0
  86. package/src/ui/context-menu.tsx +193 -0
  87. package/src/ui/select.tsx +105 -0
  88. package/src/ui/slider.tsx +67 -0
@@ -0,0 +1,75 @@
1
+ import React from "react";
2
+ import { cn } from "@livepeer-frameworks/player-core";
3
+
4
+ interface LogoOverlayProps {
5
+ src: string;
6
+ show?: boolean;
7
+ position?: "top-left" | "top-right" | "bottom-left" | "bottom-right";
8
+ width?: number;
9
+ height?: number | "auto";
10
+ clickUrl?: string;
11
+ }
12
+
13
+ const POSITION_MAP: Record<
14
+ NonNullable<LogoOverlayProps["position"]>,
15
+ string
16
+ > = {
17
+ "top-left": "left-3 top-3 sm:left-4 sm:top-4",
18
+ "top-right": "right-3 top-3 sm:right-4 sm:top-4",
19
+ "bottom-left": "left-3 bottom-3 sm:left-4 sm:bottom-4",
20
+ "bottom-right": "right-3 bottom-3 sm:right-4 sm:bottom-4"
21
+ };
22
+
23
+ const LogoOverlay: React.FC<LogoOverlayProps> = ({
24
+ src,
25
+ show = true,
26
+ position = "bottom-right",
27
+ width = 96,
28
+ height = "auto",
29
+ clickUrl
30
+ }) => {
31
+ if (!show) return null;
32
+
33
+ const content = (
34
+ <img
35
+ src={src}
36
+ alt="FrameWorks logo"
37
+ width={width}
38
+ height={height === "auto" ? undefined : height}
39
+ className={cn(
40
+ "max-h-[72px] rounded-md border border-white/10 bg-black/40 p-2 shadow-lg backdrop-blur transition",
41
+ clickUrl ? "hover:bg-black/60" : ""
42
+ )}
43
+ style={{ width, height: height === "auto" ? undefined : height }}
44
+ />
45
+ );
46
+
47
+ if (clickUrl) {
48
+ return (
49
+ <a
50
+ href={clickUrl}
51
+ target="_blank"
52
+ rel="noreferrer"
53
+ className={cn(
54
+ "fw-player-surface absolute z-40 inline-flex items-center justify-center opacity-90",
55
+ POSITION_MAP[position]
56
+ )}
57
+ >
58
+ {content}
59
+ </a>
60
+ );
61
+ }
62
+
63
+ return (
64
+ <div
65
+ className={cn(
66
+ "fw-player-surface absolute z-40 inline-flex items-center justify-center opacity-90",
67
+ POSITION_MAP[position]
68
+ )}
69
+ >
70
+ {content}
71
+ </div>
72
+ );
73
+ };
74
+
75
+ export default LogoOverlay;
@@ -0,0 +1,419 @@
1
+ import React, { useState, useCallback, useMemo } from "react";
2
+ import IdleScreen from "./IdleScreen";
3
+ import ThumbnailOverlay from "./ThumbnailOverlay";
4
+ import TitleOverlay from "./TitleOverlay";
5
+ import StatsPanel from "./StatsPanel";
6
+ import PlayerControls from "./PlayerControls";
7
+ import DevModePanel from "./DevModePanel";
8
+ import SpeedIndicator from "./SpeedIndicator";
9
+ import SkipIndicator, { SkipDirection } from "./SkipIndicator";
10
+ import { StatsIcon, SettingsIcon, PictureInPictureIcon } from "./Icons";
11
+ import { PlayerProps } from "../types";
12
+ import { usePlayerController } from "../hooks/usePlayerController";
13
+ import { cn } from "@livepeer-frameworks/player-core";
14
+ import type { PlaybackMode, EndpointInfo } from "@livepeer-frameworks/player-core";
15
+ import {
16
+ ContextMenu,
17
+ ContextMenuContent,
18
+ ContextMenuItem,
19
+ ContextMenuSeparator,
20
+ ContextMenuTrigger,
21
+ } from "../ui/context-menu";
22
+
23
+ /**
24
+ * Inner player component that uses PlayerController via hook
25
+ */
26
+ const PlayerInner: React.FC<PlayerProps> = ({
27
+ contentId,
28
+ contentType,
29
+ thumbnailUrl = null,
30
+ options,
31
+ endpoints: propsEndpoints,
32
+ onStateChange
33
+ }) => {
34
+ // ============================================================================
35
+ // UI-only State (stays in wrapper)
36
+ // ============================================================================
37
+ const [isStatsOpen, setIsStatsOpen] = useState(false);
38
+ const [isDevPanelOpen, setIsDevPanelOpen] = useState(false);
39
+ const [skipDirection, setSkipDirection] = useState<SkipDirection>(null);
40
+
41
+ // Playback mode preference (persistent)
42
+ const [devPlaybackMode, setDevPlaybackMode] = useState<PlaybackMode>(options?.playbackMode || 'auto');
43
+
44
+ // ============================================================================
45
+ // PlayerController Hook - ALL business logic
46
+ // ============================================================================
47
+ const {
48
+ containerRef,
49
+ state,
50
+ controller,
51
+ togglePlay,
52
+ toggleMute,
53
+ toggleLoop,
54
+ toggleFullscreen,
55
+ togglePiP,
56
+ setVolume,
57
+ selectQuality,
58
+ clearError,
59
+ retry,
60
+ reload,
61
+ jumpToLive,
62
+ handleMouseEnter,
63
+ handleMouseLeave,
64
+ handleMouseMove,
65
+ setDevModeOptions,
66
+ } = usePlayerController({
67
+ contentId,
68
+ contentType,
69
+ endpoints: propsEndpoints,
70
+ gatewayUrl: options?.gatewayUrl,
71
+ mistUrl: options?.mistUrl,
72
+ authToken: options?.authToken,
73
+ autoplay: options?.autoplay !== false,
74
+ muted: options?.muted !== false,
75
+ controls: options?.stockControls === true,
76
+ poster: thumbnailUrl || undefined,
77
+ debug: options?.debug,
78
+ onStateChange: (playerState) => {
79
+ onStateChange?.(playerState);
80
+ },
81
+ onError: (error) => {
82
+ console.warn('[Player] Error:', error);
83
+ },
84
+ });
85
+
86
+ // ============================================================================
87
+ // Dev Mode Callbacks
88
+ // ============================================================================
89
+ const handleDevSettingsChange = useCallback((settings: {
90
+ forcePlayer?: string;
91
+ forceType?: string;
92
+ forceSource?: number;
93
+ }) => {
94
+ // One-shot selection - controller handles the state
95
+ setDevModeOptions({
96
+ forcePlayer: settings.forcePlayer,
97
+ forceType: settings.forceType,
98
+ forceSource: settings.forceSource,
99
+ });
100
+ }, [setDevModeOptions]);
101
+
102
+ const handleModeChange = useCallback((mode: PlaybackMode) => {
103
+ setDevPlaybackMode(mode);
104
+ // Mode is a persistent preference
105
+ setDevModeOptions({ playbackMode: mode });
106
+ }, [setDevModeOptions]);
107
+
108
+ const handleReload = useCallback(() => {
109
+ clearError();
110
+ reload();
111
+ }, [clearError, reload]);
112
+
113
+ const handleStatsToggle = useCallback(() => {
114
+ setIsStatsOpen(prev => !prev);
115
+ }, []);
116
+
117
+ // Clear skip indicator after animation
118
+ const handleSkipIndicatorHide = useCallback(() => {
119
+ setSkipDirection(null);
120
+ }, []);
121
+
122
+ // ============================================================================
123
+ // Derived Values
124
+ // ============================================================================
125
+ const primaryEndpoint = state.endpoints?.primary as EndpointInfo | undefined;
126
+ const isLegacyPlayer = state.currentPlayerInfo?.shortname === 'mist-legacy';
127
+ const useStockControls = options?.stockControls === true || isLegacyPlayer;
128
+
129
+ // Title overlay visibility: show on hover or when paused
130
+ const showTitleOverlay = (state.isHovering || state.isPaused) &&
131
+ !state.shouldShowIdleScreen && !state.isBuffering && !state.error;
132
+
133
+ // Buffering spinner: only during active playback
134
+ const showBufferingSpinner = !state.shouldShowIdleScreen &&
135
+ state.isBuffering && !state.error && state.hasPlaybackStarted;
136
+
137
+ // Click-to-play overlay support
138
+ const supportsOverlay = false;
139
+
140
+ // ============================================================================
141
+ // Waiting for Endpoint (shown as overlay, not early return)
142
+ // ============================================================================
143
+ const showWaitingForEndpoint = !state.endpoints?.primary && state.state !== 'booting';
144
+ const waitingMessage = options?.gatewayUrl
145
+ ? (state.state === 'gateway_loading' ? 'Resolving viewing endpoint...' : 'Waiting for endpoint...')
146
+ : 'Waiting for endpoint...';
147
+
148
+ // ============================================================================
149
+ // Render
150
+ // ============================================================================
151
+ return (
152
+ <ContextMenu>
153
+ <ContextMenuTrigger asChild>
154
+ <div
155
+ className={cn(
156
+ "fw-player-surface fw-player-root w-full h-full overflow-hidden",
157
+ options?.devMode && "flex"
158
+ )}
159
+ data-player-container="true"
160
+ tabIndex={0}
161
+ onMouseEnter={handleMouseEnter}
162
+ onMouseLeave={handleMouseLeave}
163
+ onMouseMove={handleMouseMove}
164
+ >
165
+ {/* Player area */}
166
+ <div className={cn(
167
+ "relative",
168
+ options?.devMode ? "flex-1 min-w-0" : "w-full h-full"
169
+ )}>
170
+ {/* Video container - PlayerController attaches here */}
171
+ <div ref={containerRef} className="fw-player-container" />
172
+
173
+ {/* Title/Description overlay */}
174
+ <TitleOverlay
175
+ title={state.metadata?.title}
176
+ description={state.metadata?.description}
177
+ isVisible={showTitleOverlay}
178
+ />
179
+
180
+ {/* Stats panel */}
181
+ <StatsPanel
182
+ isOpen={isStatsOpen}
183
+ onClose={handleStatsToggle}
184
+ metadata={state.metadata}
185
+ streamState={state.streamState?.isOnline ? {
186
+ status: state.streamState.status,
187
+ viewers: state.metadata?.viewers,
188
+ tracks: state.streamState.streamInfo?.meta?.tracks
189
+ ? Object.values(state.streamState.streamInfo.meta.tracks).map(t => ({
190
+ type: t.type,
191
+ codec: t.codec,
192
+ width: t.width,
193
+ height: t.height,
194
+ bps: t.bps,
195
+ }))
196
+ : [],
197
+ } : null}
198
+ quality={state.playbackQuality}
199
+ videoElement={state.videoElement}
200
+ protocol={primaryEndpoint?.protocol}
201
+ nodeId={primaryEndpoint?.nodeId}
202
+ geoDistance={primaryEndpoint?.geoDistance}
203
+ />
204
+
205
+ {/* Dev Mode Panel toggle */}
206
+ {options?.devMode && !isDevPanelOpen && (
207
+ <DevModePanel
208
+ onSettingsChange={handleDevSettingsChange}
209
+ playbackMode={devPlaybackMode}
210
+ onModeChange={handleModeChange}
211
+ onReload={handleReload}
212
+ streamInfo={state.streamInfo}
213
+ mistStreamInfo={state.streamState?.streamInfo}
214
+ currentPlayer={state.currentPlayerInfo}
215
+ currentSource={state.currentSourceInfo}
216
+ videoElement={state.videoElement}
217
+ protocol={primaryEndpoint?.protocol}
218
+ nodeId={primaryEndpoint?.nodeId}
219
+ isVisible={false}
220
+ isOpen={false}
221
+ onOpenChange={setIsDevPanelOpen}
222
+ />
223
+ )}
224
+
225
+ {/* Speed indicator */}
226
+ {state.isHoldingSpeed && (
227
+ <SpeedIndicator isVisible={true} speed={state.holdSpeed} />
228
+ )}
229
+
230
+ {/* Skip indicator */}
231
+ <SkipIndicator
232
+ direction={skipDirection}
233
+ seconds={10}
234
+ onHide={handleSkipIndicatorHide}
235
+ />
236
+
237
+ {/* Waiting for endpoint overlay */}
238
+ {showWaitingForEndpoint && (
239
+ <IdleScreen status="OFFLINE" message={waitingMessage} />
240
+ )}
241
+
242
+ {/* Idle screen */}
243
+ {!showWaitingForEndpoint && state.shouldShowIdleScreen && (
244
+ <IdleScreen
245
+ status={state.isEffectivelyLive ? state.streamState?.status : undefined}
246
+ message={state.isEffectivelyLive ? state.streamState?.message : 'Loading video...'}
247
+ percentage={state.isEffectivelyLive ? state.streamState?.percentage : undefined}
248
+ />
249
+ )}
250
+
251
+ {/* Buffering spinner */}
252
+ {showBufferingSpinner && (
253
+ <div
254
+ role="status"
255
+ aria-live="polite"
256
+ className="fw-player-surface absolute inset-0 flex items-center justify-center bg-black/40 backdrop-blur-sm z-20"
257
+ >
258
+ <div className="flex items-center gap-3 rounded-lg border border-white/10 bg-black/70 px-4 py-3 text-sm text-white shadow-lg">
259
+ <div className="w-4 h-4 border-2 border-white/30 border-t-white rounded-full animate-spin"></div>
260
+ <span>Buffering...</span>
261
+ </div>
262
+ </div>
263
+ )}
264
+
265
+ {/* Error overlay */}
266
+ {!state.shouldShowIdleScreen && state.error && (
267
+ <div
268
+ role="alert"
269
+ aria-live="assertive"
270
+ className={cn(
271
+ "fw-error-overlay",
272
+ state.isPassiveError ? "fw-error-overlay--passive" : "fw-error-overlay--fullscreen"
273
+ )}
274
+ >
275
+ <div className={cn(
276
+ "fw-error-popup",
277
+ state.isPassiveError ? "fw-error-popup--passive" : "fw-error-popup--fullscreen"
278
+ )}>
279
+ <div className={cn(
280
+ "fw-error-header",
281
+ state.isPassiveError ? "fw-error-header--warning" : "fw-error-header--error"
282
+ )}>
283
+ <span className={cn(
284
+ "fw-error-title",
285
+ state.isPassiveError ? "fw-error-title--warning" : "fw-error-title--error"
286
+ )}>
287
+ {state.isPassiveError ? "Warning" : "Error"}
288
+ </span>
289
+ <button
290
+ type="button"
291
+ className="fw-error-close"
292
+ onClick={clearError}
293
+ aria-label="Dismiss"
294
+ >
295
+ <svg width="12" height="12" viewBox="0 0 12 12" fill="none">
296
+ <path d="M9 3L3 9M3 3L9 9" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round"/>
297
+ </svg>
298
+ </button>
299
+ </div>
300
+ <div className="fw-error-body">
301
+ <p className="fw-error-message">Playback issue</p>
302
+ </div>
303
+ <div className="fw-error-actions">
304
+ <button
305
+ type="button"
306
+ className="fw-error-btn"
307
+ aria-label="Retry playback"
308
+ onClick={() => { clearError(); retry(); }}
309
+ >
310
+ Retry
311
+ </button>
312
+ </div>
313
+ </div>
314
+ </div>
315
+ )}
316
+
317
+ {/* Player controls */}
318
+ {!useStockControls && (
319
+ <PlayerControls
320
+ currentTime={state.currentTime}
321
+ duration={state.duration}
322
+ isVisible={state.shouldShowControls}
323
+ onSeek={(t) => controller?.seek(t)}
324
+ showStatsButton={false}
325
+ isStatsOpen={isStatsOpen}
326
+ onStatsToggle={handleStatsToggle}
327
+ mistStreamInfo={state.streamState?.streamInfo}
328
+ disabled={!state.videoElement}
329
+ playbackMode={devPlaybackMode}
330
+ onModeChange={handleModeChange}
331
+ sourceType={state.currentSourceInfo?.type}
332
+ isContentLive={state.isEffectivelyLive}
333
+ // Props from usePlayerController hook
334
+ videoElement={state.videoElement}
335
+ qualities={state.qualities}
336
+ onSelectQuality={selectQuality}
337
+ isMuted={state.isMuted}
338
+ volume={state.volume}
339
+ onVolumeChange={setVolume}
340
+ onToggleMute={toggleMute}
341
+ isPlaying={state.isPlaying}
342
+ onTogglePlay={togglePlay}
343
+ onToggleFullscreen={toggleFullscreen}
344
+ isFullscreen={state.isFullscreen}
345
+ isLoopEnabled={state.isLoopEnabled}
346
+ onToggleLoop={toggleLoop}
347
+ onJumpToLive={jumpToLive}
348
+ />
349
+ )}
350
+ </div>
351
+
352
+ {/* Dev Mode Panel - side panel */}
353
+ {options?.devMode && isDevPanelOpen && (
354
+ <DevModePanel
355
+ onSettingsChange={handleDevSettingsChange}
356
+ playbackMode={devPlaybackMode}
357
+ onModeChange={handleModeChange}
358
+ onReload={handleReload}
359
+ streamInfo={state.streamInfo}
360
+ mistStreamInfo={state.streamState?.streamInfo}
361
+ currentPlayer={state.currentPlayerInfo}
362
+ currentSource={state.currentSourceInfo}
363
+ videoElement={state.videoElement}
364
+ protocol={primaryEndpoint?.protocol}
365
+ nodeId={primaryEndpoint?.nodeId}
366
+ isVisible={true}
367
+ isOpen={true}
368
+ onOpenChange={setIsDevPanelOpen}
369
+ />
370
+ )}
371
+ </div>
372
+ </ContextMenuTrigger>
373
+
374
+ {/* Context menu */}
375
+ <ContextMenuContent>
376
+ <ContextMenuItem onClick={handleStatsToggle} className="gap-2">
377
+ <StatsIcon size={14} className="opacity-70 flex-shrink-0" />
378
+ <span>{isStatsOpen ? "Hide Stats" : "Stats"}</span>
379
+ </ContextMenuItem>
380
+ {options?.devMode && (
381
+ <>
382
+ <ContextMenuSeparator />
383
+ <ContextMenuItem onClick={() => setIsDevPanelOpen(!isDevPanelOpen)} className="gap-2">
384
+ <SettingsIcon size={14} className="opacity-70 flex-shrink-0" />
385
+ <span>{isDevPanelOpen ? "Hide Settings" : "Settings"}</span>
386
+ </ContextMenuItem>
387
+ </>
388
+ )}
389
+ <ContextMenuSeparator />
390
+ <ContextMenuItem onClick={togglePiP} className="gap-2">
391
+ <PictureInPictureIcon size={14} className="opacity-70 flex-shrink-0" />
392
+ <span>Picture-in-Picture</span>
393
+ </ContextMenuItem>
394
+ <ContextMenuItem onClick={toggleLoop} className="gap-2">
395
+ <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round" className="opacity-70 flex-shrink-0">
396
+ <polyline points="17 1 21 5 17 9"></polyline>
397
+ <path d="M3 11V9a4 4 0 0 1 4-4h14"></path>
398
+ <polyline points="7 23 3 19 7 15"></polyline>
399
+ <path d="M21 13v2a4 4 0 0 1-4 4H3"></path>
400
+ </svg>
401
+ <span>{state.isLoopEnabled ? "Disable Loop" : "Enable Loop"}</span>
402
+ </ContextMenuItem>
403
+ </ContextMenuContent>
404
+ </ContextMenu>
405
+ );
406
+ };
407
+
408
+ /**
409
+ * Main Player component.
410
+ *
411
+ * Note: PlayerProvider is available if you need to use context-based access
412
+ * across multiple components. PlayerInner manages its own PlayerController
413
+ * via usePlayerController hook.
414
+ */
415
+ const Player: React.FC<PlayerProps> = (props) => {
416
+ return <PlayerInner {...props} />;
417
+ };
418
+
419
+ export default Player;