@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
@@ -0,0 +1,331 @@
1
+ import { LitElement, css, html, nothing } from "lit";
2
+ import { customElement, property } from "lit/decorators.js";
3
+ import type { StreamStatus } from "@livepeer-frameworks/player-core";
4
+ import { sharedStyles } from "../styles/shared-styles.js";
5
+
6
+ @customElement("fw-stream-state-overlay")
7
+ export class FwStreamStateOverlay extends LitElement {
8
+ @property({ type: String }) status: StreamStatus = "OFFLINE";
9
+ @property({ type: String }) message = "";
10
+ @property({ type: Number }) percentage?: number;
11
+ @property({ type: Boolean }) visible = true;
12
+ @property({ type: Boolean, attribute: "retry-enabled" }) retryEnabled = false;
13
+ @property({ attribute: false }) onRetry?: () => void;
14
+
15
+ static styles = [
16
+ sharedStyles,
17
+ css`
18
+ :host {
19
+ display: contents;
20
+ }
21
+
22
+ .overlay-backdrop {
23
+ position: absolute;
24
+ inset: 0;
25
+ z-index: 20;
26
+ display: flex;
27
+ align-items: center;
28
+ justify-content: center;
29
+ background-color: hsl(var(--tn-bg-dark, 235 21% 11%) / 0.8);
30
+ backdrop-filter: blur(4px);
31
+ }
32
+
33
+ .slab {
34
+ width: 280px;
35
+ max-width: 90%;
36
+ background-color: hsl(var(--tn-bg, 233 23% 17%) / 0.95);
37
+ border: 1px solid hsl(var(--tn-fg-gutter, 233 23% 25%) / 0.3);
38
+ }
39
+
40
+ .slab-header {
41
+ display: flex;
42
+ align-items: center;
43
+ gap: 0.5rem;
44
+ padding: 0.75rem 1rem;
45
+ border-bottom: 1px solid hsl(var(--tn-fg-gutter, 233 23% 25%) / 0.3);
46
+ font-size: 0.75rem;
47
+ font-weight: 600;
48
+ text-transform: uppercase;
49
+ letter-spacing: 0.05em;
50
+ color: hsl(var(--tn-fg-dark, 233 23% 60%));
51
+ }
52
+
53
+ .slab-body {
54
+ padding: 1rem;
55
+ }
56
+
57
+ .slab-message {
58
+ font-size: 0.875rem;
59
+ color: hsl(var(--tn-fg, 233 23% 75%));
60
+ }
61
+
62
+ .progress-wrap {
63
+ margin-top: 0.75rem;
64
+ }
65
+
66
+ .progress-bar {
67
+ height: 0.375rem;
68
+ width: 100%;
69
+ overflow: hidden;
70
+ background-color: hsl(var(--tn-bg-visual, 233 23% 20%));
71
+ }
72
+
73
+ .progress-fill {
74
+ height: 100%;
75
+ transition: width 0.3s ease;
76
+ background-color: hsl(var(--tn-yellow, 40 70% 64%));
77
+ }
78
+
79
+ .progress-text {
80
+ margin-top: 0.375rem;
81
+ font-size: 0.75rem;
82
+ font-family: monospace;
83
+ color: hsl(var(--tn-fg-dark, 233 23% 60%));
84
+ }
85
+
86
+ .hint {
87
+ margin-top: 0.5rem;
88
+ font-size: 0.75rem;
89
+ color: hsl(var(--tn-fg-dark, 233 23% 60%));
90
+ }
91
+
92
+ .polling-indicator {
93
+ display: flex;
94
+ align-items: center;
95
+ gap: 0.5rem;
96
+ margin-top: 0.75rem;
97
+ font-size: 0.75rem;
98
+ color: hsl(var(--tn-fg-dark, 233 23% 60%));
99
+ }
100
+
101
+ .polling-dot {
102
+ width: 0.375rem;
103
+ height: 0.375rem;
104
+ background-color: hsl(var(--tn-cyan, 192 78% 73%));
105
+ animation: pulse 2s cubic-bezier(0.4, 0, 0.6, 1) infinite;
106
+ }
107
+
108
+ .slab-actions {
109
+ border-top: 1px solid hsl(var(--tn-fg-gutter, 233 23% 25%) / 0.3);
110
+ }
111
+
112
+ .btn-flush {
113
+ width: 100%;
114
+ padding: 0.625rem 1rem;
115
+ background: none;
116
+ border: none;
117
+ cursor: pointer;
118
+ font-size: 0.75rem;
119
+ font-weight: 500;
120
+ text-transform: uppercase;
121
+ letter-spacing: 0.05em;
122
+ color: hsl(var(--tn-blue, 217 89% 71%));
123
+ transition: background-color 0.15s;
124
+ }
125
+
126
+ .btn-flush:hover {
127
+ background-color: hsl(var(--tn-bg-visual, 233 23% 20%) / 0.5);
128
+ }
129
+
130
+ .icon {
131
+ width: 1.25rem;
132
+ height: 1.25rem;
133
+ }
134
+
135
+ .icon-online {
136
+ color: hsl(var(--tn-green, 115 54% 57%));
137
+ }
138
+
139
+ .icon-offline {
140
+ color: hsl(var(--tn-red, 355 68% 65%));
141
+ }
142
+
143
+ .icon-warning {
144
+ color: hsl(var(--tn-yellow, 40 70% 64%));
145
+ }
146
+
147
+ .animate-spin {
148
+ animation: spin 1s linear infinite;
149
+ }
150
+
151
+ @keyframes spin {
152
+ to {
153
+ transform: rotate(360deg);
154
+ }
155
+ }
156
+
157
+ @keyframes pulse {
158
+ 0%,
159
+ 100% {
160
+ opacity: 1;
161
+ }
162
+ 50% {
163
+ opacity: 0.5;
164
+ }
165
+ }
166
+ `,
167
+ ];
168
+
169
+ private _getStatusLabel(status: StreamStatus): string {
170
+ switch (status) {
171
+ case "ONLINE":
172
+ return "ONLINE";
173
+ case "OFFLINE":
174
+ return "OFFLINE";
175
+ case "INITIALIZING":
176
+ return "INITIALIZING";
177
+ case "BOOTING":
178
+ return "STARTING";
179
+ case "WAITING_FOR_DATA":
180
+ return "WAITING";
181
+ case "SHUTTING_DOWN":
182
+ return "ENDING";
183
+ case "ERROR":
184
+ return "ERROR";
185
+ case "INVALID":
186
+ return "INVALID";
187
+ default:
188
+ return "STATUS";
189
+ }
190
+ }
191
+
192
+ private _renderStatusIcon(status: StreamStatus) {
193
+ if (status === "OFFLINE") {
194
+ return html`<svg
195
+ class="icon icon-offline"
196
+ fill="none"
197
+ viewBox="0 0 24 24"
198
+ stroke="currentColor"
199
+ >
200
+ <path
201
+ stroke-linecap="round"
202
+ stroke-linejoin="round"
203
+ stroke-width="2"
204
+ d="M18.364 5.636a9 9 0 010 12.728m0 0l-2.829-2.829m2.829 2.829L21 21M15.536 8.464a5 5 0 010 7.072m0 0l-2.829-2.829m-4.243 2.829a4.978 4.978 0 01-1.414-2.83m-1.414 5.658a9 9 0 01-2.167-9.238m7.824 2.167a1 1 0 111.414 1.414m-1.414-1.414L3 3m8.293 8.293l1.414 1.414"
205
+ ></path>
206
+ </svg>`;
207
+ }
208
+
209
+ if (status === "INITIALIZING" || status === "BOOTING" || status === "WAITING_FOR_DATA") {
210
+ return html`<svg class="icon icon-warning animate-spin" fill="none" viewBox="0 0 24 24">
211
+ <circle
212
+ class="opacity-25"
213
+ cx="12"
214
+ cy="12"
215
+ r="10"
216
+ stroke="currentColor"
217
+ stroke-width="4"
218
+ ></circle>
219
+ <path
220
+ class="opacity-75"
221
+ fill="currentColor"
222
+ d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"
223
+ ></path>
224
+ </svg>`;
225
+ }
226
+
227
+ if (status === "SHUTTING_DOWN") {
228
+ return html`<svg
229
+ class="icon icon-warning"
230
+ fill="none"
231
+ viewBox="0 0 24 24"
232
+ stroke="currentColor"
233
+ >
234
+ <path
235
+ stroke-linecap="round"
236
+ stroke-linejoin="round"
237
+ stroke-width="2"
238
+ d="M13 10V3L4 14h7v7l9-11h-7z"
239
+ ></path>
240
+ </svg>`;
241
+ }
242
+
243
+ return html`<svg
244
+ class="icon icon-offline"
245
+ fill="none"
246
+ viewBox="0 0 24 24"
247
+ stroke="currentColor"
248
+ >
249
+ <path
250
+ stroke-linecap="round"
251
+ stroke-linejoin="round"
252
+ stroke-width="2"
253
+ d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"
254
+ ></path>
255
+ </svg>`;
256
+ }
257
+
258
+ private _handleRetry = () => {
259
+ if (this.onRetry) {
260
+ this.onRetry();
261
+ return;
262
+ }
263
+ this.dispatchEvent(new CustomEvent("fw-retry", { bubbles: true, composed: true }));
264
+ };
265
+
266
+ protected render() {
267
+ if (!this.visible || this.status === "ONLINE") {
268
+ return nothing;
269
+ }
270
+
271
+ const showRetry =
272
+ (this.status === "ERROR" || this.status === "INVALID" || this.status === "OFFLINE") &&
273
+ (this.retryEnabled || typeof this.onRetry === "function");
274
+ const showProgress = this.status === "INITIALIZING" && this.percentage !== undefined;
275
+ const progressWidth = `${Math.min(100, Math.max(0, this.percentage ?? 0))}%`;
276
+
277
+ return html`
278
+ <div class="overlay-backdrop" role="status" aria-live="polite">
279
+ <div class="slab">
280
+ <div class="slab-header">
281
+ ${this._renderStatusIcon(this.status)}
282
+ <span>${this._getStatusLabel(this.status)}</span>
283
+ </div>
284
+ <div class="slab-body">
285
+ <p class="slab-message">${this.message}</p>
286
+ ${showProgress
287
+ ? html`
288
+ <div class="progress-wrap">
289
+ <div class="progress-bar">
290
+ <div class="progress-fill" style="width:${progressWidth};"></div>
291
+ </div>
292
+ <p class="progress-text">${Math.round(this.percentage ?? 0)}%</p>
293
+ </div>
294
+ `
295
+ : nothing}
296
+ ${this.status === "OFFLINE"
297
+ ? html`<p class="hint">The stream will start when the broadcaster goes live</p>`
298
+ : nothing}
299
+ ${this.status === "BOOTING" || this.status === "WAITING_FOR_DATA"
300
+ ? html`<p class="hint">Please wait while the stream prepares...</p>`
301
+ : nothing}
302
+ ${!showRetry
303
+ ? html`<div class="polling-indicator">
304
+ <span class="polling-dot"></span>
305
+ <span>Checking stream status...</span>
306
+ </div>`
307
+ : nothing}
308
+ </div>
309
+ ${showRetry
310
+ ? html`<div class="slab-actions">
311
+ <button
312
+ type="button"
313
+ class="btn-flush"
314
+ @click=${this._handleRetry}
315
+ aria-label="Retry connection"
316
+ >
317
+ Retry Connection
318
+ </button>
319
+ </div>`
320
+ : nothing}
321
+ </div>
322
+ </div>
323
+ `;
324
+ }
325
+ }
326
+
327
+ declare global {
328
+ interface HTMLElementTagNameMap {
329
+ "fw-stream-state-overlay": FwStreamStateOverlay;
330
+ }
331
+ }
@@ -1,60 +1,248 @@
1
- import { LitElement, html, css, nothing } from "lit";
1
+ import { LitElement, css, html, nothing } from "lit";
2
2
  import { customElement, property, state } from "lit/decorators.js";
