@shortkitsdk/react-native 0.2.30 → 0.2.32

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 (32) hide show
  1. package/android/libs/shortkit-release.aar +0 -0
  2. package/ios/ShortKitBridge.swift +77 -3
  3. package/ios/ShortKitFeedView.swift +1 -12
  4. package/ios/ShortKitModule.mm +5 -1
  5. package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/Info.plist +2 -2
  6. package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios.abi.json +5652 -972
  7. package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios.private.swiftinterface +111 -9
  8. package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios.swiftdoc +0 -0
  9. package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios.swiftinterface +111 -9
  10. package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/ShortKitSDK +0 -0
  11. package/ios/ShortKitSDK.xcframework/ios-arm64/ShortKitSDK.framework/_CodeSignature/CodeResources +9 -9
  12. package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/Info.plist +2 -2
  13. package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios-simulator.abi.json +5652 -972
  14. package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios-simulator.private.swiftinterface +111 -9
  15. package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios-simulator.swiftdoc +0 -0
  16. package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/arm64-apple-ios-simulator.swiftinterface +111 -9
  17. package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/x86_64-apple-ios-simulator.abi.json +5652 -972
  18. package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/x86_64-apple-ios-simulator.private.swiftinterface +111 -9
  19. package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/x86_64-apple-ios-simulator.swiftdoc +0 -0
  20. package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/Modules/ShortKitSDK.swiftmodule/x86_64-apple-ios-simulator.swiftinterface +111 -9
  21. package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/ShortKitSDK +0 -0
  22. package/ios/ShortKitSDK.xcframework/ios-arm64_x86_64-simulator/ShortKitSDK.framework/_CodeSignature/CodeResources +17 -17
  23. package/ios/ShortKitWidgetNativeView.swift +23 -0
  24. package/ios/ShortKitWidgetNativeViewManager.mm +1 -0
  25. package/package.json +1 -1
  26. package/src/ShortKitCommands.ts +12 -2
  27. package/src/ShortKitContext.ts +7 -1
  28. package/src/ShortKitWidget.tsx +47 -2
  29. package/src/specs/NativeShortKitModule.ts +9 -1
  30. package/src/specs/ShortKitWidgetViewNativeComponent.ts +6 -0
  31. package/src/types.ts +15 -0
  32. package/ios/DebugPanelView.swift +0 -302
@@ -55,7 +55,13 @@ export interface ShortKitContextValue {
55
55
 
56
56
  // Download
57
57
  downloadState: DownloadState;
58
- downloadVideo: (itemId: string, mode?: 'nonInterruptive' | 'interruptive') => Promise<string>;
58
+ downloadVideo: (
59
+ itemId: string,
60
+ options?: {
61
+ mode?: 'interruptive' | 'nonInterruptive';
62
+ overlayMode?: 'none' | 'static' | 'deterministic';
63
+ }
64
+ ) => Promise<string>;
59
65
  cancelDownload: () => void;
60
66
  }
61
67
 
@@ -1,13 +1,21 @@
1
- import React, { useContext, useLayoutEffect, useMemo } from 'react';
1
+ import React, { useContext, useEffect, useLayoutEffect, useMemo, useRef } from 'react';
2
2
  import { View, StyleSheet } from 'react-native';
3
3
  import type { ShortKitWidgetProps } from './types';
4
4
  import ShortKitWidgetView from './specs/ShortKitWidgetViewNativeComponent';
5
+ import NativeShortKitModule from './specs/NativeShortKitModule';
5
6
  import { ShortKitInitContext } from './ShortKitContext';
6
7
  import { serializeWidgetConfig } from './serialization';
7
8
  import { registerOverlayComponent } from './ShortKitOverlaySurface';
8
9
  import { registerCarouselOverlayComponent } from './ShortKitCarouselOverlaySurface';
9
10
  import { registerVideoCarouselOverlayComponent } from './ShortKitVideoCarouselOverlaySurface';
10
11
 
