@livepeer-frameworks/player-wc 0.1.2 → 0.1.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 (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 +347 -173
  10. package/dist/cjs/components/fw-player-controls.js.map +1 -1
  11. package/dist/cjs/components/fw-player.js +460 -60
  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 +191 -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 +28 -1
  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 +348 -174
  48. package/dist/esm/components/fw-player-controls.js.map +1 -1
  49. package/dist/esm/components/fw-player.js +460 -60
  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 +192 -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 +28 -1
  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 +2072 -880
  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 +21 -6
  83. package/dist/types/components/fw-player.d.ts +28 -1
  84. package/dist/types/components/fw-seek-bar.d.ts +31 -14
  85. package/dist/types/components/fw-settings-menu.d.ts +15 -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 +435 -176
  102. package/src/components/fw-player.ts +505 -57
  103. package/src/components/fw-seek-bar.ts +336 -143
  104. package/src/components/fw-settings-menu.ts +208 -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 +29 -2
  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,49 @@ 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: "show-stats-button" }) showStatsButton = false;
26
52
  @property({ type: Boolean, attribute: "is-stats-open" }) isStatsOpen = false;
27
53
 
28
54
  @state() private _settingsOpen = false;
55
+ @state() private _isNearLiveState = true;
56
+ @state() private _buffered: TimeRanges | null = null;
57
+
58
+ private _boundVideo: HTMLVideoElement | null = null;
59
+ private _onBufferedUpdate: (() => void) | null = null;
29
60
 
