@livepeer-frameworks/player-wc 0.1.2 → 0.1.4

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 (116) hide show
  1. package/dist/cjs/components/fw-dev-mode-panel.js +845 -212
  2. package/dist/cjs/components/fw-dev-mode-panel.js.map +1 -1
  3. package/dist/cjs/components/fw-dvd-logo.js +211 -0
  4. package/dist/cjs/components/fw-dvd-logo.js.map +1 -0
  5. package/dist/cjs/components/fw-idle-screen.js +641 -97
  6. package/dist/cjs/components/fw-idle-screen.js.map +1 -1
  7. package/dist/cjs/components/fw-loading-screen.js +513 -0
  8. package/dist/cjs/components/fw-loading-screen.js.map +1 -0
  9. package/dist/cjs/components/fw-player-controls.js +390 -173
  10. package/dist/cjs/components/fw-player-controls.js.map +1 -1
  11. package/dist/cjs/components/fw-player.js +506 -63
  12. package/dist/cjs/components/fw-player.js.map +1 -1
  13. package/dist/cjs/components/fw-seek-bar.js +292 -142
  14. package/dist/cjs/components/fw-seek-bar.js.map +1 -1
  15. package/dist/cjs/components/fw-settings-menu.js +208 -81
  16. package/dist/cjs/components/fw-settings-menu.js.map +1 -1
  17. package/dist/cjs/components/fw-stats-panel.js +134 -70
  18. package/dist/cjs/components/fw-stats-panel.js.map +1 -1
  19. package/dist/cjs/components/fw-stream-state-overlay.js +338 -0
  20. package/dist/cjs/components/fw-stream-state-overlay.js.map +1 -0
  21. package/dist/cjs/components/fw-subtitle-renderer.js +174 -27
  22. package/dist/cjs/components/fw-subtitle-renderer.js.map +1 -1
  23. package/dist/cjs/components/fw-thumbnail-overlay.js +161 -0
  24. package/dist/cjs/components/fw-thumbnail-overlay.js.map +1 -0
  25. package/dist/cjs/components/fw-volume-control.js +150 -69
  26. package/dist/cjs/components/fw-volume-control.js.map +1 -1
  27. package/dist/cjs/components/shared/hitmarker-audio.js +76 -0
  28. package/dist/cjs/components/shared/hitmarker-audio.js.map +1 -0
  29. package/dist/cjs/constants/media-assets.js +11 -0
  30. package/dist/cjs/constants/media-assets.js.map +1 -0
  31. package/dist/cjs/controllers/player-controller-host.js +51 -2
  32. package/dist/cjs/controllers/player-controller-host.js.map +1 -1
  33. package/dist/cjs/define.js +8 -0
  34. package/dist/cjs/define.js.map +1 -1
  35. package/dist/cjs/icons/index.js +27 -0
  36. package/dist/cjs/icons/index.js.map +1 -1
  37. package/dist/cjs/index.js +20 -0
  38. package/dist/cjs/index.js.map +1 -1
  39. package/dist/esm/components/fw-dev-mode-panel.js +846 -213
  40. package/dist/esm/components/fw-dev-mode-panel.js.map +1 -1
  41. package/dist/esm/components/fw-dvd-logo.js +211 -0
  42. package/dist/esm/components/fw-dvd-logo.js.map +1 -0
  43. package/dist/esm/components/fw-idle-screen.js +643 -99
  44. package/dist/esm/components/fw-idle-screen.js.map +1 -1
  45. package/dist/esm/components/fw-loading-screen.js +513 -0
  46. package/dist/esm/components/fw-loading-screen.js.map +1 -0
  47. package/dist/esm/components/fw-player-controls.js +391 -174
  48. package/dist/esm/components/fw-player-controls.js.map +1 -1
  49. package/dist/esm/components/fw-player.js +506 -63
  50. package/dist/esm/components/fw-player.js.map +1 -1
  51. package/dist/esm/components/fw-seek-bar.js +293 -143
  52. package/dist/esm/components/fw-seek-bar.js.map +1 -1
  53. package/dist/esm/components/fw-settings-menu.js +209 -82
  54. package/dist/esm/components/fw-settings-menu.js.map +1 -1
  55. package/dist/esm/components/fw-stats-panel.js +135 -71
  56. package/dist/esm/components/fw-stats-panel.js.map +1 -1
  57. package/dist/esm/components/fw-stream-state-overlay.js +338 -0
  58. package/dist/esm/components/fw-stream-state-overlay.js.map +1 -0
  59. package/dist/esm/components/fw-subtitle-renderer.js +175 -28
  60. package/dist/esm/components/fw-subtitle-renderer.js.map +1 -1
  61. package/dist/esm/components/fw-thumbnail-overlay.js +161 -0
  62. package/dist/esm/components/fw-thumbnail-overlay.js.map +1 -0
  63. package/dist/esm/components/fw-volume-control.js +150 -69
  64. package/dist/esm/components/fw-volume-control.js.map +1 -1
  65. package/dist/esm/components/shared/hitmarker-audio.js +74 -0
  66. package/dist/esm/components/shared/hitmarker-audio.js.map +1 -0
  67. package/dist/esm/constants/media-assets.js +8 -0
  68. package/dist/esm/constants/media-assets.js.map +1 -0
  69. package/dist/esm/controllers/player-controller-host.js +51 -2
  70. package/dist/esm/controllers/player-controller-host.js.map +1 -1
  71. package/dist/esm/define.js +8 -0
  72. package/dist/esm/define.js.map +1 -1
  73. package/dist/esm/icons/index.js +26 -2
  74. package/dist/esm/icons/index.js.map +1 -1
  75. package/dist/esm/index.js +4 -0
  76. package/dist/esm/index.js.map +1 -1
  77. package/dist/fw-player.iife.js +2097 -883
  78. package/dist/types/components/fw-dev-mode-panel.d.ts +36 -9
  79. package/dist/types/components/fw-dvd-logo.d.ts +29 -0
  80. package/dist/types/components/fw-idle-screen.d.ts +36 -0
  81. package/dist/types/components/fw-loading-screen.d.ts +36 -0
  82. package/dist/types/components/fw-player-controls.d.ts +23 -6
  83. package/dist/types/components/fw-player.d.ts +32 -1
  84. package/dist/types/components/fw-seek-bar.d.ts +31 -14
  85. package/dist/types/components/fw-settings-menu.d.ts +16 -1
  86. package/dist/types/components/fw-stats-panel.d.ts +4 -4
  87. package/dist/types/components/fw-stream-state-overlay.d.ts +20 -0
  88. package/dist/types/components/fw-subtitle-renderer.d.ts +33 -2
  89. package/dist/types/components/fw-thumbnail-overlay.d.ts +17 -0
  90. package/dist/types/components/fw-volume-control.d.ts +11 -4
  91. package/dist/types/components/shared/hitmarker-audio.d.ts +1 -0
  92. package/dist/types/constants/media-assets.d.ts +5 -0
  93. package/dist/types/controllers/player-controller-host.d.ts +14 -1
  94. package/dist/types/iife-entry.d.ts +4 -0
  95. package/dist/types/index.d.ts +4 -0
  96. package/package.json +2 -2
  97. package/src/components/fw-dev-mode-panel.ts +929 -228
  98. package/src/components/fw-dvd-logo.ts +233 -0
  99. package/src/components/fw-idle-screen.ts +680 -100
  100. package/src/components/fw-loading-screen.ts +540 -0
  101. package/src/components/fw-player-controls.ts +475 -175
  102. package/src/components/fw-player.ts +551 -60
  103. package/src/components/fw-seek-bar.ts +336 -143
  104. package/src/components/fw-settings-menu.ts +248 -85
  105. package/src/components/fw-stats-panel.ts +150 -77
  106. package/src/components/fw-stream-state-overlay.ts +331 -0
  107. package/src/components/fw-subtitle-renderer.ts +216 -28
  108. package/src/components/fw-thumbnail-overlay.ts +148 -0
  109. package/src/components/fw-volume-control.ts +166 -66
  110. package/src/components/shared/hitmarker-audio.ts +92 -0
  111. package/src/constants/media-assets.ts +7 -0
  112. package/src/controllers/player-controller-host.ts +52 -3
  113. package/src/define.ts +8 -0
  114. package/src/iife-entry.ts +4 -0
  115. package/src/index.ts +4 -0
  116. package/dist/fw-player.iife.js.map +0 -1