12
+ // Local UUID generator. We don't pull in a runtime dep just for this; a
13
+ // timestamp + random suffix is more than unique enough to disambiguate
14
+ // concurrently-mounted widgets within a single app session.
15
+ function generateWidgetId(): string {
16
+ return `widget-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
17
+ }
18
+
11
19
  /**
12
20
  * Horizontal carousel widget component. Displays a row of video cards
13
21
  * with automatic rotation. Wraps a native Fabric view.
@@ -15,13 +23,36 @@ import { registerVideoCarouselOverlayComponent } from './ShortKitVideoCarouselOv
15
23
  * Must be rendered inside a `<ShortKitProvider>`.
16
24
  */
17
25
  export function ShortKitWidget(props: ShortKitWidgetProps) {
18
- const { config, items, style } = props;
26
+ const { config, items, onCardTap, style } = props;
19
27
 
20
28
  const isInitialized = useContext(ShortKitInitContext);
21
29
  if (!isInitialized) {
22
30
  throw new Error('ShortKitWidget must be used within a ShortKitProvider');
23
31
  }
24
32
 
33
+ // Stable widget ID for the lifetime of this component, ONLY when the host
34
+ // wires `onCardTap`. Skipping it when unset means the native side leaves
35
+ // `widgetVC.onCardTap` nil, which means the SDK's existing
36
+ // `clickAction: 'feed'` modal-presentation path stays intact for hosts
37
+ // that don't opt in. Backwards compatible.
38
+ const widgetId = useMemo(
39
+ () => (onCardTap ? generateWidgetId() : undefined),
40
+ // We deliberately compute this once per "host wired callback" lifetime,
41
+ // not per render. The boolean test on first render is what matters; if
42
+ // the host swaps onCardTap from set → unset later we keep the same
43
+ // widgetId (harmless — no events will fire because the JS subscription
44
+ // below tears down). Going the other way (unset → set), we generate a
45
+ // fresh id then.
46
+ // eslint-disable-next-line react-hooks/exhaustive-deps
47
+ [Boolean(onCardTap)],
48
+ );
49
+
50
+ // Keep a stable ref to the latest onCardTap so the subscription doesn't
51
+ // need to tear down + rebuild every time the host re-renders with a new
52
+ // closure identity.
53
+ const onCardTapRef = useRef(onCardTap);
54
+ onCardTapRef.current = onCardTap;
55
+
25
56
  useLayoutEffect(() => {
26
57
  if (config?.overlay && config.overlay !== 'none') {
27
58
  registerOverlayComponent(config.overlay.name, config.overlay.component);
@@ -40,6 +71,19 @@ export function ShortKitWidget(props: ShortKitWidgetProps) {
40
71
  }
41
72
  }, [config?.overlay, config?.feedConfig]);
42
73
 
74
+ // Subscribe to the global widget-card-tap event, filtered to this
75
+ // instance's widgetId.
76
+ useEffect(() => {
77
+ if (!NativeShortKitModule || !onCardTap) return;
78
+
79
+ const subscription = NativeShortKitModule.onWidgetCardTap((event) => {
80
+ if (event.widgetId !== widgetId) return;
81
+ onCardTapRef.current?.(event.playbackId, event.index);
82
+ });
83
+
84
+ return () => subscription.remove();
85
+ }, [widgetId, onCardTap]);
86
+
43
87
  const serializedConfig = useMemo(() => {
44
88
  return serializeWidgetConfig(config ?? {});
45
89
  }, [config]);
@@ -55,6 +99,7 @@ export function ShortKitWidget(props: ShortKitWidgetProps) {
55
99
  style={styles.widget}
56
100
  config={serializedConfig}
57
101
  items={serializedItems}
102
+ widgetId={widgetId}
58
103
  />
59
104
  </View>
60
105
  );
@@ -127,6 +127,12 @@ type VideoCarouselCellTapEvent = Readonly<{
127
127
  pageIndex: Int32;
128
128
  }>;
129
129
 
130
+ type WidgetCardTapEvent = Readonly<{
131
+ widgetId: string;
132
+ playbackId: string;
133
+ index: Int32;
134
+ }>;
135
+
130
136
  type DownloadStartedEvent = Readonly<{
131
137
  itemId: string;
132
138
  }>;
@@ -134,6 +140,7 @@ type DownloadStartedEvent = Readonly<{
134
140
  type DownloadProgressEvent = Readonly<{
135
141
  itemId: string;
136
142
  progress: number;
143
+ phase: 'downloading' | 'compositing' | 'finalizing';
137
144
  }>;
138
145
 
139
146
  type DownloadCompletedEvent = Readonly<{
@@ -300,7 +307,7 @@ export interface Spec extends TurboModule {
300
307
  getStoryboardData(playbackId: string): Promise<string>;
301
308
 
302
309
  // --- Download ---
303
- downloadVideo(itemId: string, mode: string): Promise<string>;
310
+ downloadVideo(itemId: string, mode: string, overlayMode: string): Promise<string>;
304
311
  cancelDownload(): void;
305
312
 
306
313
  // --- Carousel accessors ---
@@ -329,6 +336,7 @@ export interface Spec extends TurboModule {
329
336
  readonly onDidFetchContentItems: EventEmitter<DidFetchContentItemsEvent>;
330
337
  readonly onFeedReady: EventEmitter<FeedReadyEvent>;
331
338
  readonly onVideoCarouselCellTap: EventEmitter<VideoCarouselCellTapEvent>;
339
+ readonly onWidgetCardTap: EventEmitter<WidgetCardTapEvent>;
332
340
 
333
341
  // --- Overlay per-surface events ---
334
342
  readonly onOverlayActiveChanged: EventEmitter<OverlayActiveEvent>;
@@ -4,6 +4,12 @@ import { codegenNativeComponent } from 'react-native';
4
4
  export interface NativeProps extends ViewProps {
5
5
  config: string;
6
6
  items?: string;
7
+ /**
8
+ * Stable per-instance widget identifier. Used to route the global
9
+ * `NativeShortKitModule.onWidgetCardTap` event back to the originating
10
+ * `<ShortKitWidget>` when multiple widgets are mounted.
11
+ */
12
+ widgetId?: string;
7
13
  }
8
14
 
9
15
  export default codegenNativeComponent<NativeProps>(
package/src/types.ts CHANGED
@@ -73,6 +73,10 @@ export interface ContentItem {
73
73
 
74
74
  export type DownloadStatus = 'idle' | 'downloading' | 'completed' | 'failed';
75
75
 
76
+ export type DownloadOverlayMode = 'none' | 'static' | 'deterministic';
77
+
78
+ export type DownloadPhase = 'downloading' | 'compositing' | 'finalizing';
79
+
76
80
  export interface DownloadState {
77
81
  status: DownloadStatus;
78
82
  /** ID of the content item being downloaded, if any. */
@@ -484,6 +488,17 @@ export interface ShortKitWidgetProps {
484
488
  * how `<ShortKitFeed feedItems={...} />` works.
485
489
  */
486
490
  items?: WidgetInput[];
491
+ /**
492
+ * Per-card tap callback. When set, the SDK skips the built-in
493
+ * `clickAction: 'feed'` modal-presentation path and hands the tap to the
494
+ * host — typically used to push a feed screen via the host's navigator
495
+ * instead of letting the SDK present a UIKit modal that escapes the host's
496
+ * navigation hierarchy.
497
+ *
498
+ * Mirrors `ShortKitPlayer.onTap`. `clickAction: 'mute'` continues to work
499
+ * regardless (per-cell mute toggle runs alongside `onCardTap`).
500
+ */
501
+ onCardTap?: (playbackId: string, index: number) => void;
487
502
  style?: ViewStyle;
488
503
  }
489
504
 
@@ -1,302 +0,0 @@
1
- import UIKit
2
- import Combine
3
- import ShortKitSDK
4
-
5
- private enum AdjacentSlot {
6
- case previous, next
7
- }
8
-
9
- final class DebugPanelView: UIView {
10
- static let panelWidth: CGFloat = 310
11
- static let panelHeight: CGFloat = 296 // +20 for collapsed header row
12
- static let expandedExtraHeight: CGFloat = 80 // 4 labels × ~20pt
13
-
14
- /// Currently selected swipe curve — FeedViewController reads SwipeCurve.selected.
15
- static var selectedSwipeCurve: SwipeCurve {
16
- get { SwipeCurve.selected }
17
- set { SwipeCurve.selected = newValue }
18
- }
19
-
20
- private let stackView = UIStackView()
21
- private let headerLabel = UILabel()
22
- private let curveSegment = UISegmentedControl()
23
- private let positionLabel = UILabel()
24
- private let intentTtffLabel = UILabel()
25
- private let transitionTtffLabel = UILabel()
26
- private let prerollBufferLabel = UILabel()
27
- private let videoQualityLabel = UILabel()
28
- private let thumbnailLabel = UILabel()
29
- private let prefetchLabel = UILabel()
30
- private let bitrateLabel = UILabel()
31
- private let rebufferLabel = UILabel()
32
- private let loopWatchLabel = UILabel()
33
-
34
- // Adjacent players section
35
- private let adjacentHeader = UILabel()
36
- private let prevHeaderLabel = UILabel()
37
- private let prevDetailLabel = UILabel()
38
- private let nextHeaderLabel = UILabel()
39
- private let nextDetailLabel = UILabel()
40
- private let separatorView = UIView()
41
- private var adjacentLabels: [UIView] = []
42
- private var isAdjacentExpanded = false
43
- private var prevCancellable: AnyCancellable?
44
- private var nextCancellable: AnyCancellable?
45
-
46
- private var cancellable: AnyCancellable?
47
- private var dragOffset: CGPoint = .zero
48
-
49
- override init(frame: CGRect) {
50
- super.init(frame: frame)
51
- setupViews()
52
- }
53
-
54
- required init?(coder: NSCoder) {
55
- fatalError("init(coder:) has not been implemented")
56
- }
57
-
58
- private func setupViews() {
59
- backgroundColor = UIColor.black.withAlphaComponent(0.75)
60
- layer.cornerRadius = 10
61
- clipsToBounds = true
62
-
63
- let pan = UIPanGestureRecognizer(target: self, action: #selector(handlePan(_:)))
64
- addGestureRecognizer(pan)
65
-
66
- let labels = [headerLabel, positionLabel, intentTtffLabel, transitionTtffLabel, prerollBufferLabel, videoQualityLabel, thumbnailLabel, prefetchLabel, bitrateLabel, rebufferLabel, loopWatchLabel]
67
- for label in labels {
68
- label.font = UIFont.monospacedSystemFont(ofSize: 12, weight: .regular)
69
- label.textColor = .white
70
- label.numberOfLines = 1
71
- }
72
- headerLabel.font = UIFont.monospacedSystemFont(ofSize: 12, weight: .bold)
73
-
74
- stackView.axis = .vertical
75
- stackView.spacing = 4
76
- stackView.alignment = .leading
77
- stackView.translatesAutoresizingMaskIntoConstraints = false
78
- addSubview(stackView)
79
-
80
- for label in labels {
81
- stackView.addArrangedSubview(label)
82
- }
83
-
84
- // Swipe curve picker
85
- for (i, curve) in SwipeCurve.allCases.enumerated() {
86
- curveSegment.insertSegment(withTitle: curve.label, at: i, animated: false)
87
- }
88
- curveSegment.selectedSegmentIndex = SwipeCurve.quarticOut.rawValue
89
- curveSegment.setTitleTextAttributes([.foregroundColor: UIColor.white, .font: UIFont.monospacedSystemFont(ofSize: 10, weight: .medium)], for: .normal)
90
- curveSegment.setTitleTextAttributes([.foregroundColor: UIColor.black], for: .selected)
91
- curveSegment.backgroundColor = UIColor.white.withAlphaComponent(0.15)
92
- curveSegment.selectedSegmentTintColor = UIColor.white.withAlphaComponent(0.9)
93
- curveSegment.addTarget(self, action: #selector(curveChanged), for: .valueChanged)
94
- stackView.addArrangedSubview(curveSegment)
95
-
96
- // Separator
97
- separatorView.backgroundColor = UIColor.white.withAlphaComponent(0.3)
98
- separatorView.translatesAutoresizingMaskIntoConstraints = false
99
- separatorView.heightAnchor.constraint(equalToConstant: 0.5).isActive = true
100
- stackView.addArrangedSubview(separatorView)
101
-
102
- // Adjacent header (tap target)
103
- adjacentHeader.font = UIFont.monospacedSystemFont(ofSize: 12, weight: .bold)
104
- adjacentHeader.textColor = .white
105
- adjacentHeader.text = "▶ Adjacent Players"
106
- adjacentHeader.isUserInteractionEnabled = true
107
- adjacentHeader.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(toggleAdjacent)))
108
- stackView.addArrangedSubview(adjacentHeader)
109
-
110
- // Adjacent player labels (initially hidden)
111
- let adjLabels = [prevHeaderLabel, prevDetailLabel, nextHeaderLabel, nextDetailLabel]
112
- for label in adjLabels {
113
- label.font = UIFont.monospacedSystemFont(ofSize: 11, weight: .regular)
114
- label.textColor = UIColor.white.withAlphaComponent(0.85)
115
- label.numberOfLines = 1
116
- label.isHidden = true
117
- stackView.addArrangedSubview(label)
118
- }
119
- adjacentLabels = adjLabels
120
-
121
- prevHeaderLabel.text = "▲ Prev — not assigned"
122
- prevDetailLabel.text = ""
123
- nextHeaderLabel.text = "▼ Next — not assigned"
124
- nextDetailLabel.text = ""
125
-
126
- NSLayoutConstraint.activate([
127
- stackView.topAnchor.constraint(equalTo: topAnchor, constant: 10),
128
- stackView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: 12),
129
- stackView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -12),
130
- ])
131
-
132
- // Initial state
133
- update(DebugMetrics())
134
- }
135
-
136
- @objc private func handlePan(_ gesture: UIPanGestureRecognizer) {
137
- guard let parent = superview else { return }
138
- switch gesture.state {
139
- case .began:
140
- dragOffset = gesture.location(in: self)
141
- case .changed:
142
- let location = gesture.location(in: parent)
143
- var newCenter = CGPoint(
144
- x: location.x - dragOffset.x + bounds.width / 2,
145
- y: location.y - dragOffset.y + bounds.height / 2
146
- )
147
- // Keep panel within parent bounds
148
- let hw = bounds.width / 2, hh = bounds.height / 2
149
- newCenter.x = max(hw, min(parent.bounds.width - hw, newCenter.x))
150
- newCenter.y = max(hh, min(parent.bounds.height - hh, newCenter.y))
151
- center = newCenter
152
- default:
153
- break
154
- }
155
- }
156
-
157
- @objc private func curveChanged(_ sender: UISegmentedControl) {
158
- guard let curve = SwipeCurve(rawValue: sender.selectedSegmentIndex) else { return }
159
- Self.selectedSwipeCurve = curve
160
- }
161
-
162
- @objc private func toggleAdjacent() {
163
- isAdjacentExpanded.toggle()
164
- adjacentHeader.text = isAdjacentExpanded ? "▼ Adjacent Players" : "▶ Adjacent Players"
165
-
166
- UIView.animate(withDuration: 0.25) {
167
- for label in self.adjacentLabels {
168
- label.isHidden = !self.isAdjacentExpanded
169
- label.alpha = self.isAdjacentExpanded ? 1 : 0
170
- }
171
- // Resize panel
172
- var newFrame = self.frame
173
- let baseHeight = Self.panelHeight
174
- newFrame.size.height = self.isAdjacentExpanded
175
- ? baseHeight + Self.expandedExtraHeight
176
- : baseHeight
177
- self.frame = newFrame
178
- self.layoutIfNeeded()
179
- }
180
- }
181
-
182
- func subscribe(to subject: CurrentValueSubject<DebugMetrics, Never>) {
183
- cancellable = subject
184
- .receive(on: DispatchQueue.main)
185
- .sink { [weak self] metrics in
186
- self?.update(metrics)
187
- }
188
- }
189
-
190
- func subscribeAdjacent(
191
- prev: CurrentValueSubject<DebugMetrics, Never>,
192
- next: CurrentValueSubject<DebugMetrics, Never>
193
- ) {
194
- prevCancellable = prev
195
- .receive(on: DispatchQueue.main)
196
- .sink { [weak self] metrics in
197
- self?.updateAdjacent(metrics, slot: .previous)
198
- }
199
- nextCancellable = next
200
- .receive(on: DispatchQueue.main)
201
- .sink { [weak self] metrics in
202
- self?.updateAdjacent(metrics, slot: .next)
203
- }
204
- }
205
-
206
- private func updateAdjacent(_ m: DebugMetrics, slot: AdjacentSlot) {
207
- let headerLabel = slot == .previous ? prevHeaderLabel : nextHeaderLabel
208
- let detailLabel = slot == .previous ? prevDetailLabel : nextDetailLabel
209
- let arrow = slot == .previous ? "▲ Prev" : "▼ Next"
210
-
211
- guard m.contentId != nil else {
212
- headerLabel.text = "\(arrow) — not assigned"
213
- detailLabel.text = ""
214
- return
215
- }
216
-
217
- let id = m.contentId.map { String($0.prefix(8)) } ?? "—"
218
- let stateIcon: String
219
- switch m.playerState {
220
- case .playing: stateIcon = "▶"
221
- case .paused: stateIcon = "⏸"
222
- case .buffering: stateIcon = "⏳"
223
- default: stateIcon = "○"
224
- }
225
- headerLabel.text = "\(arrow) #\(m.feedIndex) \(id) \(stateIcon) \(m.playerState)"
226
-
227
- let buf = String(format: "%.1f", m.bufferAhead)
228
- let preroll = m.prerollCompleted ? "✓" : "✗"
229
- let mbps = m.observedBitrateBps / 1_000_000
230
- var detail = " buf: \(buf)s preroll: \(preroll)"
231
- if mbps > 0 {
232
- detail += String(format: " %.1f Mbps", mbps)
233
- }
234
- if m.videoWidth > 0 {
235
- let codec = m.codec ?? ""
236
- detail += " \(m.videoWidth)×\(m.videoHeight) \(codec)"
237
- }
238
- detailLabel.text = detail
239
- }
240
-
241
- private func update(_ m: DebugMetrics) {
242
- let id = m.contentId.map { String($0.prefix(8)) } ?? "—"
243
- let stateIcon: String
244
- switch m.playerState {
245
- case .playing: stateIcon = "▶"
246
- case .paused: stateIcon = "⏸"
247
- case .buffering: stateIcon = "⏳"
248
- case .seeking: stateIcon = "⏩"
249
- case .error: stateIcon = "⚠️"
250
- default: stateIcon = "○"
251
- }
252
- headerLabel.text = "#\(m.feedIndex) \(id) \(stateIcon) \(m.playerState)"
253
-
254
- let cur = String(format: "%.1f", m.currentTime)
255
- let dur = String(format: "%.1f", m.duration)
256
- positionLabel.text = "Position: \(cur)s / \(dur)s"
257
-
258
- if let intent = m.intentToFrameMs {
259
- intentTtffLabel.text = "Intent: \(intent)ms"
260
- } else {
261
- intentTtffLabel.text = "Intent: N/A"
262
- }
263
-
264
- if let transition = m.transitionToFrameMs {
265
- let net = m.networkMs.map { "\($0)" } ?? "?"
266
- let dec = m.decodeMs.map { "\($0)" } ?? "?"
267
- transitionTtffLabel.text = "Transition: \(transition)ms (net: \(net) + dec: \(dec))"
268
- } else {
269
- transitionTtffLabel.text = "Transition: measuring..."
270
- }
271
-
272
- let preroll = m.prerollCompleted ? "✓" : "✗"
273
- let buffer = String(format: "%.1f", m.bufferAhead)
274
- prerollBufferLabel.text = "Preroll: \(preroll) Buffer: \(buffer)s"
275
-
276
- let codec = m.codec ?? "—"
277
- if m.videoWidth > 0 {
278
- videoQualityLabel.text = "Video: \(m.videoWidth)×\(m.videoHeight) \(codec)"
279
- } else {
280
- videoQualityLabel.text = "Video: —"
281
- }
282
-
283
- thumbnailLabel.text = "Thumbnail: \(m.thumbnailVisible ? "visible" : "removed")"
284
- prefetchLabel.text = "Prefetch: \(m.thumbsCachedAhead)/\(m.metadataAhead) thumbs \(m.metadataAhead) meta"
285
-
286
- let obsMbps = m.observedBitrateBps / 1_000_000
287
- let indMbps = m.indicatedBitrateBps / 1_000_000
288
- if obsMbps > 0 {
289
- bitrateLabel.text = String(format: "Bitrate: %.1f Mbps (ind: %.1f)", obsMbps, indMbps)
290
- } else {
291
- bitrateLabel.text = "Bitrate: —"
292
- }
293
-
294
- if let lastMs = m.lastRebufferDurationMs {
295
- rebufferLabel.text = "Rebuffers: \(m.rebufferCount) (last: \(lastMs)ms)"
296
- } else {
297
- rebufferLabel.text = "Rebuffers: \(m.rebufferCount)"
298
- }
299
-
300
- loopWatchLabel.text = "Loops: \(m.loopCount)"
301
- }
302
- }