30
61
  static styles = [
31
62
  sharedStyles,
@@ -34,213 +65,441 @@ export class FwPlayerControls extends LitElement {
34
65
  :host {
35
66
  display: contents;
36
67
  }
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;
96
- }
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;
68
+
69
+ .fw-settings-anchor {
70
+ position: relative;
111
71
  }
112
- .live-badge--active {
113
- color: hsl(var(--tn-red, 348 74% 64%));
72
+ `,
73
+ ];
74
+
75
+ connectedCallback(): void {
76
+ super.connectedCallback();
77
+ }
78
+
79
+ disconnectedCallback(): void {
80
+ super.disconnectedCallback();
81
+ this._unbindVideoEvents();
82
+ this._detachWindowClickListener();
83
+ }
84
+
85
+ protected updated(changed: PropertyValues<this>): void {
86
+ this._bindVideoEvents();
87
+ this._reconcileNearLiveState();
88
+
89
+ if (changed.has("_settingsOpen" as keyof FwPlayerControls)) {
90
+ if (this._settingsOpen) {
91
+ this._attachWindowClickListener();
92
+ } else {
93
+ this._detachWindowClickListener();
114
94
  }
115
- .live-badge--behind {
116
- color: rgb(255 255 255 / 0.5);
95
+ }
96
+ }
97
+
98
+ private _bindVideoEvents(): void {
99
+ const video = this.pc?.s.videoElement ?? null;
100
+ if (video === this._boundVideo) {
101
+ return;
102
+ }
103
+
104
+ this._unbindVideoEvents();
105
+ this._boundVideo = video;
106
+
107
+ if (!video) {
108
+ this._buffered = null;
109
+ return;
110
+ }
111
+
112
+ const updateBuffered = () => {
113
+ this._buffered = this.pc.getBufferedRanges() ?? video.buffered;
114
+ };
115
+
116
+ updateBuffered();
117
+ video.addEventListener("progress", updateBuffered);
118
+ video.addEventListener("loadeddata", updateBuffered);
119
+ this._onBufferedUpdate = updateBuffered;
120
+ }
121
+
122
+ private _unbindVideoEvents(): void {
123
+ if (!this._boundVideo) {
124
+ return;
125
+ }
126
+
127
+ const updateBuffered = this._onBufferedUpdate;
128
+ if (updateBuffered) {
129
+ this._boundVideo.removeEventListener("progress", updateBuffered);
130
+ this._boundVideo.removeEventListener("loadeddata", updateBuffered);
131
+ }
132
+
133
+ this._boundVideo = null;
134
+ this._onBufferedUpdate = null;
135
+ }
136
+
137
+ private _attachWindowClickListener(): void {
138
+ window.setTimeout(() => {
139
+ if (!this._settingsOpen) {
140
+ return;
117
141
  }
118
- .live-dot {
119
- width: 6px;
120
- height: 6px;
121
- border-radius: 50%;
122
- background: currentColor;
142
+ window.addEventListener("click", this._onWindowClick);
143
+ }, 0);
144
+ }
145
+
146
+ private _detachWindowClickListener(): void {
147
+ window.removeEventListener("click", this._onWindowClick);
148
+ }
149
+
150
+ private _onWindowClick = (event: MouseEvent): void => {
151
+ const path = event.composedPath();
152
+ const insideControls = path.some((entry) => {
153
+ if (!(entry instanceof HTMLElement)) {
154
+ return false;
123
155
  }
124
- .settings-anchor {
125
- position: relative;
156
+ return (
157
+ entry.classList.contains("fw-settings-anchor") ||
158
+ entry.classList.contains("fw-settings-menu")
159
+ );
160
+ });
161
+
162
+ if (!insideControls) {
163
+ this._settingsOpen = false;
164
+ }
165
+ };
166
+
167
+ private _deriveBufferWindowMs(
168
+ tracks?: Record<string, { firstms?: number; lastms?: number }>
169
+ ): number | undefined {
170
+ if (!tracks) {
171
+ return undefined;
172
+ }
173
+
174
+ const trackValues = Object.values(tracks);
175
+ if (trackValues.length === 0) {
176
+ return undefined;
177
+ }
178
+
179
+ const firstmsValues = trackValues
180
+ .map((track) => track.firstms)
181
+ .filter((value): value is number => typeof value === "number");
182
+ const lastmsValues = trackValues
183
+ .map((track) => track.lastms)
184
+ .filter((value): value is number => typeof value === "number");
185
+
186
+ if (firstmsValues.length === 0 || lastmsValues.length === 0) {
187
+ return undefined;
188
+ }
189
+
190
+ const firstms = Math.max(...firstmsValues);
191
+ const lastms = Math.min(...lastmsValues);
192
+ const window = lastms - firstms;
193
+
194
+ if (!Number.isFinite(window) || window <= 0) {
195
+ return undefined;
196
+ }
197
+
198
+ return window;
199
+ }
200
+
201
+ private _getSeekingContext(): SeekingContext {
202
+ const state = this.pc.s;
203
+ const controller = this.pc.getController();
204
+ const sourceType = state.currentSourceInfo?.type;
205
+ const mistStreamInfo = state.streamState?.streamInfo as MistStreamInfo | undefined;
206
+
207
+ const isLive = isLiveContent(this.isContentLive, mistStreamInfo, state.duration);
208
+ const bufferWindowMs =
209
+ mistStreamInfo?.meta?.buffer_window ??
210
+ this._deriveBufferWindowMs(
211
+ mistStreamInfo?.meta?.tracks as
212
+ | Record<string, { firstms?: number; lastms?: number }>
213
+ | undefined
214
+ );
215
+
216
+ const isWebRTC = isMediaStreamSource(state.videoElement);
217
+
218
+ const allowMediaStreamDvr =
219
+ isMediaStreamSource(state.videoElement) &&
220
+ bufferWindowMs !== undefined &&
221
+ bufferWindowMs > 0 &&
222
+ sourceType !== "whep" &&
223
+ sourceType !== "webrtc";
224
+
225
+ const calculatedRange = calculateSeekableRange({
226
+ isLive,
227
+ video: state.videoElement,
228
+ mistStreamInfo,
229
+ currentTime: state.currentTime,
230
+ duration: state.duration,
231
+ allowMediaStreamDvr,
232
+ });
233
+
234
+ const controllerSeekableStart = this.pc.getSeekableStart();
235
+ const controllerLiveEdge = this.pc.getLiveEdge();
236
+
237
+ const useControllerRange =
238
+ Number.isFinite(controllerSeekableStart) &&
239
+ Number.isFinite(controllerLiveEdge) &&
240
+ controllerLiveEdge >= controllerSeekableStart &&
241
+ (controllerLiveEdge > 0 || controllerSeekableStart > 0);
242
+
243
+ const seekableStart = useControllerRange
244
+ ? controllerSeekableStart
245
+ : calculatedRange.seekableStart;
246
+ const liveEdge = useControllerRange ? controllerLiveEdge : calculatedRange.liveEdge;
247
+
248
+ const hasDvrWindow =
249
+ isLive &&
250
+ Number.isFinite(liveEdge) &&
251
+ Number.isFinite(seekableStart) &&
252
+ liveEdge > seekableStart;
253
+
254
+ const baseCanSeek =
255
+ controller?.canSeekStream?.() ??
256
+ canSeekStream({
257
+ video: state.videoElement,
258
+ isLive,
259
+ duration: state.duration,
260
+ bufferWindowMs,
261
+ });
262
+
263
+ const liveThresholds = calculateLiveThresholds(sourceType, isWebRTC, bufferWindowMs);
264
+
265
+ return {
266
+ mistStreamInfo,
267
+ isLive,
268
+ sourceType,
269
+ seekableStart,
270
+ liveEdge,
271
+ hasDvrWindow,
272
+ canSeek: baseCanSeek && (!isLive || hasDvrWindow),
273
+ commitOnRelease: isLive,
274
+ liveThresholds,
275
+ };
276
+ }
277
+
278
+ private _reconcileNearLiveState(): void {
279
+ const context = this._getSeekingContext();
280
+
281
+ if (!context.isLive) {
282
+ if (!this._isNearLiveState) {
283
+ this._isNearLiveState = true;
126
284
  }
127
- `,
128
- ];
285
+ return;
286
+ }
287
+
288
+ const next = calculateIsNearLive(
289
+ this.pc.s.currentTime,
290
+ context.liveEdge,
291
+ context.liveThresholds,
292
+ this._isNearLiveState
293
+ );
129
294
 
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")}`;
295
+ if (next !== this._isNearLiveState) {
296
+ this._isNearLiveState = next;
297
+ }
138
298
  }