3
+ import { styleMap } from "lit/directives/style-map.js";
3
4
 
4
5
  interface SubtitleCue {
5
6
  id?: string;
7
+ text: string;
6
8
  startTime: number;
7
9
  endTime: number;
8
- text: string;
10
+ lang?: string;
11
+ }
12
+
13
+ interface MetaTrackEvent {
14
+ type: string;
15
+ data: unknown;
9
16
  }
10
17
 
18
+ interface SubtitleStyle {
19
+ fontSize?: string;
20
+ fontFamily?: string;
21
+ color?: string;
22
+ backgroundColor?: string;
23
+ textShadow?: string;
24
+ bottom?: string;
25
+ maxWidth?: string;
26
+ padding?: string;
27
+ borderRadius?: string;
28
+ }
29
+
30
+ const DEFAULT_STYLE: Required<SubtitleStyle> = {
31
+ fontSize: "1.5rem",
32
+ fontFamily: "system-ui, -apple-system, sans-serif",
33
+ color: "white",
34
+ backgroundColor: "rgba(0, 0, 0, 0.75)",
35
+ textShadow: "2px 2px 4px rgba(0, 0, 0, 0.5)",
36
+ bottom: "5%",
37
+ maxWidth: "90%",
38
+ padding: "0.5em 1em",
39
+ borderRadius: "4px",
40
+ };
41
+
11
42
  @customElement("fw-subtitle-renderer")