@@ -1,8 +1,8 @@
1
1
  /**
2
- * <fw-player-controls> — Control bar with play/pause, seek, volume, quality, fullscreen.
3
- * Port of PlayerControls.tsx from player-react.
2
+ * <fw-player-controls> — Player controls with seek, volume, live state, and settings.
3
+ * Parity port of React/Svelte control behavior.
4
4
  */
5
- import { LitElement, html, css, nothing } from "lit";
5
+ import { LitElement, html, css, nothing, type PropertyValues } from "lit";
6
6
  import { customElement, property, state } from "lit/decorators.js";
7
7
  import { classMap } from "lit/directives/class-map.js";
8
8
  import { sharedStyles } from "../styles/shared-styles.js";
@@ -14,18 +14,51 @@ import {
14
14
  fullscreenExitIcon,
15
15
  settingsIcon,
16
16
  seekToLiveIcon,
17
+ skipBackIcon,
18
+ skipForwardIcon,
19
+ statsIcon,
17
20
  } from "../icons/index.js";
21
+ import {
22
+ calculateIsNearLive,
23
+ calculateLiveThresholds,
24
+ calculateSeekableRange,
25
+ canSeekStream,
26
+ formatTimeDisplay,
27
+ isLiveContent,
28
+ isMediaStreamSource,
29
+ type MistStreamInfo,
30
+ type PlaybackMode,
31
+ } from "@livepeer-frameworks/player-core";
18
32
  import type { PlayerControllerHost } from "../controllers/player-controller-host.js";
