@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,15 +1,33 @@
1
1
  /**
2
- * <fw-dev-mode-panel> — Developer mode panel for forcing player/source selection.
3
- * Port of DevModePanel.tsx from player-react.
2
+ * <fw-dev-mode-panel> — Developer mode side panel.
3
+ * Feature parity with React/Svelte advanced panel.
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";
9
9
  import { utilityStyles } from "../styles/utility-styles.js";
10
10
  import { closeIcon } from "../icons/index.js";
11
+ import {
12
+ QualityMonitor,
13
+ globalPlayerManager,
14
+ type MistStreamInfo,
15
+ type PlaybackMode,
16
+ type PlayerCombination,
17
+ type StreamInfo,
18
+ } from "@livepeer-frameworks/player-core";
11
19
  import type { PlayerControllerHost } from "../controllers/player-controller-host.js";
12
- import type { PlaybackMode } from "@livepeer-frameworks/player-core";
20
+
21
+ const SOURCE_TYPE_LABELS: Record<string, string> = {
22
+ "html5/application/vnd.apple.mpegurl": "HLS",
23
+ "dash/video/mp4": "DASH",
24
+ "html5/video/mp4": "MP4",
25
+ "html5/video/webm": "WebM",
26
+ whep: "WHEP",
27
+ "mist/html": "Mist",
28
+ "mist/legacy": "Auto",
29
+ "ws/video/mp4": "MEWS",
30
+ };
13
31
 
14
32
  @customElement("fw-dev-mode-panel")
15
33
  export class FwDevModePanel extends LitElement {
@@ -17,6 +35,32 @@ export class FwDevModePanel extends LitElement {
17
35
  @property({ type: String }) playbackMode: PlaybackMode = "auto";
18
36
 
19
37
  @state() private _activeTab: "config" | "stats" = "config";
38
+ @state() private _hoveredComboIndex: number | null = null;
39
+ @state() private _tooltipAbove = false;
40
+ @state() private _showDisabledPlayers = false;
41
+
42
+ @state() private _playbackScore = 1;
43
+ @state() private _qualityScore = 100;
44
+ @state() private _stallCount = 0;
45
+ @state() private _frameDropRate = 0;
46
+
47
+ @state()
48
+ private _videoStats: {
49
+ resolution: string;
50
+ buffered: string;
51
+ playbackRate: string;
52
+ currentTime: string;
53
+ duration: string;
54
+ readyState: number;
55
+ networkState: number;
56
+ } | null = null;
57
+
58
+ @state() private _playerStats: unknown = null;
59
+
60
+ private _qualityMonitor: QualityMonitor | null = null;
61
+ private _qualityMonitorVideo: HTMLVideoElement | null = null;
62
+ private _videoStatsInterval: ReturnType<typeof setInterval> | null = null;
63
+ private _playerStatsInterval: ReturnType<typeof setInterval> | null = null;
20
64
 
21
65
  static styles = [
22
66
  sharedStyles,
@@ -24,258 +68,915 @@ export class FwDevModePanel extends LitElement {
24
68
  css`
25
69
  :host {
26
70
  display: block;
27
- }
28
- .panel {
29
- width: 320px;
30
71
  height: 100%;
31
- border-left: 1px solid rgb(255 255 255 / 0.1);
32
- background: rgb(15 23 42);
33
- overflow: auto;
34
- font-size: 0.75rem;
35
- color: rgb(255 255 255 / 0.7);
36
- }
37
- .header {
38
- display: flex;
39
- align-items: center;
40
- justify-content: space-between;
41
- padding: 0.5rem 0.75rem;
42
- border-bottom: 1px solid rgb(255 255 255 / 0.1);
43
- }
44
- .tabs {
45
- display: flex;
46
- gap: 0.5rem;
47
- }
48
- .tab {
49
- padding: 0.25rem 0.5rem;
50
- border: none;
51
- background: none;
52
- color: rgb(255 255 255 / 0.5);
53
- font-size: 0.6875rem;
54
- font-weight: 600;
55
- cursor: pointer;
56
- border-radius: 0.25rem;
57
- }
58
- .tab--active {
59
- color: white;
60
- background: rgb(255 255 255 / 0.1);
61
- }
62
- .close {
63
- display: flex;
64
- background: none;
65
- border: none;
66
- color: rgb(255 255 255 / 0.5);
67
- cursor: pointer;
68
- padding: 0;
69
- }
70
- .close:hover {
71
- color: white;
72
- }
73
- .body {
74
- padding: 0.75rem;
75
- }
76
- .section {
77
- margin-bottom: 0.75rem;
78
- }
79
- .label {
80
- font-size: 0.625rem;
81
- font-weight: 600;
82
- text-transform: uppercase;
83
- letter-spacing: 0.05em;
84
- color: rgb(255 255 255 / 0.4);
85
- margin-bottom: 0.375rem;
86
- }
87
- .value {
88
- color: rgb(255 255 255 / 0.9);
89
- font-family: ui-monospace, monospace;
90
- }
91
- .mode-group {
92
- display: flex;
93
- gap: 0.25rem;
94
- flex-wrap: wrap;
95
- }
96
- .mode-btn {
97
- padding: 0.25rem 0.5rem;
98
- border: 1px solid rgb(255 255 255 / 0.15);
99
- background: none;
100
- color: rgb(255 255 255 / 0.6);
101
- font-size: 0.6875rem;
102
- cursor: pointer;
103
- border-radius: 0.25rem;
104
- transition: all 150ms;
105
- }
106
- .mode-btn:hover {
107
- border-color: rgb(255 255 255 / 0.3);
108
- color: white;
109
- }
110
- .mode-btn--active {
111
- border-color: hsl(var(--tn-blue, 217 89% 61%));
112
- color: hsl(var(--tn-blue, 217 89% 61%));
113
- background: hsl(var(--tn-blue, 217 89% 61%) / 0.1);
114
- }
115
- .actions {
116
- display: flex;
117
- gap: 0.5rem;
118
- margin-top: 0.5rem;
119
- }
120
- .action-btn {
121
- padding: 0.375rem 0.75rem;
122
- border: 1px solid rgb(255 255 255 / 0.15);
123
- background: none;
124
- color: rgb(255 255 255 / 0.7);
125
- font-size: 0.6875rem;
126
- cursor: pointer;
127
- border-radius: 0.25rem;
128
- }
129
- .action-btn:hover {
130
- border-color: rgb(255 255 255 / 0.3);
131
- color: white;
132
- }
133
- .stat-row {
134
- display: flex;
135
- justify-content: space-between;
136
- padding: 0.125rem 0;
137
- }
138
- .stat-label {
139
- color: rgb(255 255 255 / 0.4);
140
- }
141
- .stat-value {
142
- color: rgb(255 255 255 / 0.8);
143
- font-family: ui-monospace, monospace;
144
- font-variant-numeric: tabular-nums;
145
72
  }
146
73
  `,
147
74
  ];
148
75
 
149
- private _modes: PlaybackMode[] = ["auto", "low-latency", "quality"];
76
+ disconnectedCallback(): void {
77
+ super.disconnectedCallback();
78
+ this._stopQualityMonitor();
79
+ this._stopStatsPolling();
80
+ }
150
81
 
151
- protected render() {
152
- const s = this.pc.s;
82
+ protected updated(_changed: PropertyValues<this>): void {
83
+ this._syncQualityMonitor();
84
+ this._syncStatsPolling();
85
+ }
86
+
87
+ private _getMistStreamInfo(): MistStreamInfo | undefined {
88
+ return this.pc.s.streamState?.streamInfo as MistStreamInfo | undefined;
89
+ }
90
+
91
+ private _getAllCombinations(): PlayerCombination[] {
92
+ const streamInfo = this.pc.s.streamInfo as StreamInfo | null;
93
+ if (!streamInfo) {
94
+ return [];
95
+ }
96
+
97
+ try {
98
+ return globalPlayerManager.getAllCombinations(streamInfo, this.playbackMode);
99
+ } catch {
100
+ return [];
101
+ }
102
+ }
103
+
104
+ private _getCompatibleCombinations(): PlayerCombination[] {
105
+ return this._getAllCombinations().filter((combo) => combo.compatible);
106
+ }
107
+
108
+ private _getActiveComboIndex(combinations: PlayerCombination[]): number {
109
+ const currentPlayer = this.pc.s.currentPlayerInfo;
110
+ const currentSource = this.pc.s.currentSourceInfo;
111
+
112
+ if (!currentPlayer || !currentSource || combinations.length === 0) {
113
+ return -1;
114
+ }
115
+
116
+ return combinations.findIndex(
117
+ (combo) => combo.player === currentPlayer.shortname && combo.sourceType === currentSource.type
118
+ );
119
+ }
120
+
121
+ private _syncQualityMonitor(): void {
122
+ const video = this.pc.s.videoElement;
123
+
124
+ if (!video) {
125
+ this._stopQualityMonitor();
126
+ return;
127
+ }
128
+
129
+ if (!this._qualityMonitor) {
130
+ this._qualityMonitor = new QualityMonitor({
131
+ sampleInterval: 500,
132
+ onSample: (quality) => {
133
+ this._qualityScore = quality.score;
134
+ this._stallCount = quality.stallCount;
135
+ this._frameDropRate = quality.frameDropRate;
136
+ this._playbackScore = this._qualityMonitor?.getPlaybackScore() ?? 1;
137
+ this.requestUpdate();
138
+ },
139
+ });
140
+ }
141
+
142
+ if (this._qualityMonitorVideo !== video) {
143
+ this._qualityMonitor.stop();
144
+ this._qualityMonitor.start(video);
145
+ this._qualityMonitorVideo = video;
146
+ }
147
+ }
148
+
149
+ private _stopQualityMonitor(): void {
150
+ this._qualityMonitor?.stop();
151
+ this._qualityMonitorVideo = null;
152
+ }
153
+
154
+ private _syncStatsPolling(): void {
155
+ if (this._activeTab !== "stats") {
156
+ this._stopStatsPolling();
157
+ return;
158
+ }
159
+
160
+ if (!this._videoStatsInterval) {
161
+ this._updateVideoStats();
162
+ this._videoStatsInterval = setInterval(() => {
163
+ this._updateVideoStats();
164
+ }, 500);
165
+ }
166
+
167
+ if (!this._playerStatsInterval) {
168
+ void this._pollPlayerStats();
169
+ this._playerStatsInterval = setInterval(() => {
170
+ void this._pollPlayerStats();
171
+ }, 500);
172
+ }
173
+ }
174
+
175
+ private _stopStatsPolling(): void {
176
+ if (this._videoStatsInterval) {
177
+ clearInterval(this._videoStatsInterval);
178
+ this._videoStatsInterval = null;
179
+ }
180
+
181
+ if (this._playerStatsInterval) {
182
+ clearInterval(this._playerStatsInterval);
183
+ this._playerStatsInterval = null;
184
+ }
185
+ }
186
+
187
+ private _updateVideoStats(): void {
188
+ const video = this.pc.s.videoElement;
189
+ if (!video) {
190
+ this._videoStats = null;
191
+ return;
192
+ }
193
+
194
+ this._videoStats = {
195
+ resolution: `${video.videoWidth}x${video.videoHeight}`,
196
+ buffered:
197
+ video.buffered.length > 0
198
+ ? (video.buffered.end(video.buffered.length - 1) - video.currentTime).toFixed(1)
199
+ : "0",
200
+ playbackRate: video.playbackRate.toFixed(2),
201
+ currentTime: video.currentTime.toFixed(1),
202
+ duration: Number.isFinite(video.duration) ? video.duration.toFixed(1) : "live",
203
+ readyState: video.readyState,
204
+ networkState: video.networkState,
205
+ };
206
+ }
207
+
208
+ private async _pollPlayerStats(): Promise<void> {
209
+ try {
210
+ const stats = await this.pc.getStats();
211
+ if (stats) {
212
+ this._playerStats = stats;
213
+ }
214
+ } catch {
215
+ // No-op for optional stats backends.
216
+ }
217
+ }
218
+
219
+ private _handleComboMouseEnter(index: number, event: MouseEvent): void {
220
+ this._hoveredComboIndex = index;
221
+
222
+ const container = this.renderRoot.querySelector(".fw-dev-body") as HTMLElement | null;
223
+ if (!container) {
224
+ return;
225
+ }
226
+
227
+ const row = event.currentTarget as HTMLElement;
228
+ const containerRect = container.getBoundingClientRect();
229
+ const rowRect = row.getBoundingClientRect();
230
+ const relativePosition = (rowRect.top - containerRect.top) / containerRect.height;
231
+
232
+ this._tooltipAbove = relativePosition > 0.6;
233
+ }
234
+
235
+ private _handleModeChange(mode: "auto" | "low-latency" | "quality"): void {
236
+ this.playbackMode = mode;
237
+ void this.pc.setDevModeOptions({ playbackMode: mode });
238
+ this.dispatchEvent(
239
+ new CustomEvent("fw-playback-mode-change", {
240
+ detail: { mode },
241
+ bubbles: true,
242
+ composed: true,
243
+ })
244
+ );
245
+ }
246
+
247
+ private _handleReload(): void {
248
+ this.pc.clearError();
249
+ void this.pc.reload();
250
+ }
251
+
252
+ private _handleNextCombo(): void {
253
+ const compatible = this._getCompatibleCombinations();
254
+ if (compatible.length === 0) {
255
+ return;
256
+ }
257
+
258
+ const activeCompatibleIndex = this._getActiveComboIndex(compatible);
259
+ const startIndex = activeCompatibleIndex >= 0 ? activeCompatibleIndex : -1;
260
+ const nextIndex = (startIndex + 1) % compatible.length;
261
+ const next = compatible[nextIndex];
262
+
263
+ void this.pc.setDevModeOptions({
264
+ forcePlayer: next.player,
265
+ forceType: next.sourceType,
266
+ forceSource: next.sourceIndex,
267
+ });
268
+ }
269
+
270
+ private _handleSelectCombo(index: number): void {
271
+ const allCombinations = this._getAllCombinations();
272
+ const combo = allCombinations[index];
273
+ if (!combo) {
274
+ return;
275
+ }
276
+
277
+ void this.pc.setDevModeOptions({
278
+ forcePlayer: combo.player,
279
+ forceType: combo.sourceType,
280
+ forceSource: combo.sourceIndex,
281
+ });
282
+ }
283
+
284
+ private _renderStatsTab(): unknown {
285
+ const primaryEndpoint = (this.pc.s.endpoints?.primary ?? null) as {
286
+ protocol?: string;
287
+ nodeId?: string;
288
+ } | null;
289
+
290
+ const stats = this._videoStats;
291
+ const playerStats = this._playerStats as any;
292
+ const mistStreamInfo = this._getMistStreamInfo();
293
+ const trackEntries = Object.entries(mistStreamInfo?.meta?.tracks ?? {});
153
294
 
154
295
  return html`
155
- <div class="panel fw-dev-panel">
156
- <div class="header fw-dev-header">
157
- <div class="tabs">
158
- <button
159
- class=${classMap({ tab: true, "tab--active": this._activeTab === "config" })}
160
- @click=${() => {
161
- this._activeTab = "config";
162
- }}
296
+ <div class="fw-dev-body">
297
+ <div class="fw-dev-section">
298
+ <div class="fw-dev-label">Playback Rate</div>
299
+ <div class="fw-dev-rate">
300
+ <div
301
+ class=${classMap({
302
+ "fw-dev-rate-value": true,
303
+ "fw-dev-stat-value--good":
304
+ this._playbackScore >= 0.95 && this._playbackScore <= 1.05,
305
+ "fw-dev-stat-value--accent": this._playbackScore > 1.05,
306
+ "fw-dev-stat-value--warn":
307
+ this._playbackScore >= 0.75 && this._playbackScore < 0.95,
308
+ "fw-dev-stat-value--bad": this._playbackScore < 0.75,
309
+ })}
310
+ >
311
+ ${this._playbackScore.toFixed(2)}x
312
+ </div>
313
+ <div class="fw-dev-rate-status">
314
+ ${this._playbackScore >= 0.95 && this._playbackScore <= 1.05
315
+ ? "realtime"
316
+ : this._playbackScore > 1.05
317
+ ? "catching up"
318
+ : this._playbackScore >= 0.75
319
+ ? "slightly slow"
320
+ : "stalling"}
321
+ </div>
322
+ </div>
323
+ <div class="fw-dev-rate-stats">
324
+ <span
325
+ class=${classMap({
326
+ "fw-dev-stat-value--good": this._qualityScore >= 75,
327
+ "fw-dev-stat-value--bad": this._qualityScore < 75,
328
+ })}
163
329
  >
164
- Config
165
- </button>
166
- <button
167
- class=${classMap({ tab: true, "tab--active": this._activeTab === "stats" })}
168
- @click=${() => {
169
- this._activeTab = "stats";
170
- }}
330
+ Quality: ${this._qualityScore}/100
331
+ </span>
332
+ <span
333
+ class=${classMap({
334
+ "fw-dev-stat-value--good": this._stallCount === 0,
335
+ "fw-dev-stat-value--warn": this._stallCount > 0,
336
+ })}
171
337
  >
172
- Stats
173
- </button>
338
+ Stalls: ${this._stallCount}
339
+ </span>
340
+ <span
341
+ class=${classMap({
342
+ "fw-dev-stat-value--good": this._frameDropRate < 1,
343
+ "fw-dev-stat-value--bad": this._frameDropRate >= 1,
344
+ })}
345
+ >
346
+ Drops: ${this._frameDropRate.toFixed(1)}%
347
+ </span>
174
348
  </div>
175
- <button
176
- class="close"
177
- @click=${() =>
178
- this.dispatchEvent(new CustomEvent("fw-close", { bubbles: true, composed: true }))}
179
- aria-label="Close panel"
180
- >
181
- ${closeIcon()}
182
- </button>
183
349
  </div>
184
350
 
185
- <div class="body fw-dev-body">
186
- ${this._activeTab === "config" ? this._renderConfig(s) : this._renderStats(s)}
187
- </div>
351
+ ${stats
352
+ ? html`
353
+ <div class="fw-dev-stat">
354
+ <span class="fw-dev-stat-label">Resolution</span>
355
+ <span class="fw-dev-stat-value">${stats.resolution}</span>
356
+ </div>
357
+ <div class="fw-dev-stat">
358
+ <span class="fw-dev-stat-label">Buffer</span>
359
+ <span class="fw-dev-stat-value">${stats.buffered}s</span>
360
+ </div>
361
+ <div class="fw-dev-stat">
362
+ <span class="fw-dev-stat-label">Playback Rate</span>
363
+ <span class="fw-dev-stat-value">${stats.playbackRate}x</span>
364
+ </div>
365
+ <div class="fw-dev-stat">
366
+ <span class="fw-dev-stat-label">Time</span>
367
+ <span class="fw-dev-stat-value">${stats.currentTime} / ${stats.duration}</span>
368
+ </div>
369
+ <div class="fw-dev-stat">
370
+ <span class="fw-dev-stat-label">Ready State</span>
371
+ <span class="fw-dev-stat-value">${stats.readyState}</span>
372
+ </div>
373
+ <div class="fw-dev-stat">
374
+ <span class="fw-dev-stat-label">Network State</span>
375
+ <span class="fw-dev-stat-value">${stats.networkState}</span>
376
+ </div>
377
+ ${primaryEndpoint?.protocol
378
+ ? html`
379
+ <div class="fw-dev-stat">
380
+ <span class="fw-dev-stat-label">Protocol</span>
381
+ <span class="fw-dev-stat-value">${primaryEndpoint.protocol}</span>
382
+ </div>
383
+ `
384
+ : nothing}
385
+ ${primaryEndpoint?.nodeId
386
+ ? html`
387
+ <div class="fw-dev-stat">
388
+ <span class="fw-dev-stat-label">Node ID</span>
389
+ <span
390
+ class="fw-dev-stat-value"
391
+ style="max-width:150px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;"
392
+ >${primaryEndpoint.nodeId}</span
393
+ >
394
+ </div>
395
+ `
396
+ : nothing}
397
+ `
398
+ : html`<div class="fw-dev-list-empty">No video element available</div>`}
399
+ ${playerStats
400
+ ? html`
401
+ <div class="fw-dev-list-header fw-dev-section-header">
402
+ <span class="fw-dev-list-title"
403
+ >${playerStats.type === "hls"
404
+ ? "HLS.js Stats"
405
+ : playerStats.type === "webrtc"
406
+ ? "WebRTC Stats"
407
+ : "Player Stats"}</span
408
+ >
409
+ </div>
410
+
411
+ ${playerStats.type === "hls"
412
+ ? html`
413
+ <div class="fw-dev-stat">
414
+ <span class="fw-dev-stat-label">Bitrate</span>
415
+ <span class="fw-dev-stat-value--accent"
416
+ >${typeof playerStats.currentBitrate === "number" &&
417
+ playerStats.currentBitrate > 0
418
+ ? `${Math.round(playerStats.currentBitrate / 1000)} kbps`
419
+ : "N/A"}</span
420
+ >
421
+ </div>
422
+ <div class="fw-dev-stat">
423
+ <span class="fw-dev-stat-label">Bandwidth Est.</span>
424
+ <span class="fw-dev-stat-value"
425
+ >${typeof playerStats.bandwidthEstimate === "number" &&
426
+ playerStats.bandwidthEstimate > 0
427
+ ? `${Math.round(playerStats.bandwidthEstimate / 1000)} kbps`
428
+ : "N/A"}</span
429
+ >
430
+ </div>
431
+ <div class="fw-dev-stat">
432
+ <span class="fw-dev-stat-label">Level</span>
433
+ <span class="fw-dev-stat-value"
434
+ >${typeof playerStats.currentLevel === "number" &&
435
+ playerStats.currentLevel >= 0
436
+ ? playerStats.currentLevel
437
+ : "Auto"}
438
+ / ${Array.isArray(playerStats.levels) ? playerStats.levels.length : 0}</span
439
+ >
440
+ </div>
441
+ ${typeof playerStats.latency === "number"
442
+ ? html`
443
+ <div class="fw-dev-stat">
444
+ <span class="fw-dev-stat-label">Latency</span>
445
+ <span
446
+ class=${classMap({
447
+ "fw-dev-stat-value": true,
448
+ "fw-dev-stat-value--warn": playerStats.latency > 5000,
449
+ })}
450
+ >${Math.round(playerStats.latency)} ms</span
451
+ >
452
+ </div>
453
+ `
454
+ : nothing}
455
+ `
456
+ : nothing}
457
+ ${playerStats.type === "webrtc"
458
+ ? html`
459
+ ${playerStats.video
460
+ ? html`
461
+ <div class="fw-dev-stat">
462
+ <span class="fw-dev-stat-label">Video Bitrate</span>
463
+ <span class="fw-dev-stat-value--accent"
464
+ >${typeof playerStats.video.bitrate === "number" &&
465
+ playerStats.video.bitrate > 0
466
+ ? `${Math.round(playerStats.video.bitrate / 1000)} kbps`
467
+ : "N/A"}</span
468
+ >
469
+ </div>
470
+ <div class="fw-dev-stat">
471
+ <span class="fw-dev-stat-label">FPS</span>
472
+ <span class="fw-dev-stat-value"
473
+ >${Math.round(
474
+ (playerStats.video.framesPerSecond as number) || 0
475
+ )}</span
476
+ >
477
+ </div>
478
+ <div class="fw-dev-stat">
479
+ <span class="fw-dev-stat-label">Frames</span>
480
+ <span class="fw-dev-stat-value"
481
+ >${playerStats.video.framesDecoded as number} decoded,
482
+ <span
483
+ class=${classMap({
484
+ "fw-dev-stat-value--bad":
485
+ ((playerStats.video.frameDropRate as number) || 0) > 1,
486
+ "fw-dev-stat-value--good":
487
+ ((playerStats.video.frameDropRate as number) || 0) <= 1,
488
+ })}
489
+ >${playerStats.video.framesDropped as number} dropped</span
490
+ ></span
491
+ >
492
+ </div>
493
+ <div class="fw-dev-stat">
494
+ <span class="fw-dev-stat-label">Packet Loss</span>
495
+ <span
496
+ class=${classMap({
497
+ "fw-dev-stat-value--bad":
498
+ ((playerStats.video.packetLossRate as number) || 0) > 1,
499
+ "fw-dev-stat-value--good":
500
+ ((playerStats.video.packetLossRate as number) || 0) <= 1,
501
+ })}
502
+ >${(
503
+ ((playerStats.video.packetLossRate as number) || 0) as number
504
+ ).toFixed(2)}%</span
505
+ >
506
+ </div>
507
+ <div class="fw-dev-stat">
508
+ <span class="fw-dev-stat-label">Jitter</span>
509
+ <span
510
+ class=${classMap({
511
+ "fw-dev-stat-value": true,
512
+ "fw-dev-stat-value--warn":
513
+ ((playerStats.video.jitter as number) || 0) > 30,
514
+ })}
515
+ >${(((playerStats.video.jitter as number) || 0) as number).toFixed(1)}
516
+ ms</span
517
+ >
518
+ </div>
519
+ <div class="fw-dev-stat">
520
+ <span class="fw-dev-stat-label">Jitter Buffer</span>
521
+ <span class="fw-dev-stat-value"
522
+ >${(
523
+ ((playerStats.video.jitterBufferDelay as number) || 0) as number
524
+ ).toFixed(1)}
525
+ ms</span
526
+ >
527
+ </div>
528
+ `
529
+ : nothing}
530
+ ${playerStats.network
531
+ ? html`
532
+ <div class="fw-dev-stat">
533
+ <span class="fw-dev-stat-label">RTT</span>
534
+ <span
535
+ class=${classMap({
536
+ "fw-dev-stat-value": true,
537
+ "fw-dev-stat-value--warn":
538
+ ((playerStats.network.rtt as number) || 0) > 200,
539
+ })}
540
+ >${Math.round(((playerStats.network.rtt as number) || 0) as number)}
541
+ ms</span
542
+ >
543
+ </div>
544
+ `
545
+ : nothing}
546
+ `
547
+ : nothing}
548
+ `
549
+ : nothing}
550
+ ${trackEntries.length > 0
551
+ ? html`
552
+ <div class="fw-dev-list-header fw-dev-section-header">
553
+ <span class="fw-dev-list-title">Tracks (${trackEntries.length})</span>
554
+ </div>
555
+ ${trackEntries.map(([id, track]) => {
556
+ const typedTrack = track as {
557
+ type: string;
558
+ codec: string;
559
+ width?: number;
560
+ height?: number;
561
+ bps?: number;
562
+ fpks?: number;
563
+ channels?: number;
564
+ rate?: number;
565
+ lang?: string;
566
+ };
567
+
568
+ return html`
569
+ <div class="fw-dev-track">
570
+ <div class="fw-dev-track-header">
571
+ <span
572
+ class=${classMap({
573
+ "fw-dev-track-badge": true,
574
+ "fw-dev-track-badge--video": typedTrack.type === "video",
575
+ "fw-dev-track-badge--audio": typedTrack.type === "audio",
576
+ "fw-dev-track-badge--other":
577
+ typedTrack.type !== "video" && typedTrack.type !== "audio",
578
+ })}
579
+ >${typedTrack.type}</span
580
+ >
581
+ <span class="fw-dev-track-codec">${typedTrack.codec}</span>
582
+ <span class="fw-dev-track-id">#${id}</span>
583
+ </div>
584
+ <div class="fw-dev-track-meta">
585
+ ${typedTrack.type === "video" && typedTrack.width && typedTrack.height
586
+ ? html`<span>${typedTrack.width}x${typedTrack.height}</span>`
587
+ : nothing}
588
+ ${typedTrack.bps
589
+ ? html`<span>${Math.round(typedTrack.bps / 1000)} kbps</span>`
590
+ : nothing}
591
+ ${typedTrack.fpks
592
+ ? html`<span>${Math.round(typedTrack.fpks / 1000)} fps</span>`
593
+ : nothing}
594
+ ${typedTrack.type === "audio" && typedTrack.channels
595
+ ? html`<span>${typedTrack.channels}ch</span>`
596
+ : nothing}
597
+ ${typedTrack.type === "audio" && typedTrack.rate
598
+ ? html`<span>${typedTrack.rate} Hz</span>`
599
+ : nothing}
600
+ ${typedTrack.lang ? html`<span>${typedTrack.lang}</span>` : nothing}
601
+ </div>
602
+ </div>
603
+ `;
604
+ })}
605
+ `
606
+ : nothing}
607
+ ${mistStreamInfo && trackEntries.length === 0
608
+ ? html`
609
+ <div class="fw-dev-no-tracks">
610
+ <span class="fw-dev-no-tracks-text"
611
+ >No track data available
612
+ ${mistStreamInfo.type
613
+ ? html`<span class="fw-dev-no-tracks-type">(${mistStreamInfo.type})</span>`
614
+ : nothing}</span
615
+ >
616
+ </div>
617
+ `
618
+ : nothing}
188
619
  </div>
189
620
  `;
190
621
  }
191
622
 
192
- private _renderConfig(s: typeof this.pc.s) {
623
+ private _renderConfigTab(): unknown {
624
+ const allCombinations = this._getAllCombinations();
625
+ const compatibleCombinations = allCombinations.filter((combo) => combo.compatible);
626
+ const activeComboIndex = this._getActiveComboIndex(allCombinations);
627
+
628
+ const currentPlayer = this.pc.s.currentPlayerInfo;
629
+ const currentSource = this.pc.s.currentSourceInfo;
630
+
193
631
  return html`
194
- <div class="section">
195
- <div class="label">Current Player</div>
196
- <div class="value">${s.currentPlayerInfo?.name ?? "—"}</div>
197
- </div>
198
- <div class="section">
199
- <div class="label">Current Source</div>
200
- <div class="value">${s.currentSourceInfo?.type ?? ""}</div>
201
- </div>
202
- <div class="section">
203
- <div class="label">Playback Mode</div>
204
- <div class="mode-group fw-dev-mode-group">
205
- ${this._modes.map(
206
- (mode) => html`
207
- <button
208
- class=${classMap({
209
- "mode-btn": true,
210
- "fw-dev-mode-btn": true,
211
- "mode-btn--active": this.playbackMode === mode,
212
- "fw-dev-mode-btn--active": this.playbackMode === mode,
632
+ <div class="fw-dev-body">
633
+ <div class="fw-dev-section">
634
+ <div class="fw-dev-label">Active</div>
635
+ <div class="fw-dev-value">
636
+ ${currentPlayer?.name || "None"}
637
+ <span class="fw-dev-value-arrow">${"\u2192"}</span>
638
+ ${SOURCE_TYPE_LABELS[currentSource?.type || ""] || currentSource?.type || "-"}
639
+ </div>
640
+ ${(this.pc.s.endpoints?.primary as { nodeId?: string } | undefined)?.nodeId
641
+ ? html`
642
+ <div class="fw-dev-value-muted">
643
+ Node: ${(this.pc.s.endpoints?.primary as { nodeId?: string }).nodeId}
644
+ </div>
645
+ `
646
+ : nothing}
647
+ </div>
648
+
649
+ <div class="fw-dev-section">
650
+ <div class="fw-dev-label">Playback Mode</div>
651
+ <div class="fw-dev-mode-group">
652
+ ${(["auto", "low-latency", "quality"] as const).map(
653
+ (mode) => html`
654
+ <button
655
+ type="button"
656
+ class=${classMap({
657
+ "fw-dev-mode-btn": true,
658
+ "fw-dev-mode-btn--active": this.playbackMode === mode,
659
+ })}
660
+ @click=${() => this._handleModeChange(mode)}
661
+ >
662
+ ${mode === "low-latency"
663
+ ? "Low Lat"
664
+ : `${mode.charAt(0).toUpperCase()}${mode.slice(1)}`}
665
+ </button>
666
+ `
667
+ )}
668
+ </div>
669
+ <div class="fw-dev-mode-desc">
670
+ ${this.playbackMode === "auto"
671
+ ? "Balanced: MP4/WS \u2192 WHEP \u2192 HLS"
672
+ : this.playbackMode === "low-latency"
673
+ ? "WHEP/WebRTC first (<1s delay)"
674
+ : "MP4/WS first, HLS fallback"}
675
+ </div>
676
+ </div>
677
+
678
+ <div class="fw-dev-actions">
679
+ <button type="button" class="fw-dev-action-btn" @click=${this._handleReload}>
680
+ Reload
681
+ </button>
682
+ <button type="button" class="fw-dev-action-btn" @click=${this._handleNextCombo}>
683
+ Next Option
684
+ </button>
685
+ </div>
686
+
687
+ <div class="fw-dev-section" style="padding:0;border-bottom:0;">
688
+ <div class="fw-dev-list-header">
689
+ <span class="fw-dev-list-title">Player Options (${compatibleCombinations.length})</span>
690
+ ${allCombinations.length > compatibleCombinations.length
691
+ ? html`
692
+ <button
693
+ type="button"
694
+ class="fw-dev-list-toggle"
695
+ @click=${() => {
696
+ this._showDisabledPlayers = !this._showDisabledPlayers;
697
+ }}
698
+ >
699
+ <svg
700
+ width="10"
701
+ height="10"
702
+ viewBox="0 0 24 24"
703
+ fill="none"
704
+ stroke="currentColor"
705
+ stroke-width="2"
706
+ class=${classMap({
707
+ "fw-dev-chevron": true,
708
+ "fw-dev-chevron--open": this._showDisabledPlayers,
709
+ })}
710
+ >
711
+ <path d="M6 9l6 6 6-6"></path>
712
+ </svg>
713
+ ${this._showDisabledPlayers ? "Hide" : "Show"} disabled
714
+ (${allCombinations.length - compatibleCombinations.length})
715
+ </button>
716
+ `
717
+ : nothing}
718
+ </div>
719
+
720
+ ${allCombinations.length === 0
721
+ ? html`<div class="fw-dev-list-empty">No stream info available</div>`
722
+ : html`
723
+ ${allCombinations.map((combo, index) => {
724
+ const isCodecIncompatible = combo.codecIncompatible === true;
725
+ if (!combo.compatible && !isCodecIncompatible && !this._showDisabledPlayers) {
726
+ return nothing;
727
+ }
728
+
729
+ const isActive = activeComboIndex === index;
730
+ const typeLabel =
731
+ SOURCE_TYPE_LABELS[combo.sourceType] || combo.sourceType.split("/").pop();
732
+
733
+ const scoreClass =
734
+ !combo.compatible && !isCodecIncompatible
735
+ ? "fw-dev-combo-score--disabled"
736
+ : isCodecIncompatible
737
+ ? "fw-dev-combo-score--low"
738
+ : combo.score >= 2
739
+ ? "fw-dev-combo-score--high"
740
+ : combo.score >= 1.5
741
+ ? "fw-dev-combo-score--mid"
742
+ : "fw-dev-combo-score--low";
743
+
744
+ const rankClass = isActive
745
+ ? "fw-dev-combo-rank--active"
746
+ : !combo.compatible && !isCodecIncompatible
747
+ ? "fw-dev-combo-rank--disabled"
748
+ : isCodecIncompatible
749
+ ? "fw-dev-combo-rank--warn"
750
+ : "";
751
+
752
+ const typeClass =
753
+ !combo.compatible && !isCodecIncompatible
754
+ ? "fw-dev-combo-type--disabled"
755
+ : isCodecIncompatible
756
+ ? "fw-dev-combo-type--warn"
757
+ : "";
758
+
759
+ return html`
760
+ <div
761
+ class="fw-dev-combo"
762
+ @mouseenter=${(event: MouseEvent) =>
763
+ this._handleComboMouseEnter(index, event)}
764
+ @mouseleave=${() => {
765
+ this._hoveredComboIndex = null;
766
+ }}
767
+ >
768
+ <button
769
+ type="button"
770
+ class=${classMap({
771
+ "fw-dev-combo-btn": true,
772
+ "fw-dev-combo-btn--active": isActive,
773
+ "fw-dev-combo-btn--disabled": !combo.compatible && !isCodecIncompatible,
774
+ "fw-dev-combo-btn--codec-warn": isCodecIncompatible,
775
+ })}
776
+ @click=${() => this._handleSelectCombo(index)}
777
+ >
778
+ <span
779
+ class=${classMap({
780
+ "fw-dev-combo-rank": true,
781
+ [rankClass]: rankClass.length > 0,
782
+ })}
783
+ >${combo.compatible
784
+ ? index + 1
785
+ : isCodecIncompatible
786
+ ? "\u26A0"
787
+ : "\u2014"}</span
788
+ >
789
+
790
+ <span class="fw-dev-combo-name"
791
+ >${combo.playerName} <span class="fw-dev-combo-arrow">${"\u2192"}</span>
792
+ <span
793
+ class=${classMap({
794
+ "fw-dev-combo-type": true,
795
+ [typeClass]: typeClass.length > 0,
796
+ })}
797
+ >${typeLabel}</span
798
+ ></span
799
+ >
800
+
801
+ <span class=${classMap({ "fw-dev-combo-score": true, [scoreClass]: true })}
802
+ >${combo.score.toFixed(2)}</span
803
+ >
804
+ </button>
805
+
806
+ ${this._hoveredComboIndex === index
807
+ ? html`
808
+ <div
809
+ class=${classMap({
810
+ "fw-dev-tooltip": true,
811
+ "fw-dev-tooltip--above": this._tooltipAbove,
812
+ "fw-dev-tooltip--below": !this._tooltipAbove,
813
+ })}
814
+ >
815
+ <div class="fw-dev-tooltip-header">
816
+ <div class="fw-dev-tooltip-title">${combo.playerName}</div>
817
+ <div class="fw-dev-tooltip-subtitle">${combo.sourceType}</div>
818
+ ${combo.scoreBreakdown?.trackTypes &&
819
+ combo.scoreBreakdown.trackTypes.length > 0
820
+ ? html`
821
+ <div class="fw-dev-tooltip-tracks">
822
+ Tracks:
823
+ <span class="fw-dev-tooltip-value"
824
+ >${combo.scoreBreakdown.trackTypes.join(", ")}</span
825
+ >
826
+ </div>
827
+ `
828
+ : nothing}
829
+ </div>
830
+
831
+ ${combo.compatible && combo.scoreBreakdown
832
+ ? html`
833
+ <div class="fw-dev-tooltip-score">
834
+ Score: ${combo.score.toFixed(2)}
835
+ </div>
836
+ <div class="fw-dev-tooltip-row">
837
+ Tracks [${combo.scoreBreakdown.trackTypes.join(", ")}]:
838
+ <span class="fw-dev-tooltip-value"
839
+ >${combo.scoreBreakdown.trackScore.toFixed(2)}</span
840
+ >
841
+ <span class="fw-dev-tooltip-weight"
842
+ >x${combo.scoreBreakdown.weights.tracks}</span
843
+ >
844
+ </div>
845
+ <div class="fw-dev-tooltip-row">
846
+ Priority:
847
+ <span class="fw-dev-tooltip-value"
848
+ >${combo.scoreBreakdown.priorityScore.toFixed(2)}</span
849
+ >
850
+ <span class="fw-dev-tooltip-weight"
851
+ >x${combo.scoreBreakdown.weights.priority}</span
852
+ >
853
+ </div>
854
+ <div class="fw-dev-tooltip-row">
855
+ Source:
856
+ <span class="fw-dev-tooltip-value"
857
+ >${combo.scoreBreakdown.sourceScore.toFixed(2)}</span
858
+ >
859
+ <span class="fw-dev-tooltip-weight"
860
+ >x${combo.scoreBreakdown.weights.source}</span
861
+ >
862
+ </div>
863
+
864
+ ${typeof combo.scoreBreakdown.reliabilityScore === "number"
865
+ ? html`
866
+ <div class="fw-dev-tooltip-row">
867
+ Reliability:
868
+ <span class="fw-dev-tooltip-value"
869
+ >${combo.scoreBreakdown.reliabilityScore.toFixed(
870
+ 2
871
+ )}</span
872
+ >
873
+ <span class="fw-dev-tooltip-weight"
874
+ >x${combo.scoreBreakdown.weights.reliability ??
875
+ 0}</span
876
+ >
877
+ </div>
878
+ `
879
+ : nothing}
880
+ ${typeof combo.scoreBreakdown.modeBonus === "number" &&
881
+ combo.scoreBreakdown.modeBonus !== 0
882
+ ? html`
883
+ <div class="fw-dev-tooltip-row">
884
+ Mode (${this.playbackMode}):
885
+ <span class="fw-dev-tooltip-bonus"
886
+ >+${combo.scoreBreakdown.modeBonus.toFixed(2)}</span
887
+ >
888
+ <span class="fw-dev-tooltip-weight"
889
+ >x${combo.scoreBreakdown.weights.mode ?? 0}</span
890
+ >
891
+ </div>
892
+ `
893
+ : nothing}
894
+ ${typeof combo.scoreBreakdown.routingBonus === "number" &&
895
+ combo.scoreBreakdown.routingBonus !== 0
896
+ ? html`
897
+ <div class="fw-dev-tooltip-row">
898
+ Routing:
899
+ <span
900
+ class=${classMap({
901
+ "fw-dev-tooltip-bonus":
902
+ combo.scoreBreakdown.routingBonus > 0,
903
+ "fw-dev-tooltip-penalty":
904
+ combo.scoreBreakdown.routingBonus < 0,
905
+ })}
906
+ >${combo.scoreBreakdown.routingBonus > 0
907
+ ? "+"
908
+ : ""}${combo.scoreBreakdown.routingBonus.toFixed(
909
+ 2
910
+ )}</span
911
+ >
912
+ <span class="fw-dev-tooltip-weight"
913
+ >x${combo.scoreBreakdown.weights.routing ?? 0}</span
914
+ >
915
+ </div>
916
+ `
917
+ : nothing}
918
+ `
919
+ : html`
920
+ <div class="fw-dev-tooltip-error">
921
+ ${combo.incompatibleReason || "Incompatible"}
922
+ </div>
923
+ `}
924
+ </div>
925
+ `
926
+ : nothing}
927
+ </div>
928
+ `;
213
929
  })}
214
- @click=${() => this.pc.setDevModeOptions({ playbackMode: mode })}
215
- >
216
- ${mode}
217
- </button>
218
- `
219
- )}
930
+ `}
220
931
  </div>
221
932
  </div>
222
- <div class="actions fw-dev-actions">
223
- <button
224
- class="action-btn fw-dev-action-btn"
225
- @click=${() => {
226
- this.pc.clearError();
227
- this.pc.reload();
228
- }}
229
- >
230
- Reload
231
- </button>
232
- </div>
233
933
  `;
234
934
  }
235
935
 
236
- private _renderStats(s: typeof this.pc.s) {
237
- const q = s.playbackQuality;
936
+ protected render() {
238
937
  return html`
239
- <div class="section">
240
- <div class="label">Playback</div>
241
- ${this._row("State", s.state)}
242
- ${this._row(
243
- "Time",
244
- `${s.currentTime.toFixed(1)}s / ${isFinite(s.duration) ? s.duration.toFixed(1) + "s" : "∞"}`
245
- )}
246
- ${this._row("Volume", `${Math.round(s.volume * 100)}%${s.isMuted ? " (muted)" : ""}`)}
938
+ <div class="fw-dev-panel">
939
+ <div class="fw-dev-header">
940
+ <button
941
+ type="button"
942
+ class=${classMap({
943
+ "fw-dev-tab": true,
944
+ "fw-dev-tab--active": this._activeTab === "config",
945
+ })}
946
+ @click=${() => {
947
+ this._activeTab = "config";
948
+ }}
949
+ >
950
+ Config
951
+ </button>
952
+ <button
953
+ type="button"
954
+ class=${classMap({
955
+ "fw-dev-tab": true,
956
+ "fw-dev-tab--active": this._activeTab === "stats",
957
+ })}
958
+ @click=${() => {
959
+ this._activeTab = "stats";
960
+ }}
961
+ >
962
+ Stats
963
+ </button>
964
+ <div class="fw-dev-spacer"></div>
965
+ <button
966
+ type="button"
967
+ class="fw-dev-close"
968
+ aria-label="Close dev mode panel"
969
+ @click=${() =>
970
+ this.dispatchEvent(new CustomEvent("fw-close", { bubbles: true, composed: true }))}
971
+ >
972
+ ${closeIcon()}
973
+ </button>
974
+ </div>
975
+
976
+ ${this._activeTab === "config" ? this._renderConfigTab() : this._renderStatsTab()}
247
977
  </div>
248
- ${q
249
- ? html`
250
- <div class="section">
251
- <div class="label">Quality</div>
252
- ${this._row("Resolution", this._resolution())}
253
- ${this._row("Bitrate", q.bitrate ? `${Math.round(q.bitrate / 1000)} kbps` : "—")}
254
- ${this._row("Latency", q.latency != null ? `${q.latency.toFixed(2)}s` : "—")}
255
- ${this._row(
256
- "Buffer",
257
- q.bufferedAhead != null ? `${q.bufferedAhead.toFixed(1)}s` : "—"
258
- )}
259
- ${this._row("Score", q.score != null ? `${q.score.toFixed(0)}` : "—")}
260
- ${this._row("Drops", `${q.frameDropRate?.toFixed(1) ?? "0"}%`)}
261
- ${this._row("Stalls", `${q.stallCount ?? 0}`)}
262
- </div>
263
- `
264
- : nothing}
265
978
  `;
266
979
  }
267
-
268
- private _resolution(): string {
269
- const video = this.pc.s.videoElement;
270
- if (!video || !video.videoWidth || !video.videoHeight) return "—";
271
- return `${video.videoWidth}×${video.videoHeight}`;
272
- }
273
-
274
- private _row(label: string, value: string) {
275
- return html`<div class="stat-row">
276
- <span class="stat-label">${label}</span><span class="stat-value">${value}</span>
277
- </div>`;
278
- }
279
980
  }
280
981
 
281
982
  declare global {