139
299
 
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;
300
+ private _handleModeChange(
301
+ event: CustomEvent<{ mode: "auto" | "low-latency" | "quality" }>
302
+ ): void {
303
+ const { mode } = event.detail;
304
+ this.dispatchEvent(
305
+ new CustomEvent("fw-mode-change", {
306
+ detail: { mode },
307
+ bubbles: true,
308
+ composed: true,
309
+ })
310
+ );
145
311
  }
146
312
 
147
313
  protected render() {
148
- const s = this.pc.s;
149
- const disabled = !s.videoElement;
314
+ const state = this.pc.s;
315
+ const disabled = !state.videoElement;
316
+ const context = this._getSeekingContext();
317
+
318
+ const timeDisplay = formatTimeDisplay({
319
+ isLive: context.isLive,
320
+ currentTime: state.currentTime,
321
+ duration: state.duration,
322
+ liveEdge: context.liveEdge,
323
+ seekableStart: context.seekableStart,
324
+ unixoffset: context.mistStreamInfo?.unixoffset,
325
+ });
326
+
327
+ const liveButtonDisabled = !context.hasDvrWindow || this._isNearLiveState;
150
328
 
151
329
  return html`
152
330
  <div
153
331
  class=${classMap({
154
- "controls-wrapper": true,
155
- "controls-wrapper--visible": s.shouldShowControls,
332
+ "fw-player-surface": true,
156
333
  "fw-controls-wrapper": true,
334
+ "fw-controls-wrapper--visible": state.shouldShowControls,
335
+ "fw-controls-wrapper--hidden": !state.shouldShowControls,
157
336
  })}
158
337
  >
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
338
+ <div class="fw-control-bar" @click=${(event: Event) => event.stopPropagation()}>
339
+ ${context.canSeek
340
+ ? html`
341
+ <div class="fw-seek-wrapper">
342
+ <fw-seek-bar
343
+ .currentTime=${state.currentTime}
344
+ .duration=${state.duration}
345
+ .buffered=${this._buffered}
346
+ .disabled=${disabled}
347
+ .isLive=${context.isLive}
348
+ .seekableStart=${context.seekableStart}
349
+ .liveEdge=${context.liveEdge}
350
+ .commitOnRelease=${context.commitOnRelease}
351
+ @fw-seek=${(event: CustomEvent<{ time: number }>) =>
352
+ this.pc.seek(event.detail.time)}
353
+ ></fw-seek-bar>
354
+ </div>
355
+ `
356
+ : nothing}
357
+
358
+ <div class="fw-controls-row">
359
+ <div class="fw-controls-left">
360
+ <div class="fw-control-group">
361
+ <button
362
+ type="button"
363
+ class="fw-btn-flush"
364
+ ?disabled=${disabled}
365
+ aria-label=${state.isPlaying ? "Pause" : "Play"}
366
+ @click=${() => this.pc.togglePlay()}
367
+ >
368
+ ${state.isPlaying ? pauseIcon(18) : playIcon(18)}
369
+ </button>
370
+
371
+ ${context.canSeek
372
+ ? html`
373
+ <button
374
+ type="button"
375
+ class="fw-btn-flush hidden sm:flex"
376
+ ?disabled=${disabled}
377
+ aria-label="Skip back 10 seconds"
378
+ @click=${() => this.pc.seekBy(-10)}
379
+ >
380
+ ${skipBackIcon(16)}
381
+ </button>
382
+ <button
383
+ type="button"
384
+ class="fw-btn-flush hidden sm:flex"
385
+ ?disabled=${disabled}
386
+ aria-label="Skip forward 10 seconds"
387
+ @click=${() => this.pc.seekBy(10)}
388
+ >
389
+ ${skipForwardIcon(16)}
390
+ </button>
391
+ `
392
+ : nothing}
393
+ </div>
394
+
395
+ <div class="fw-control-group">
396
+ <fw-volume-control .pc=${this.pc}></fw-volume-control>
397
+ </div>
398
+
399
+ <div class="fw-control-group">
400
+ <span class="fw-time-display">${timeDisplay}</span>
401
+ </div>
402
+
403
+ ${context.isLive
188
404
  ? html`
189
- <span class="time fw-time-display">
190
- ${this._formatTime(s.currentTime)} / ${this._formatTime(s.duration)}
191
- </span>
405
+ <div class="fw-control-group">
406
+ <button
407
+ type="button"
408
+ @click=${() => this.pc.jumpToLive()}
409
+ ?disabled=${liveButtonDisabled}
410
+ class=${classMap({
411
+ "fw-live-badge": true,
412
+ "fw-live-badge--active": liveButtonDisabled,
413
+ "fw-live-badge--behind": !liveButtonDisabled,
414
+ })}
415
+ title=${!context.hasDvrWindow
416
+ ? "Live only"
417
+ : this._isNearLiveState
418
+ ? "At live edge"
419
+ : "Jump to live"}
420
+ >
421
+ LIVE
422
+ ${!this._isNearLiveState && context.hasDvrWindow
423
+ ? seekToLiveIcon(10)
424
+ : nothing}
425
+ </button>
426
+ </div>
192
427
  `
193
428
  : nothing}
429
+ </div>
194
430
 
195
- <!-- Live badge -->
196
- ${this.isContentLive
431
+ <div class="fw-controls-right">
432
+ ${this.showStatsButton
197
433
  ? 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>
434
+ <div class="fw-control-group">
435
+ <button
436
+ type="button"
437
+ class=${classMap({
438
+ "fw-btn-flush": true,
439
+ "fw-btn-flush--active": this.isStatsOpen,
440
+ })}
441
+ aria-label="Toggle stats"
442
+ title="Stats"
443
+ @click=${() =>
444
+ this.dispatchEvent(
445
+ new CustomEvent("fw-stats-toggle", {
446
+ bubbles: true,
447
+ composed: true,
448
+ })
449
+ )}
450
+ >
451
+ ${statsIcon(16)}
452
+ </button>
453
+ </div>
214
454
  `
215
455
  : nothing}