19
- import type { PlaybackMode } from "@livepeer-frameworks/player-core";
33
+
34
+ interface SeekingContext {
35
+ mistStreamInfo?: MistStreamInfo;
36
+ isLive: boolean;
37
+ sourceType?: string;
38
+ seekableStart: number;
39
+ liveEdge: number;
40
+ hasDvrWindow: boolean;
41
+ canSeek: boolean;
42
+ commitOnRelease: boolean;
43
+ liveThresholds: ReturnType<typeof calculateLiveThresholds>;
44
+ }
20
45
 
21
46
  @customElement("fw-player-controls")
22
47
  export class FwPlayerControls extends LitElement {
23
48
  @property({ attribute: false }) pc!: PlayerControllerHost;
24
49
  @property({ type: String }) playbackMode: PlaybackMode = "auto";
25
50
  @property({ type: Boolean, attribute: "is-content-live" }) isContentLive = false;
51
+ @property({ type: Boolean, attribute: "dev-mode" }) devMode = false;
52
+ @property({ type: Boolean, attribute: "is-dev-panel-open" }) isDevPanelOpen = false;
53
+ @property({ type: Boolean, attribute: "show-stats-button" }) showStatsButton = false;
26
54
  @property({ type: Boolean, attribute: "is-stats-open" }) isStatsOpen = false;
27
55
 
28
56
  @state() private _settingsOpen = false;
57
+ @state() private _isNearLiveState = true;
58
+ @state() private _buffered: TimeRanges | null = null;
59
+
60
+ private _boundVideo: HTMLVideoElement | null = null;
61
+ private _onBufferedUpdate: (() => void) | null = null;
29
62
 
30
63
  static styles = [
31
64
  sharedStyles,
@@ -34,213 +67,480 @@ export class FwPlayerControls extends LitElement {
34
67
  :host {
35
68
  display: contents;
36
69
  }
37
- .controls-wrapper {
38
- position: absolute;
39
- bottom: 0;
40
- left: 0;
41
- right: 0;
42
- z-index: 20;
43
- background: linear-gradient(to top, rgb(0 0 0 / 0.7), transparent);
44
- padding: 2rem 0.75rem 0.5rem;
45
- opacity: 0;
46
- transition: opacity 200ms ease;
47
- pointer-events: none;
48
- }
49
- .controls-wrapper--visible {
50
- opacity: 1;
51
- pointer-events: auto;
52
- }
53
- .bar {
54
- display: flex;
55
- flex-direction: column;
56
- gap: 0.25rem;
57
- }
58
- .row {
59
- display: flex;
60
- align-items: center;
61
- justify-content: space-between;
62
- gap: 0.25rem;
63
- }
64
- .group {
65
- display: flex;
66
- align-items: center;
67
- gap: 0.125rem;
68
- }
69
- .btn {
70
- display: flex;
71
- align-items: center;
72
- justify-content: center;
73
- width: 2rem;
74
- height: 2rem;
75
- background: none;
76
- border: none;
77
- color: rgb(255 255 255 / 0.8);
78
- cursor: pointer;
79
- padding: 0;
80
- border-radius: 0.25rem;
81
- transition: color 150ms;
82
- }
83
- .btn:hover {
84
- color: white;
85
- }
86
- .btn:disabled {
87
- opacity: 0.4;
88
- cursor: not-allowed;
89
- }
90
- .time {
91
- font-size: 0.6875rem;
92
- color: rgb(255 255 255 / 0.7);
93
- font-variant-numeric: tabular-nums;
94
- padding: 0 0.375rem;
95
- white-space: nowrap;
70
+
71
+ .fw-settings-anchor {
72
+ position: relative;
96
73
  }
97
- .live-badge {
98
- display: inline-flex;
99
- align-items: center;
100
- gap: 0.375rem;
101
- padding: 0.125rem 0.5rem;
102
- border-radius: 0.25rem;
103
- font-size: 0.6875rem;
104
- font-weight: 600;
105
- text-transform: uppercase;
106
- letter-spacing: 0.025em;
107
- cursor: pointer;
108
- border: none;
109
- background: none;
110
- transition: color 150ms;
74
+
75
+ .fw-dev-toggle-label {
76
+ font-size: 10px;
77
+ font-weight: 700;
78
+ letter-spacing: 0.08em;
111
79
  }
112
- .live-badge--active {
113
- color: hsl(var(--tn-red, 348 74% 64%));
80
+ `,
81
+ ];
82
+
83
+ connectedCallback(): void {
84
+ super.connectedCallback();
85
+ }
86
+
87
+ disconnectedCallback(): void {
88
+ super.disconnectedCallback();
89
+ this._unbindVideoEvents();
90
+ this._detachWindowClickListener();
91
+ }
92
+
93
+ protected updated(changed: PropertyValues<this>): void {
94
+ this._bindVideoEvents();
95
+ this._reconcileNearLiveState();
96
+
97
+ if (changed.has("_settingsOpen" as keyof FwPlayerControls)) {
98
+ if (this._settingsOpen) {
99
+ this._attachWindowClickListener();
100
+ } else {
101
+ this._detachWindowClickListener();
114
102
  }
115
- .live-badge--behind {
116
- color: rgb(255 255 255 / 0.5);
103
+ }
104
+ }
105
+
106
+ private _bindVideoEvents(): void {
107
+ const video = this.pc?.s.videoElement ?? null;
108
+ if (video === this._boundVideo) {
109
+ return;
110
+ }
111
+
112
+ this._unbindVideoEvents();
113
+ this._boundVideo = video;
114
+
115
+ if (!video) {
116
+ this._buffered = null;
117
+ return;
118
+ }
119
+
120
+ const updateBuffered = () => {
121
+ this._buffered = this.pc.getBufferedRanges() ?? video.buffered;
122
+ };
123
+
124
+ updateBuffered();
125
+ video.addEventListener("progress", updateBuffered);
126
+ video.addEventListener("loadeddata", updateBuffered);
127
+ this._onBufferedUpdate = updateBuffered;
128
+ }
129
+
130
+ private _unbindVideoEvents(): void {
131
+ if (!this._boundVideo) {
132
+ return;
133
+ }
134
+
135
+ const updateBuffered = this._onBufferedUpdate;
136
+ if (updateBuffered) {
137
+ this._boundVideo.removeEventListener("progress", updateBuffered);
138
+ this._boundVideo.removeEventListener("loadeddata", updateBuffered);
139
+ }
140
+
141
+ this._boundVideo = null;
142
+ this._onBufferedUpdate = null;
143
+ }
144
+
145
+ private _attachWindowClickListener(): void {
146
+ window.setTimeout(() => {
147
+ if (!this._settingsOpen) {
148
+ return;
117
149
  }
118
- .live-dot {
119
- width: 6px;
120
- height: 6px;
121
- border-radius: 50%;
122
- background: currentColor;
150
+ window.addEventListener("click", this._onWindowClick);
151
+ }, 0);
152
+ }
153
+
154
+ private _detachWindowClickListener(): void {
155
+ window.removeEventListener("click", this._onWindowClick);
156
+ }
157
+
158
+ private _onWindowClick = (event: MouseEvent): void => {
159
+ const path = event.composedPath();
160
+ const insideControls = path.some((entry) => {
161
+ if (!(entry instanceof HTMLElement)) {
162
+ return false;
123
163
  }
124
- .settings-anchor {
125
- position: relative;
164
+ return (
165
+ entry.classList.contains("fw-settings-anchor") ||
166
+ entry.classList.contains("fw-settings-menu")
167
+ );
168
+ });
169
+
170
+ if (!insideControls) {
171
+ this._settingsOpen = false;
172
+ }
173
+ };
174
+
175
+ private _deriveBufferWindowMs(
176
+ tracks?: Record<string, { firstms?: number; lastms?: number }>
177
+ ): number | undefined {
178
+ if (!tracks) {
179
+ return undefined;
180
+ }
181
+
182
+ const trackValues = Object.values(tracks);
183
+ if (trackValues.length === 0) {
184
+ return undefined;
185
+ }
186
+
187
+ const firstmsValues = trackValues
188
+ .map((track) => track.firstms)
189
+ .filter((value): value is number => typeof value === "number");
190
+ const lastmsValues = trackValues
191
+ .map((track) => track.lastms)
192
+ .filter((value): value is number => typeof value === "number");
193
+
194
+ if (firstmsValues.length === 0 || lastmsValues.length === 0) {
195
+ return undefined;
196
+ }
197
+
198
+ const firstms = Math.max(...firstmsValues);
199
+ const lastms = Math.min(...lastmsValues);
200
+ const window = lastms - firstms;
201
+
202
+ if (!Number.isFinite(window) || window <= 0) {
203
+ return undefined;
204
+ }
205
+
206
+ return window;
207
+ }
208
+
209
+ private _getSeekingContext(): SeekingContext {
210
+ const state = this.pc.s;
211
+ const controller = this.pc.getController();
212
+ const sourceType = state.currentSourceInfo?.type;
213
+ const mistStreamInfo = state.streamState?.streamInfo as MistStreamInfo | undefined;
214
+
215
+ const isLive = isLiveContent(this.isContentLive, mistStreamInfo, state.duration);
216
+ const bufferWindowMs =
217
+ mistStreamInfo?.meta?.buffer_window ??
218
+ this._deriveBufferWindowMs(
219
+ mistStreamInfo?.meta?.tracks as
220
+ | Record<string, { firstms?: number; lastms?: number }>
221
+ | undefined
222
+ );
223
+
224
+ const isWebRTC = isMediaStreamSource(state.videoElement);
225
+
226
+ const allowMediaStreamDvr =
227
+ isMediaStreamSource(state.videoElement) &&
228
+ bufferWindowMs !== undefined &&
229
+ bufferWindowMs > 0 &&
230
+ sourceType !== "whep" &&
231
+ sourceType !== "webrtc";
232
+
233
+ const calculatedRange = calculateSeekableRange({
234
+ isLive,
235
+ video: state.videoElement,
236
+ mistStreamInfo,
237
+ currentTime: state.currentTime,
238
+ duration: state.duration,
239
+ allowMediaStreamDvr,
240
+ });
241
+
242
+ const controllerSeekableStart = this.pc.getSeekableStart();
243
+ const controllerLiveEdge = this.pc.getLiveEdge();
244
+
245
+ const useControllerRange =
246
+ Number.isFinite(controllerSeekableStart) &&
247
+ Number.isFinite(controllerLiveEdge) &&
248
+ controllerLiveEdge >= controllerSeekableStart &&
249
+ (controllerLiveEdge > 0 || controllerSeekableStart > 0);
250
+
251
+ const seekableStart = useControllerRange
252
+ ? controllerSeekableStart
253
+ : calculatedRange.seekableStart;
254
+ const liveEdge = useControllerRange ? controllerLiveEdge : calculatedRange.liveEdge;
255
+
256
+ const hasDvrWindow =
257
+ isLive &&
258
+ Number.isFinite(liveEdge) &&
259
+ Number.isFinite(seekableStart) &&
260
+ liveEdge > seekableStart;
261
+
262
+ const baseCanSeek =
263
+ controller?.canSeekStream?.() ??
264
+ canSeekStream({
265
+ video: state.videoElement,
266
+ isLive,
267
+ duration: state.duration,
268
+ bufferWindowMs,
269
+ });
270
+
271
+ const liveThresholds = calculateLiveThresholds(sourceType, isWebRTC, bufferWindowMs);
272
+
273
+ return {
274
+ mistStreamInfo,
275
+ isLive,
276
+ sourceType,
277
+ seekableStart,
278
+ liveEdge,
279
+ hasDvrWindow,
280
+ canSeek: baseCanSeek && (!isLive || hasDvrWindow),
281
+ commitOnRelease: isLive,
282
+ liveThresholds,
283
+ };
284
+ }
285
+
286
+ private _reconcileNearLiveState(): void {
287
+ const context = this._getSeekingContext();
288
+
289
+ if (!context.isLive) {
290
+ if (!this._isNearLiveState) {
291
+ this._isNearLiveState = true;
126
292
  }
127
- `,
128
- ];
293
+ return;
294
+ }
129
295
 
130
- private _formatTime(seconds: number): string {
131
- if (!isFinite(seconds) || isNaN(seconds)) return "0:00";
132
- const abs = Math.abs(Math.floor(seconds));
133
- const h = Math.floor(abs / 3600);
134
- const m = Math.floor((abs % 3600) / 60);
135
- const s = abs % 60;
136
- if (h > 0) return `${h}:${String(m).padStart(2, "0")}:${String(s).padStart(2, "0")}`;
137
- return `${m}:${String(s).padStart(2, "0")}`;
296
+ const next = calculateIsNearLive(
297
+ this.pc.s.currentTime,
298
+ context.liveEdge,
299
+ context.liveThresholds,
300
+ this._isNearLiveState
301
+ );
302
+
303
+ if (next !== this._isNearLiveState) {
304
+ this._isNearLiveState = next;
305
+ }
138
306
  }
139
307
 
140
- private get _isNearLive(): boolean {
141
- if (!this.isContentLive) return false;
142
- const { currentTime, duration } = this.pc.s;
143
- if (!isFinite(duration) || duration <= 0) return true;
144
- return duration - currentTime < 10;
308
+ private _handleModeChange(
309
+ event: CustomEvent<{ mode: "auto" | "low-latency" | "quality" }>
310
+ ): void {
311
+ const { mode } = event.detail;
312
+ this.dispatchEvent(
313
+ new CustomEvent("fw-mode-change", {
314
+ detail: { mode },
315
+ bubbles: true,
316
+ composed: true,
317
+ })
318
+ );
145
319
  }
146
320
 
147
321
  protected render() {
148
- const s = this.pc.s;
149
- const disabled = !s.videoElement;
322
+ const state = this.pc.s;
323
+ const disabled = !state.videoElement;
324
+ const context = this._getSeekingContext();
325
+ const shouldShowControls =
326
+ state.shouldShowControls ||
327
+ state.isPaused ||
328
+ !state.hasPlaybackStarted ||
329
+ state.shouldShowIdleScreen ||
330
+ !!state.error ||
331
+ this._settingsOpen;
332
+
333
+ const timeDisplay = formatTimeDisplay({
334
+ isLive: context.isLive,
335
+ currentTime: state.currentTime,
336
+ duration: state.duration,
337
+ liveEdge: context.liveEdge,
338
+ seekableStart: context.seekableStart,
339
+ unixoffset: context.mistStreamInfo?.unixoffset,
340
+ });
341
+
342
+ const liveButtonDisabled = !context.hasDvrWindow || this._isNearLiveState;
150
343
 
151
344
  return html`
152
345
  <div
153
346
  class=${classMap({
154
- "controls-wrapper": true,
155
- "controls-wrapper--visible": s.shouldShowControls,
347
+ "fw-player-surface": true,
156
348
  "fw-controls-wrapper": true,
349
+ "fw-controls-wrapper--visible": shouldShowControls,
350
+ "fw-controls-wrapper--hidden": !shouldShowControls,
157
351
  })}
158
352
  >
159
- <div class="bar fw-control-bar">
160
- <!-- Seek bar -->
161
- <fw-seek-bar
162
- .currentTime=${s.currentTime}
163
- .duration=${s.duration}
164
- .disabled=${disabled}
165
- .isLive=${this.isContentLive}
166
- @fw-seek=${(e: CustomEvent) => this.pc.seek(e.detail.time)}
167
- ></fw-seek-bar>
168
-
169
- <!-- Button row -->
170
- <div class="row">
171
- <div class="group fw-control-group">
172
- <!-- Play/Pause -->
173
- <button
174
- class="btn fw-btn-flush"
175
- type="button"
176
- ?disabled=${disabled}
177
- @click=${() => this.pc.togglePlay()}
178
- aria-label="${s.isPlaying ? "Pause" : "Play"}"
179
- >
180
- ${s.isPlaying ? pauseIcon(18) : playIcon(18)}
181
- </button>
182
-
183
- <!-- Volume -->
184
- <fw-volume-control .pc=${this.pc}></fw-volume-control>
185
-
186
- <!-- Time display -->
187
- ${!this.isContentLive
353
+ <div class="fw-control-bar" @click=${(event: Event) => event.stopPropagation()}>
354
+ ${context.canSeek
355
+ ? html`
356
+ <div class="fw-seek-wrapper">
357
+ <fw-seek-bar
358
+ .currentTime=${state.currentTime}
359
+ .duration=${state.duration}
360
+ .buffered=${this._buffered}
361
+ .disabled=${disabled}
362
+ .isLive=${context.isLive}
363
+ .seekableStart=${context.seekableStart}
364
+ .liveEdge=${context.liveEdge}
365
+ .commitOnRelease=${context.commitOnRelease}
366
+ @fw-seek=${(event: CustomEvent<{ time: number }>) =>
367
+ this.pc.seek(event.detail.time)}
368
+ ></fw-seek-bar>
369
+ </div>
370
+ `
371
+ : nothing}
372
+
373
+ <div class="fw-controls-row">
374
+ <div class="fw-controls-left">
375
+ <div class="fw-control-group">
376
+ <button
377
+ type="button"
378
+ class="fw-btn-flush"
379
+ ?disabled=${disabled}
380
+ aria-label=${state.isPlaying ? "Pause" : "Play"}
381
+ @click=${() => this.pc.togglePlay()}
382
+ >
383
+ ${state.isPlaying ? pauseIcon(18) : playIcon(18)}
384
+ </button>
385
+
386
+ ${context.canSeek
387
+ ? html`
388
+ <button
389
+ type="button"
390
+ class="fw-btn-flush hidden sm:flex"
391
+ ?disabled=${disabled}
392
+ aria-label="Skip back 10 seconds"
393
+ @click=${() => this.pc.seekBy(-10)}
394
+ >
395
+ ${skipBackIcon(16)}
396
+ </button>
397
+ <button
398
+ type="button"
399
+ class="fw-btn-flush hidden sm:flex"
400
+ ?disabled=${disabled}
401
+ aria-label="Skip forward 10 seconds"
402
+ @click=${() => this.pc.seekBy(10)}
403
+ >
404
+ ${skipForwardIcon(16)}
405
+ </button>
406
+ `
407
+ : nothing}
408
+ </div>
409
+
410
+ <div class="fw-control-group">
411
+ <fw-volume-control .pc=${this.pc}></fw-volume-control>
412
+ </div>
413
+
414
+ <div class="fw-control-group">
415
+ <span class="fw-time-display">${timeDisplay}</span>
416
+ </div>
417
+
418
+ ${context.isLive
188
419
  ? html`
189
- <span class="time fw-time-display">
190
- ${this._formatTime(s.currentTime)} / ${this._formatTime(s.duration)}
191
- </span>
420
+ <div class="fw-control-group">
421
+ <button
422
+ type="button"
423
+ @click=${() => this.pc.jumpToLive()}
424
+ ?disabled=${liveButtonDisabled}
425
+ class=${classMap({
426
+ "fw-live-badge": true,
427
+ "fw-live-badge--active": liveButtonDisabled,
428
+ "fw-live-badge--behind": !liveButtonDisabled,
429
+ })}
430
+ title=${!context.hasDvrWindow
431
+ ? "Live only"
432
+ : this._isNearLiveState
433
+ ? "At live edge"
434
+ : "Jump to live"}
435
+ >
436
+ LIVE
437
+ ${!this._isNearLiveState && context.hasDvrWindow
438
+ ? seekToLiveIcon(10)
439
+ : nothing}
440
+ </button>
441
+ </div>
192
442
  `
193
443
  : nothing}
444
+ </div>
194
445
 
195
- <!-- Live badge -->
196
- ${this.isContentLive
446
+ <div class="fw-controls-right">
447
+ ${this.showStatsButton
197
448
  ? html`
198
- <button
199
- class=${classMap({
200
- "live-badge": true,
201
- "fw-live-badge": true,
202
- "live-badge--active": this._isNearLive,
203
- "fw-live-badge--active": this._isNearLive,
204
- "live-badge--behind": !this._isNearLive,
205
- "fw-live-badge--behind": !this._isNearLive,
206
- })}
207
- type="button"
208
- @click=${() => this.pc.jumpToLive()}
209
- aria-label="Jump to live"
210
- >
211
- <span class="live-dot"></span>
212
- LIVE
213
- </button>
449
+ <div class="fw-control-group">
450
+ <button
451
+ type="button"
452
+ class=${classMap({
453
+ "fw-btn-flush": true,
454
+ "fw-btn-flush--active": this.isStatsOpen,
455
+ })}
456
+ aria-label="Toggle stats"
457
+ title="Stats"
458
+ @click=${() =>
459
+ this.dispatchEvent(
460
+ new CustomEvent("fw-stats-toggle", {
461
+ bubbles: true,
462
+ composed: true,
463
+ })
464
+ )}
465
+ >
466
+ ${statsIcon(16)}
467
+ </button>
468
+ </div>
469
+ `
470
+ : nothing}
471
+ ${this.devMode
472
+ ? html`
473
+ <div class="fw-control-group">
474
+ <button
475
+ type="button"
476
+ class=${classMap({
477
+ "fw-btn-flush": true,
478
+ "fw-btn-flush--active": this.isDevPanelOpen,
479
+ })}
480
+ aria-label=${this.isDevPanelOpen
481
+ ? "Hide advanced settings sidebar"
482
+ : "Show advanced settings sidebar"}
483
+ title=${this.isDevPanelOpen ? "Hide advanced sidebar" : "Advanced sidebar"}
484
+ @click=${() =>
485
+ this.dispatchEvent(
486
+ new CustomEvent("fw-dev-panel-toggle", {
487
+ bubbles: true,
488
+ composed: true,
489
+ })
490
+ )}
491
+ >
492
+ <span class="fw-dev-toggle-label">ADV</span>
493
+ </button>
494
+ </div>
214
495
  `
215
496
  : nothing}
216
- </div>
217
497
 
218
- <div class="group fw-control-group">
219
- <!-- Settings -->
220
- <div class="settings-anchor">
498
+ <div class="fw-control-group fw-settings-anchor">
221
499
  <button
222
- class="btn fw-btn-flush"
223
500
  type="button"
501
+ class=${classMap({
502
+ "fw-btn-flush": true,
503
+ group: true,
504
+ "fw-btn-flush--active": this._settingsOpen,
505
+ })}
506
+ aria-label="Settings"
507
+ title="Settings"
508
+ ?disabled=${disabled}
224
509
  @click=${() => {
510
+ if (disabled) {
511
+ return;
512
+ }
225
513
  this._settingsOpen = !this._settingsOpen;
226
514
  }}
227
- aria-label="Settings"
228
515
  >
229
- ${settingsIcon(16)}
516
+ <span class="transition-transform group-hover:rotate-90"
517
+ >${settingsIcon(16)}</span
518
+ >
230
519
  </button>
231
- <fw-settings-menu .pc=${this.pc} .open=${this._settingsOpen}></fw-settings-menu>
520
+
521
+ <fw-settings-menu
522
+ .pc=${this.pc}
523
+ .open=${this._settingsOpen}
524
+ .playbackMode=${this.playbackMode}
525
+ .isContentLive=${this.isContentLive}
526
+ @fw-close=${() => {
527
+ this._settingsOpen = false;
528
+ }}
529
+ @fw-mode-change=${this._handleModeChange}
530
+ ></fw-settings-menu>
232
531
  </div>
233
532
 
234
- <!-- Fullscreen -->
235
- <button
236
- class="btn fw-btn-flush"
237
- type="button"
238
- ?disabled=${disabled}
239
- @click=${() => this.pc.toggleFullscreen()}
240
- aria-label="${s.isFullscreen ? "Exit fullscreen" : "Fullscreen"}"
241
- >
242
- ${s.isFullscreen ? fullscreenExitIcon(16) : fullscreenIcon(16)}
243
- </button>
533
+ <div class="fw-control-group">
534
+ <button
535
+ type="button"
536
+ class="fw-btn-flush"
537
+ ?disabled=${disabled}
538
+ aria-label=${state.isFullscreen ? "Exit fullscreen" : "Fullscreen"}
539
+ @click=${() => this.pc.toggleFullscreen()}
540
+ >
541
+ ${state.isFullscreen ? fullscreenExitIcon(16) : fullscreenIcon(16)}
542
+ </button>
543
+ </div>
244
544
  </div>
245
545
  </div>
246
546
  </div>