12
43
  export class FwSubtitleRenderer extends LitElement {
13
44
  @property({ type: Number }) currentTime = 0;
14
- @property({ type: Boolean }) enabled = false;
45
+ @property({ type: Boolean }) enabled = true;
15
46
  @property({ attribute: false }) cues: SubtitleCue[] = [];
47
+ @property({ attribute: false })
48
+ subscribeToMetaTrack?: (trackId: string, callback: (event: MetaTrackEvent) => void) => () => void;
49
+ @property({ type: String, attribute: "meta-track-id" }) metaTrackId?: string;
50
+ @property({ attribute: false }) subtitleStyle?: SubtitleStyle;
51
+ @property({ type: String, attribute: "class-name" }) className = "";
52
+
53
+ @state() private _liveCues: SubtitleCue[] = [];
54
+ @state() private _displayedText = "";
55
+
56
+ private _unsubscribe: (() => void) | null = null;
16
57
 
17
58
  static styles = css`
18
59
  :host {
19
60
  display: contents;
20
61
  }
21
- .subtitle {
62
+
63
+ .subtitle-container {
22
64
  position: absolute;
23
- bottom: 5%;
24
65
  left: 50%;
25
66
  transform: translateX(-50%);
26
- display: inline-block;
27
- max-width: 90%;
28
- padding: 0.5em 1em;
29
- border-radius: 4px;
30
- background: rgb(0 0 0 / 0.75);
31
- color: white;
32
- font-size: 1.5rem;
33
- font-family:
34
- system-ui,
35
- -apple-system,
36
- sans-serif;
37
- text-shadow: 2px 2px 4px rgb(0 0 0 / 0.5);
38
- white-space: pre-wrap;
67
+ z-index: 30;
39
68
  text-align: center;
40
- z-index: 15;
41
69
  pointer-events: none;
42
70
  }
71
+
72
+ .subtitle-text {
73
+ display: inline-block;
74
+ white-space: pre-wrap;
75
+ }
43
76
  `;
44
77
 
45
- private get _activeCue(): string | null {
46
- if (!this.enabled || !this.cues.length) return null;
47
- const t = this.currentTime * 1000; // Convert to ms
48
- for (const cue of this.cues) {
49
- if (t >= cue.startTime && t <= cue.endTime) return cue.text;
78
+ disconnectedCallback(): void {
79
+ super.disconnectedCallback();
80
+ this._teardownSubscription();
81
+ }
82
+
83
+ protected updated(changed: Map<string, unknown>): void {
84
+ if (
85
+ changed.has("enabled") ||
86
+ changed.has("subscribeToMetaTrack") ||
87
+ changed.has("metaTrackId")
88
+ ) {
89
+ this._syncSubscription();
90
+ }
91
+
92
+ if (changed.has("currentTime") || changed.has("_liveCues")) {
93
+ this._pruneExpiredLiveCues();
94
+ }
95
+
96
+ if (
97
+ changed.has("enabled") ||
98
+ changed.has("currentTime") ||
99
+ changed.has("cues") ||
100
+ changed.has("_liveCues")
101
+ ) {
102
+ this._syncDisplayedCue();
103
+ }
104
+ }
105
+
106
+ private _syncSubscription(): void {
107
+ this._teardownSubscription();
108
+
109
+ if (!this.enabled || !this.subscribeToMetaTrack || !this.metaTrackId) {
110
+ return;
111
+ }
112
+
113
+ this._unsubscribe = this.subscribeToMetaTrack(this.metaTrackId, this._handleMetaEvent);
114
+ }
115
+
116
+ private _teardownSubscription(): void {
117
+ if (this._unsubscribe) {
118
+ this._unsubscribe();
119
+ this._unsubscribe = null;
120
+ }
121
+ }
122
+
123
+ private _handleMetaEvent = (event: MetaTrackEvent): void => {
124
+ if (event.type !== "subtitle") {
125
+ return;
126
+ }
127
+
128
+ const cue = this._parseSubtitleCue(event.data);
129
+ if (!cue) {
130
+ return;
131
+ }
132
+
133
+ this._liveCues = (() => {
134
+ const existing = this._liveCues.find((value) => value.id === cue.id);
135
+ if (existing) {
136
+ return this._liveCues;
137
+ }
138
+ return [...this._liveCues, cue].slice(-50);
139
+ })();
140
+ };
141
+
142
+ private _parseSubtitleCue(data: unknown): SubtitleCue | null {
143
+ if (typeof data !== "object" || data === null) {
144
+ return null;
145
+ }
146
+
147
+ const obj = data as Record<string, unknown>;
148
+ const text = typeof obj.text === "string" ? obj.text : String(obj.text ?? "");
149
+ if (!text) {
150
+ return null;
151
+ }
152
+
153
+ const rawStart =
154
+ "startTime" in obj ? Number(obj.startTime) : "start" in obj ? Number(obj.start) : 0;
155
+ const rawEnd =
156
+ "endTime" in obj ? Number(obj.endTime) : "end" in obj ? Number(obj.end) : Infinity;
157
+ const startTime = Number.isFinite(rawStart) ? rawStart : 0;
158
+ const endTime = Number.isFinite(rawEnd) ? rawEnd : Infinity;
159
+ const id = typeof obj.id === "string" ? obj.id : String(Date.now() + Math.random());
160
+
161
+ return {
162
+ id,
163
+ text,
164
+ startTime,
165
+ endTime,
166
+ lang: typeof obj.lang === "string" ? obj.lang : undefined,
167
+ };
168
+ }
169
+
170
+ private _getAllCues(): SubtitleCue[] {
171
+ return [...(this.cues ?? []), ...this._liveCues];
172
+ }
173
+
174
+ private _syncDisplayedCue(): void {
175
+ if (!this.enabled) {
176
+ if (this._displayedText) {
177
+ this._displayedText = "";
178
+ }
179
+ return;
180
+ }
181
+
182
+ const currentTimeMs = this.currentTime * 1000;
183
+ const activeCue = this._getAllCues().find(
184
+ (cue) => currentTimeMs >= cue.startTime && currentTimeMs < cue.endTime
185
+ );
186
+ const nextText = activeCue?.text ?? "";
187
+
188
+ if (nextText !== this._displayedText) {
189
+ this._displayedText = nextText;
190
+ }
191
+ }
192
+
193
+ private _pruneExpiredLiveCues(): void {
194
+ if (this._liveCues.length === 0) {
195
+ return;
196
+ }
197
+
198
+ const currentTimeMs = this.currentTime * 1000;
199
+ const filtered = this._liveCues.filter((cue) => {
200
+ const endTime = cue.endTime === Infinity ? cue.startTime + 10000 : cue.endTime;
201
+ return endTime >= currentTimeMs - 30000;
202
+ });
203
+
204
+ if (filtered.length !== this._liveCues.length) {
205
+ this._liveCues = filtered;
50
206
  }
51
- return null;
52
207
  }
53
208
 
54
209
  protected render() {
55
- const text = this._activeCue;
56
- if (!text) return nothing;
57
- return html`<span class="subtitle" aria-live="polite">${text}</span>`;
210
+ if (!this.enabled || !this._displayedText) {
211
+ return nothing;
212
+ }
213
+
214
+ const mergedStyle: Required<SubtitleStyle> = {
215
+ ...DEFAULT_STYLE,
216
+ ...(this.subtitleStyle ?? {}),
217
+ };
218
+
219
+ return html`
220
+ <div
221
+ class="subtitle-container ${this.className}"
222
+ style=${styleMap({
223
+ bottom: mergedStyle.bottom,
224
+ maxWidth: mergedStyle.maxWidth,
225
+ })}
226
+ role="region"
227
+ aria-live="polite"
228
+ aria-label="Subtitles"
229
+ >
230
+ <span
231
+ class="subtitle-text"
232
+ style=${styleMap({
233
+ fontSize: mergedStyle.fontSize,
234
+ fontFamily: mergedStyle.fontFamily,
235
+ color: mergedStyle.color,
236
+ backgroundColor: mergedStyle.backgroundColor,
237
+ textShadow: mergedStyle.textShadow,
238
+ padding: mergedStyle.padding,
239
+ borderRadius: mergedStyle.borderRadius,
240
+ })}
241
+ >
242
+ ${this._displayedText}
243
+ </span>
244
+ </div>
245
+ `;
58
246
  }
59
247
  }
60
248