216
- </div>
217
456
 
218
- <div class="group fw-control-group">
219
- <!-- Settings -->
220
- <div class="settings-anchor">
457
+ <div class="fw-control-group fw-settings-anchor">
221
458
  <button
222
- class="btn fw-btn-flush"
223
459
  type="button"
460
+ class=${classMap({
461
+ "fw-btn-flush": true,
462
+ group: true,
463
+ "fw-btn-flush--active": this._settingsOpen,
464
+ })}
465
+ aria-label="Settings"
466
+ title="Settings"
467
+ ?disabled=${disabled}
224
468
  @click=${() => {
469
+ if (disabled) {
470
+ return;
471
+ }
225
472
  this._settingsOpen = !this._settingsOpen;
226
473
  }}
227
- aria-label="Settings"
228
474
  >
229
- ${settingsIcon(16)}
475
+ <span class="transition-transform group-hover:rotate-90"
476
+ >${settingsIcon(16)}</span
477
+ >
230
478
  </button>
231
- <fw-settings-menu .pc=${this.pc} .open=${this._settingsOpen}></fw-settings-menu>
479
+
480
+ <fw-settings-menu
481
+ .pc=${this.pc}
482
+ .open=${this._settingsOpen}
483
+ .playbackMode=${this.playbackMode}
484
+ .isContentLive=${this.isContentLive}
485
+ @fw-close=${() => {
486
+ this._settingsOpen = false;
487
+ }}
488
+ @fw-mode-change=${this._handleModeChange}
489
+ ></fw-settings-menu>
232
490
  </div>
233
491
 
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>
492
+ <div class="fw-control-group">
493
+ <button
494
+ type="button"
495
+ class="fw-btn-flush"
496
+ ?disabled=${disabled}
497
+ aria-label=${state.isFullscreen ? "Exit fullscreen" : "Fullscreen"}
498
+ @click=${() => this.pc.toggleFullscreen()}
499
+ >
500
+ ${state.isFullscreen ? fullscreenExitIcon(16) : fullscreenIcon(16)}
501
+ </button>
502
+ </div>
244
503
  </div>
245
504
  </div>
246
505
  </div>