Files
MLBApp/mlbTVOS/Views/MultiStreamView.swift
2026-03-26 20:53:08 -05:00

1360 lines
52 KiB
Swift

import AVFoundation
import AVKit
import OSLog
import SwiftUI
private struct StreamSelection: Identifiable {
let id: String
}
private let multiViewLogger = Logger(subsystem: "com.treyt.mlbTVOS", category: "MultiView")
private func logMultiView(_ message: String) {
multiViewLogger.debug("\(message, privacy: .public)")
print("[MultiView] \(message)")
}
private func multiViewStatusDescription(_ status: AVPlayer.Status) -> String {
switch status {
case .unknown: "unknown"
case .readyToPlay: "readyToPlay"
case .failed: "failed"
@unknown default: "unknown-future"
}
}
private func multiViewItemStatusDescription(_ status: AVPlayerItem.Status) -> String {
switch status {
case .unknown: "unknown"
case .readyToPlay: "readyToPlay"
case .failed: "failed"
@unknown default: "unknown-future"
}
}
private func multiViewTimeControlDescription(_ status: AVPlayer.TimeControlStatus) -> String {
switch status {
case .paused: "paused"
case .waitingToPlayAtSpecifiedRate: "waitingToPlayAtSpecifiedRate"
case .playing: "playing"
@unknown default: "unknown-future"
}
}
private func multiViewHeaderKeysDescription(_ headers: [String: String]) -> String {
guard !headers.isEmpty else { return "none" }
return headers.keys.sorted().joined(separator: ",")
}
struct MultiStreamView: View {
@Environment(GamesViewModel.self) private var viewModel
@State private var selectedStream: StreamSelection?
@State private var showFullScreen = false
var body: some View {
ZStack(alignment: .bottom) {
LinearGradient(
colors: [
Color(red: 0.03, green: 0.04, blue: 0.08),
Color(red: 0.02, green: 0.05, blue: 0.1),
Color(red: 0.01, green: 0.02, blue: 0.05),
],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
.ignoresSafeArea()
if viewModel.activeStreams.isEmpty {
emptyState
} else {
VStack(spacing: 0) {
toolbar
.padding(.horizontal, 40)
.padding(.top, 26)
.padding(.bottom, 18)
MultiViewCanvas(
contentInsets: 18,
gap: 14,
videoGravity: .resizeAspectFill,
cornerRadius: 22,
onSelect: { stream in
selectedStream = StreamSelection(id: stream.id)
}
)
.padding(.horizontal, 18)
.padding(.bottom, 82)
}
ScoresTickerView()
.allowsHitTesting(false)
.padding(.horizontal, 18)
.padding(.bottom, 14)
}
}
.fullScreenCover(isPresented: $showFullScreen) {
MultiStreamFullScreenView()
}
.sheet(item: $selectedStream) { selection in
StreamControlSheet(
streamID: selection.id,
onRemove: {
viewModel.removeStream(id: selection.id)
selectedStream = nil
}
)
}
.onAppear {
viewModel.startAutoRefresh()
}
.onDisappear {
viewModel.stopAutoRefresh()
}
}
private var emptyState: some View {
VStack(spacing: 24) {
Image(systemName: "rectangle.split.2x2")
.font(.system(size: 82, weight: .light))
.foregroundStyle(.white.opacity(0.14))
Text("No Active Streams")
.font(.system(size: 30, weight: .bold, design: .rounded))
.foregroundStyle(.white.opacity(0.9))
Text("Add broadcasts from Games to build a quadbox, then pick the main tile and live audio here.")
.font(.system(size: 18, weight: .medium))
.foregroundStyle(.white.opacity(0.38))
.multilineTextAlignment(.center)
.frame(maxWidth: 560)
}
.padding(40)
}
private var toolbar: some View {
VStack(alignment: .leading, spacing: 18) {
HStack(alignment: .top, spacing: 24) {
VStack(alignment: .leading, spacing: 10) {
Text("Multi-View 2.0")
.font(.system(size: 32, weight: .bold, design: .rounded))
.foregroundStyle(.white)
Text("Choose the main tile, route audio to the game you want, and reorder feeds without leaving the grid.")
.font(.system(size: 15, weight: .medium))
.foregroundStyle(.white.opacity(0.58))
}
Spacer()
HStack(spacing: 10) {
infoChip(
title: "\(viewModel.activeStreams.count)/4",
icon: "rectangle.split.2x2",
tint: .white
)
infoChip(
title: viewModel.multiViewLayoutMode.title,
icon: viewModel.multiViewLayoutMode.systemImage,
tint: .blue
)
infoChip(
title: viewModel.activeAudioStream?.label ?? "All Muted",
icon: viewModel.activeAudioStream == nil ? "speaker.slash.fill" : "speaker.wave.2.fill",
tint: viewModel.activeAudioStream == nil ? .white : .green
)
}
}
HStack(spacing: 14) {
MultiViewLayoutPicker(compact: false)
Spacer()
Button {
showFullScreen = true
} label: {
toolbarButtonLabel(
title: "Full Screen",
icon: "arrow.up.left.and.arrow.down.right",
tint: .white,
destructive: false
)
}
.buttonStyle(.card)
Button {
viewModel.clearAllStreams()
} label: {
toolbarButtonLabel(
title: "Clear All",
icon: "xmark",
tint: .red,
destructive: true
)
}
.buttonStyle(.card)
}
}
}
private func infoChip(title: String, icon: String, tint: Color) -> some View {
HStack(spacing: 8) {
Image(systemName: icon)
.font(.system(size: 12, weight: .bold))
Text(title)
.font(.system(size: 14, weight: .semibold))
.lineLimit(1)
}
.foregroundStyle(tint.opacity(0.95))
.padding(.horizontal, 12)
.padding(.vertical, 9)
.background(
RoundedRectangle(cornerRadius: 999)
.fill(.white.opacity(0.08))
)
}
private func toolbarButtonLabel(title: String, icon: String, tint: Color, destructive: Bool) -> some View {
HStack(spacing: 8) {
Image(systemName: icon)
.font(.system(size: 13, weight: .bold))
Text(title)
.font(.system(size: 15, weight: .semibold))
}
.foregroundStyle(tint.opacity(0.9))
.padding(.horizontal, 18)
.padding(.vertical, 12)
.background(
RoundedRectangle(cornerRadius: 999)
.fill(destructive ? .red.opacity(0.12) : .white.opacity(0.08))
)
}
}
private struct MultiViewCanvas: View {
@Environment(GamesViewModel.self) private var viewModel
@FocusState private var focusedStreamID: String?
let contentInsets: CGFloat
let gap: CGFloat
let videoGravity: AVLayerVideoGravity
let cornerRadius: CGFloat
let onSelect: (ActiveStream) -> Void
var body: some View {
GeometryReader { geo in
let frames = multiViewFrames(
count: viewModel.activeStreams.count,
mode: viewModel.multiViewLayoutMode,
size: geo.size,
inset: contentInsets,
gap: gap
)
let focusEntries = Array(viewModel.activeStreams.enumerated()).compactMap { index, stream -> MultiViewFocusEntry? in
guard index < frames.count else { return nil }
return MultiViewFocusEntry(streamID: stream.id, frame: frames[index])
}
ZStack(alignment: .topLeading) {
ForEach(Array(viewModel.activeStreams.enumerated()), id: \.element.id) { index, stream in
if index < frames.count {
let frame = frames[index]
MultiStreamTile(
stream: stream,
position: index + 1,
isPrimary: viewModel.isPrimaryStream(stream.id),
isAudioFocused: viewModel.audioFocusStreamID == stream.id,
isFocused: focusedStreamID == stream.id,
showsPrimaryBadge: viewModel.multiViewLayoutMode == .spotlight
&& viewModel.isPrimaryStream(stream.id)
&& viewModel.activeStreams.count > 1,
videoGravity: videoGravity,
cornerRadius: cornerRadius,
onSelect: { onSelect(stream) }
)
.frame(width: frame.width, height: frame.height)
.position(x: frame.midX, y: frame.midY)
.focused($focusedStreamID, equals: stream.id)
}
}
}
.focusSection()
.onMoveCommand { direction in
let currentID = focusedStreamID ?? viewModel.audioFocusStreamID ?? viewModel.activeStreams.first?.id
if let nextID = nextMultiViewFocusID(
from: currentID,
direction: direction,
entries: focusEntries
) {
focusedStreamID = nextID
}
}
}
.onAppear {
if focusedStreamID == nil {
focusedStreamID = viewModel.audioFocusStreamID ?? viewModel.activeStreams.first?.id
}
}
.onChange(of: viewModel.activeStreams.map(\.id)) { _, streamIDs in
if let focusedStreamID, streamIDs.contains(focusedStreamID) {
return
}
self.focusedStreamID = viewModel.audioFocusStreamID ?? streamIDs.first
}
.onChange(of: focusedStreamID) { _, streamID in
guard let streamID else { return }
if viewModel.audioFocusStreamID != streamID {
viewModel.setAudioFocus(streamID: streamID)
}
}
}
}
private struct MultiStreamTile: View {
let stream: ActiveStream
let position: Int
let isPrimary: Bool
let isAudioFocused: Bool
let isFocused: Bool
let showsPrimaryBadge: Bool
let videoGravity: AVLayerVideoGravity
let cornerRadius: CGFloat
let onSelect: () -> Void
@Environment(GamesViewModel.self) private var viewModel
@State private var player: AVPlayer?
@State private var hasError = false
@State private var startupPlaybackTask: Task<Void, Never>?
@State private var qualityUpgradeTask: Task<Void, Never>?
@StateObject private var playbackDiagnostics = MultiStreamPlaybackDiagnostics()
var body: some View {
ZStack {
videoLayer
LinearGradient(
colors: [
.black.opacity(0.68),
.black.opacity(0.08),
.black.opacity(0.52),
],
startPoint: .top,
endPoint: .bottom
)
VStack(spacing: 0) {
HStack(alignment: .top, spacing: 10) {
HStack(spacing: 10) {
Text("\(position)")
.font(.system(size: 12, weight: .bold, design: .rounded))
.foregroundStyle(.white.opacity(0.95))
.frame(width: 24, height: 24)
.background(.black.opacity(0.58))
.clipShape(Circle())
Text(stream.label)
.font(.system(size: 16, weight: .bold, design: .rounded))
.foregroundStyle(.white)
.lineLimit(1)
}
.padding(.horizontal, 12)
.padding(.vertical, 9)
.background(.black.opacity(0.52))
.clipShape(RoundedRectangle(cornerRadius: 12))
Spacer()
HStack(spacing: 8) {
if showsPrimaryBadge {
tileBadge(title: "MAIN", color: .blue)
}
if isAudioFocused {
tileBadge(title: "AUDIO", color: .green)
} else {
tileBadge(title: "MUTED", color: .white.opacity(0.7))
}
}
}
Spacer()
HStack {
Spacer()
if let statusText = tileStatusText, !statusText.isEmpty {
Text(statusText)
.font(.system(size: 13, weight: .semibold, design: .rounded))
.foregroundStyle(.white.opacity(0.82))
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(.black.opacity(0.48))
.clipShape(Capsule())
}
}
}
.padding(14)
}
.background(.black)
.overlay(tileBorder)
.clipShape(RoundedRectangle(cornerRadius: cornerRadius))
.contentShape(RoundedRectangle(cornerRadius: cornerRadius))
.scaleEffect(isFocused ? 1.025 : 1.0)
.shadow(
color: isFocused ? .blue.opacity(0.22) : isAudioFocused ? .green.opacity(0.22) : .black.opacity(0.28),
radius: isFocused ? 26 : 20,
y: 10
)
.focusEffectDisabled()
.focusable(true)
.animation(.easeOut(duration: 0.18), value: isFocused)
.onAppear {
logMultiView("tile appeared id=\(stream.id) label=\(stream.label)")
}
.onDisappear {
logMultiView("tile disappeared id=\(stream.id) label=\(stream.label)")
startupPlaybackTask?.cancel()
startupPlaybackTask = nil
qualityUpgradeTask?.cancel()
qualityUpgradeTask = nil
playbackDiagnostics.clear(streamID: stream.id, reason: "tile disappeared")
}
.onPlayPauseCommand {
if player?.rate == 0 {
player?.play()
} else {
player?.pause()
}
}
.onTapGesture {
onSelect()
}
.task(id: stream.id) {
await startStream()
}
}
@ViewBuilder
private var videoLayer: some View {
if let player {
MultiStreamPlayerLayerView(player: player, streamID: stream.id, videoGravity: videoGravity)
.onAppear {
logMultiView("videoLayer showing player id=\(stream.id) rate=\(player.rate) muted=\(player.isMuted) gravity=\(videoGravity.rawValue)")
}
} else if hasError {
Color.black.overlay {
VStack(spacing: 10) {
Image(systemName: "exclamationmark.triangle.fill")
.font(.system(size: 28))
.foregroundStyle(.red.opacity(0.9))
Text("Stream unavailable")
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(.white.opacity(0.82))
}
}
} else {
Color.black.overlay {
VStack(spacing: 10) {
ProgressView()
Text("Loading feed")
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(.white.opacity(0.72))
}
}
}
}
private var tileBorder: some View {
RoundedRectangle(cornerRadius: cornerRadius)
.stroke(
isAudioFocused ? .green.opacity(0.95) : isFocused ? .blue.opacity(0.9) : isPrimary ? .white.opacity(0.28) : .white.opacity(0.12),
lineWidth: isAudioFocused || isFocused ? 3 : isPrimary ? 2 : 1
)
}
private var tileStatusText: String? {
if stream.game.isLive {
return stream.game.currentInningDisplay ?? stream.game.status.label
}
if stream.game.isFinal {
return "Final"
}
if stream.game.status.isScheduled {
return stream.game.startTime ?? stream.game.status.label
}
return stream.game.status.label
}
private func tileBadge(title: String, color: Color) -> some View {
Text(title)
.font(.system(size: 11, weight: .bold, design: .rounded))
.foregroundStyle(color.opacity(0.98))
.padding(.horizontal, 10)
.padding(.vertical, 6)
.background(.black.opacity(0.5))
.clipShape(Capsule())
}
private var multiViewStartupResolution: String { "504p" }
private var multiViewUpgradeTargetResolution: String? {
let desiredResolution = viewModel.defaultResolution
return desiredResolution == multiViewStartupResolution ? nil : desiredResolution
}
private func startStream() async {
logMultiView(
"startStream begin id=\(stream.id) label=\(stream.label) hasInlinePlayer=\(player != nil) hasSharedPlayer=\(stream.player != nil) hasOverrideURL=\(stream.overrideURL != nil) hasOverrideHeaders=\(stream.overrideHeaders != nil)"
)
if let player {
player.isMuted = viewModel.audioFocusStreamID != stream.id
playbackDiagnostics.attach(
to: player,
streamID: stream.id,
label: stream.label,
onPlaybackEnded: playbackEndedHandler(for: player)
)
scheduleStartupPlaybackRecovery(for: player)
scheduleQualityUpgrade(for: player)
logMultiView("startStream reused inline player id=\(stream.id) muted=\(player.isMuted)")
return
}
do {
try AVAudioSession.sharedInstance().setCategory(.playback)
try AVAudioSession.sharedInstance().setActive(true)
logMultiView("startStream audio session configured id=\(stream.id)")
} catch {
logMultiView("startStream audio session failed id=\(stream.id) error=\(error.localizedDescription)")
}
if let existingPlayer = stream.player {
existingPlayer.isMuted = viewModel.audioFocusStreamID != stream.id
self.player = existingPlayer
hasError = false
playbackDiagnostics.attach(
to: existingPlayer,
streamID: stream.id,
label: stream.label,
onPlaybackEnded: playbackEndedHandler(for: existingPlayer)
)
scheduleStartupPlaybackRecovery(for: existingPlayer)
scheduleQualityUpgrade(for: existingPlayer)
logMultiView("startStream reused shared player id=\(stream.id) muted=\(existingPlayer.isMuted)")
return
}
let url: URL?
if let overrideURL = stream.overrideURL {
url = overrideURL
logMultiView("startStream using override URL id=\(stream.id) url=\(overrideURL.absoluteString)")
} else {
url = await viewModel.resolveStreamURL(
for: stream,
resolutionOverride: multiViewStartupResolution,
preserveServerResolutionWhenBest: false
)
}
guard let url else {
hasError = true
logMultiView("startStream failed id=\(stream.id) resolveStreamURL returned nil")
return
}
let avPlayer = makePlayer(url: url, headers: stream.overrideHeaders)
avPlayer.automaticallyWaitsToMinimizeStalling = false
avPlayer.currentItem?.preferredForwardBufferDuration = 2
self.player = avPlayer
playbackDiagnostics.attach(
to: avPlayer,
streamID: stream.id,
label: stream.label,
onPlaybackEnded: playbackEndedHandler(for: avPlayer)
)
viewModel.attachPlayer(avPlayer, to: stream.id)
scheduleStartupPlaybackRecovery(for: avPlayer)
scheduleQualityUpgrade(for: avPlayer)
logMultiView("startStream attached player id=\(stream.id) muted=\(avPlayer.isMuted) startupResolution=\(multiViewStartupResolution) fastStart=true calling playImmediately(atRate: 1.0)")
avPlayer.playImmediately(atRate: 1.0)
}
private func makePlayer(url: URL, headers: [String: String]?) -> AVPlayer {
let headers = headers ?? [:]
logMultiView(
"startStream creating AVPlayer id=\(stream.id) url=\(url.absoluteString) headerKeys=\(multiViewHeaderKeysDescription(headers))"
)
let item = makePlayerItem(url: url, headers: headers)
return AVPlayer(playerItem: item)
}
private func makePlayerItem(url: URL, headers: [String: String]) -> AVPlayerItem {
if headers.isEmpty {
return AVPlayerItem(url: url)
}
let assetOptions: [String: Any] = [
"AVURLAssetHTTPHeaderFieldsKey": headers,
]
let asset = AVURLAsset(url: url, options: assetOptions)
return AVPlayerItem(asset: asset)
}
private func scheduleStartupPlaybackRecovery(for player: AVPlayer) {
startupPlaybackTask?.cancel()
let streamID = stream.id
let label = stream.label
startupPlaybackTask = Task { @MainActor in
let retryDelays: [Double] = [0.35, 1.0, 2.0, 4.0]
for delay in retryDelays {
try? await Task.sleep(for: .seconds(delay))
guard !Task.isCancelled else { return }
guard let currentPlayer = self.player, currentPlayer === player else {
logMultiView("startupRecovery abort id=\(streamID) label=\(label) reason=player-changed")
return
}
let itemStatus = multiViewItemStatusDescription(player.currentItem?.status ?? .unknown)
let likelyToKeepUp = player.currentItem?.isPlaybackLikelyToKeepUp ?? false
let bufferEmpty = player.currentItem?.isPlaybackBufferEmpty ?? false
let timeControl = multiViewTimeControlDescription(player.timeControlStatus)
let startupSatisfied = player.rate > 0 && (itemStatus == "readyToPlay" || likelyToKeepUp)
logMultiView(
"startupRecovery check id=\(streamID) delay=\(delay)s rate=\(player.rate) timeControl=\(timeControl) itemStatus=\(itemStatus) likelyToKeepUp=\(likelyToKeepUp) bufferEmpty=\(bufferEmpty)"
)
if startupSatisfied {
logMultiView("startupRecovery satisfied id=\(streamID) delay=\(delay)s")
return
}
if player.rate == 0 {
logMultiView("startupRecovery replay id=\(streamID) delay=\(delay)s")
player.playImmediately(atRate: 1.0)
}
}
}
}
private func scheduleQualityUpgrade(for player: AVPlayer) {
qualityUpgradeTask?.cancel()
guard stream.overrideURL == nil else {
logMultiView("qualityUpgrade skip id=\(stream.id) reason=override-url")
return
}
guard let targetResolution = multiViewUpgradeTargetResolution else {
logMultiView("qualityUpgrade skip id=\(stream.id) reason=target-already-\(multiViewStartupResolution)")
return
}
let streamID = stream.id
let label = stream.label
qualityUpgradeTask = Task { @MainActor in
let checkDelays: [Double] = [2.0, 4.0, 7.0]
for delay in checkDelays {
try? await Task.sleep(for: .seconds(delay))
guard !Task.isCancelled else { return }
guard let currentPlayer = self.player, currentPlayer === player else {
logMultiView("qualityUpgrade abort id=\(streamID) label=\(label) reason=player-changed")
return
}
let itemStatus = multiViewItemStatusDescription(player.currentItem?.status ?? .unknown)
let likelyToKeepUp = player.currentItem?.isPlaybackLikelyToKeepUp ?? false
let bufferEmpty = player.currentItem?.isPlaybackBufferEmpty ?? false
let currentResolution = currentStreamResolution(for: player) ?? "unknown"
let stable = (itemStatus == "readyToPlay" || likelyToKeepUp) && !bufferEmpty
logMultiView(
"qualityUpgrade check id=\(streamID) delay=\(delay)s currentResolution=\(currentResolution) targetResolution=\(targetResolution) stable=\(stable) rate=\(player.rate)"
)
guard stable else { continue }
if currentResolution == targetResolution {
logMultiView("qualityUpgrade skip id=\(streamID) reason=already-\(targetResolution)")
return
}
guard let upgradedURL = await viewModel.resolveStreamURL(
for: stream,
resolutionOverride: targetResolution,
preserveServerResolutionWhenBest: false
) else {
logMultiView("qualityUpgrade failed id=\(streamID) targetResolution=\(targetResolution) reason=resolve-nil")
return
}
if let currentURL = currentStreamURL(for: player), currentURL == upgradedURL {
logMultiView("qualityUpgrade skip id=\(streamID) reason=same-url targetResolution=\(targetResolution)")
return
}
logMultiView("qualityUpgrade begin id=\(streamID) targetResolution=\(targetResolution) url=\(upgradedURL.absoluteString)")
let upgradedItem = AVPlayerItem(url: upgradedURL)
upgradedItem.preferredForwardBufferDuration = 4
player.replaceCurrentItem(with: upgradedItem)
player.automaticallyWaitsToMinimizeStalling = false
playbackDiagnostics.attach(to: player, streamID: streamID, label: label)
viewModel.attachPlayer(player, to: streamID)
scheduleStartupPlaybackRecovery(for: player)
logMultiView("qualityUpgrade replay id=\(streamID) targetResolution=\(targetResolution)")
player.playImmediately(atRate: 1.0)
return
}
logMultiView("qualityUpgrade timeout id=\(streamID) targetResolution=\(targetResolution)")
}
}
private func currentStreamURL(for player: AVPlayer) -> URL? {
(player.currentItem?.asset as? AVURLAsset)?.url
}
private func currentStreamResolution(for player: AVPlayer) -> String? {
guard let url = currentStreamURL(for: player) else { return nil }
return URLComponents(url: url, resolvingAgainstBaseURL: false)?
.queryItems?
.first(where: { $0.name == "resolution" })?
.value
}
private func playbackEndedHandler(for player: AVPlayer) -> (() -> Void)? {
guard stream.id == SpecialPlaybackChannelConfig.werkoutNSFWStreamID else { return nil }
return {
Task { @MainActor in
await playNextWerkoutClip(on: player)
}
}
}
private func playNextWerkoutClip(on player: AVPlayer) async {
let currentURL = currentStreamURL(for: player)
logMultiView(
"playNextWerkoutClip begin id=\(stream.id) currentURL=\(currentURL?.absoluteString ?? "nil")"
)
guard let nextURL = await viewModel.resolveNextAuthenticatedFeedURLForActiveStream(
id: stream.id,
feedURL: SpecialPlaybackChannelConfig.werkoutNSFWFeedURL,
headers: SpecialPlaybackChannelConfig.werkoutNSFWHeaders
) else {
logMultiView("playNextWerkoutClip failed id=\(stream.id) reason=resolve-nil")
return
}
let nextItem = makePlayerItem(
url: nextURL,
headers: stream.overrideHeaders ?? SpecialPlaybackChannelConfig.werkoutNSFWHeaders
)
nextItem.preferredForwardBufferDuration = 2
player.replaceCurrentItem(with: nextItem)
player.automaticallyWaitsToMinimizeStalling = false
playbackDiagnostics.attach(
to: player,
streamID: stream.id,
label: stream.label,
onPlaybackEnded: playbackEndedHandler(for: player)
)
viewModel.attachPlayer(player, to: stream.id)
scheduleStartupPlaybackRecovery(for: player)
logMultiView("playNextWerkoutClip replay id=\(stream.id) url=\(nextURL.absoluteString)")
player.playImmediately(atRate: 1.0)
}
}
private struct MultiStreamPlayerLayerView: UIViewRepresentable {
let player: AVPlayer
let streamID: String
let videoGravity: AVLayerVideoGravity
func makeUIView(context: Context) -> MultiStreamPlayerLayerContainerView {
let view = MultiStreamPlayerLayerContainerView(videoGravity: videoGravity)
view.playerLayer.player = player
logMultiView("playerLayer makeUIView id=\(streamID) rate=\(player.rate) gravity=\(videoGravity.rawValue)")
return view
}
func updateUIView(_ uiView: MultiStreamPlayerLayerContainerView, context: Context) {
if uiView.playerLayer.player !== player {
uiView.playerLayer.player = player
logMultiView("playerLayer updateUIView reassigned player id=\(streamID) rate=\(player.rate)")
}
if uiView.playerLayer.videoGravity != videoGravity {
uiView.playerLayer.videoGravity = videoGravity
logMultiView("playerLayer updateUIView changed gravity id=\(streamID) gravity=\(videoGravity.rawValue)")
}
}
static func dismantleUIView(_ uiView: MultiStreamPlayerLayerContainerView, coordinator: ()) {
uiView.playerLayer.player = nil
}
}
private final class MultiStreamPlayerLayerContainerView: UIView {
override class var layerClass: AnyClass { AVPlayerLayer.self }
var playerLayer: AVPlayerLayer {
layer as! AVPlayerLayer
}
init(videoGravity: AVLayerVideoGravity) {
super.init(frame: .zero)
backgroundColor = .black
playerLayer.videoGravity = videoGravity
}
override init(frame: CGRect) {
super.init(frame: frame)
backgroundColor = .black
playerLayer.videoGravity = .resizeAspectFill
}
@available(*, unavailable)
required init?(coder: NSCoder) {
fatalError("init(coder:) has not been implemented")
}
}
private final class MultiStreamPlaybackDiagnostics: ObservableObject {
private var playerObservations: [NSKeyValueObservation] = []
private var notificationTokens: [NSObjectProtocol] = []
private var attachedPlayerIdentifier: ObjectIdentifier?
private var attachedItemIdentifier: ObjectIdentifier?
func attach(
to player: AVPlayer,
streamID: String,
label: String,
onPlaybackEnded: (() -> Void)? = nil
) {
let playerIdentifier = ObjectIdentifier(player)
let itemIdentifier = player.currentItem.map { ObjectIdentifier($0) }
if attachedPlayerIdentifier == playerIdentifier, attachedItemIdentifier == itemIdentifier {
return
}
clear(streamID: streamID, reason: "reattach")
attachedPlayerIdentifier = playerIdentifier
attachedItemIdentifier = itemIdentifier
logMultiView("diagnostics attach id=\(streamID) label=\(label) playerRate=\(player.rate)")
playerObservations.append(
player.observe(\.status, options: [.initial, .new]) { player, _ in
logMultiView("player status id=\(streamID) status=\(multiViewStatusDescription(player.status)) error=\(player.error?.localizedDescription ?? "nil")")
}
)
playerObservations.append(
player.observe(\.timeControlStatus, options: [.initial, .new]) { player, _ in
let reason = player.reasonForWaitingToPlay?.rawValue ?? "nil"
logMultiView("player timeControl id=\(streamID) status=\(multiViewTimeControlDescription(player.timeControlStatus)) reason=\(reason) rate=\(player.rate)")
}
)
playerObservations.append(
player.observe(\.reasonForWaitingToPlay, options: [.initial, .new]) { player, _ in
logMultiView("player waitingReason id=\(streamID) value=\(player.reasonForWaitingToPlay?.rawValue ?? "nil")")
}
)
guard let item = player.currentItem else {
logMultiView("diagnostics attach id=\(streamID) missing currentItem")
return
}
playerObservations.append(
item.observe(\.status, options: [.initial, .new]) { item, _ in
logMultiView("playerItem status id=\(streamID) status=\(multiViewItemStatusDescription(item.status)) error=\(item.error?.localizedDescription ?? "nil")")
}
)
playerObservations.append(
item.observe(\.isPlaybackBufferEmpty, options: [.initial, .new]) { item, _ in
logMultiView("playerItem bufferEmpty id=\(streamID) value=\(item.isPlaybackBufferEmpty)")
}
)
playerObservations.append(
item.observe(\.isPlaybackLikelyToKeepUp, options: [.initial, .new]) { item, _ in
logMultiView("playerItem likelyToKeepUp id=\(streamID) value=\(item.isPlaybackLikelyToKeepUp)")
}
)
playerObservations.append(
item.observe(\.isPlaybackBufferFull, options: [.initial, .new]) { item, _ in
logMultiView("playerItem bufferFull id=\(streamID) value=\(item.isPlaybackBufferFull)")
}
)
notificationTokens.append(
NotificationCenter.default.addObserver(
forName: .AVPlayerItemPlaybackStalled,
object: item,
queue: .main
) { _ in
logMultiView("playerItem stalled id=\(streamID)")
}
)
notificationTokens.append(
NotificationCenter.default.addObserver(
forName: .AVPlayerItemFailedToPlayToEndTime,
object: item,
queue: .main
) { notification in
let error = notification.userInfo?[AVPlayerItemFailedToPlayToEndTimeErrorKey] as? NSError
logMultiView("playerItem failedToEnd id=\(streamID) error=\(error?.localizedDescription ?? "nil")")
}
)
notificationTokens.append(
NotificationCenter.default.addObserver(
forName: .AVPlayerItemDidPlayToEndTime,
object: item,
queue: .main
) { _ in
logMultiView("playerItem didPlayToEnd id=\(streamID)")
onPlaybackEnded?()
}
)
notificationTokens.append(
NotificationCenter.default.addObserver(
forName: .AVPlayerItemNewErrorLogEntry,
object: item,
queue: .main
) { _ in
let event = item.errorLog()?.events.last
logMultiView("playerItem errorLog id=\(streamID) domain=\(event?.errorDomain ?? "nil") statusCode=\(event?.errorStatusCode ?? 0) comment=\(event?.errorComment ?? "nil")")
}
)
notificationTokens.append(
NotificationCenter.default.addObserver(
forName: .AVPlayerItemNewAccessLogEntry,
object: item,
queue: .main
) { _ in
if let event = item.accessLog()?.events.last {
logMultiView("playerItem accessLog id=\(streamID) indicatedBitrate=\(Int(event.indicatedBitrate)) observedBitrate=\(Int(event.observedBitrate)) transferDuration=\(event.transferDuration)")
} else {
logMultiView("playerItem accessLog id=\(streamID) missing event")
}
}
)
}
func clear(streamID: String, reason: String) {
if !playerObservations.isEmpty || !notificationTokens.isEmpty {
logMultiView("diagnostics clear id=\(streamID) reason=\(reason)")
}
playerObservations.removeAll()
for token in notificationTokens {
NotificationCenter.default.removeObserver(token)
}
notificationTokens.removeAll()
attachedPlayerIdentifier = nil
attachedItemIdentifier = nil
}
deinit {
playerObservations.removeAll()
for token in notificationTokens {
NotificationCenter.default.removeObserver(token)
}
notificationTokens.removeAll()
}
}
private struct MultiViewLayoutPicker: View {
@Environment(GamesViewModel.self) private var viewModel
let compact: Bool
var body: some View {
HStack(spacing: compact ? 8 : 10) {
ForEach(MultiViewLayoutMode.allCases) { mode in
Button {
viewModel.multiViewLayoutMode = mode
} label: {
HStack(spacing: 8) {
Image(systemName: mode.systemImage)
.font(.system(size: compact ? 11 : 12, weight: .bold))
Text(mode.title)
.font(.system(size: compact ? 13 : 14, weight: .semibold))
}
.foregroundStyle(viewModel.multiViewLayoutMode == mode ? .white : .white.opacity(0.62))
.padding(.horizontal, compact ? 12 : 14)
.padding(.vertical, compact ? 9 : 10)
.background(
RoundedRectangle(cornerRadius: 999)
.fill(viewModel.multiViewLayoutMode == mode ? .blue.opacity(0.28) : .white.opacity(0.06))
)
.overlay(
RoundedRectangle(cornerRadius: 999)
.stroke(viewModel.multiViewLayoutMode == mode ? .blue.opacity(0.9) : .white.opacity(0.12), lineWidth: 1)
)
}
.buttonStyle(.card)
}
}
}
}
struct StreamControlSheet: View {
let streamID: String
let onRemove: () -> Void
@Environment(GamesViewModel.self) private var viewModel
@Environment(\.dismiss) private var dismiss
private var stream: ActiveStream? {
viewModel.activeStreams.first { $0.id == streamID }
}
private var isPrimary: Bool {
viewModel.isPrimaryStream(streamID)
}
private var isAudioFocused: Bool {
viewModel.audioFocusStreamID == streamID
}
var body: some View {
ZStack {
LinearGradient(
colors: [
Color(red: 0.05, green: 0.06, blue: 0.1),
Color(red: 0.03, green: 0.04, blue: 0.08),
],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
.ignoresSafeArea()
if let stream {
VStack(alignment: .leading, spacing: 26) {
VStack(alignment: .leading, spacing: 10) {
Text(stream.label)
.font(.system(size: 30, weight: .bold, design: .rounded))
.foregroundStyle(.white)
Text(stream.game.displayTitle)
.font(.system(size: 17, weight: .medium))
.foregroundStyle(.white.opacity(0.6))
HStack(spacing: 10) {
controlBadge(title: isPrimary ? "Primary Tile" : "Secondary Tile", tint: .blue)
controlBadge(title: isAudioFocused ? "Live Audio" : "Muted", tint: isAudioFocused ? .green : .white)
}
}
VStack(spacing: 14) {
HStack(spacing: 14) {
actionCard(
title: isAudioFocused ? "Mute All" : "Listen Here",
subtitle: isAudioFocused ? "Silence the multiview mix." : "Route game audio to this tile.",
icon: isAudioFocused ? "speaker.slash.fill" : "speaker.wave.2.fill",
tint: isAudioFocused ? .white : .green,
disabled: false
) {
viewModel.toggleAudioFocus(streamID: streamID)
}
actionCard(
title: isPrimary ? "Already Main" : "Make Main Tile",
subtitle: "Spotlight layout always favors the first stream.",
icon: "arrow.up.left.and.arrow.down.right",
tint: .blue,
disabled: isPrimary
) {
viewModel.promoteStream(id: streamID)
}
}
HStack(spacing: 14) {
actionCard(
title: "Move Earlier",
subtitle: "Shift this feed toward the front of the order.",
icon: "arrow.left",
tint: .white,
disabled: !viewModel.canMoveStream(id: streamID, direction: -1)
) {
viewModel.moveStream(id: streamID, direction: -1)
}
actionCard(
title: "Move Later",
subtitle: "Shift this feed deeper into the stack.",
icon: "arrow.right",
tint: .white,
disabled: !viewModel.canMoveStream(id: streamID, direction: 1)
) {
viewModel.moveStream(id: streamID, direction: 1)
}
}
actionCard(
title: "Remove from Multi-View",
subtitle: "Close this feed and free one slot immediately.",
icon: "trash.fill",
tint: .red,
disabled: false,
destructive: true,
wide: true
) {
onRemove()
}
}
Spacer()
}
.padding(44)
} else {
VStack(spacing: 18) {
Text("Stream Removed")
.font(.system(size: 28, weight: .bold, design: .rounded))
.foregroundStyle(.white)
Text("This feed is no longer active.")
.font(.system(size: 17, weight: .medium))
.foregroundStyle(.white.opacity(0.6))
}
.padding(40)
}
}
}
private func controlBadge(title: String, tint: Color) -> some View {
Text(title)
.font(.system(size: 13, weight: .semibold))
.foregroundStyle(tint.opacity(0.95))
.padding(.horizontal, 12)
.padding(.vertical, 8)
.background(.white.opacity(0.08))
.clipShape(Capsule())
}
private func actionCard(
title: String,
subtitle: String,
icon: String,
tint: Color,
disabled: Bool,
destructive: Bool = false,
wide: Bool = false,
action: @escaping () -> Void
) -> some View {
Button {
action()
dismiss()
} label: {
VStack(alignment: .leading, spacing: 14) {
Image(systemName: icon)
.font(.system(size: 22, weight: .bold))
.foregroundStyle(disabled ? .white.opacity(0.28) : tint.opacity(0.95))
VStack(alignment: .leading, spacing: 6) {
Text(title)
.font(.system(size: 20, weight: .bold, design: .rounded))
.foregroundStyle(disabled ? .white.opacity(0.32) : .white)
Text(subtitle)
.font(.system(size: 14, weight: .medium))
.foregroundStyle(.white.opacity(disabled ? 0.2 : 0.5))
}
}
.frame(maxWidth: .infinity, minHeight: wide ? 108 : 168, alignment: .leading)
.padding(22)
.background(
RoundedRectangle(cornerRadius: 22)
.fill(destructive ? .red.opacity(0.12) : .white.opacity(0.06))
)
.overlay(
RoundedRectangle(cornerRadius: 22)
.stroke(disabled ? .white.opacity(0.08) : tint.opacity(0.28), lineWidth: 1)
)
}
.disabled(disabled)
.buttonStyle(.card)
}
}
struct MultiStreamFullScreenView: View {
@Environment(GamesViewModel.self) private var viewModel
@Environment(\.dismiss) private var dismiss
@State private var selectedStream: StreamSelection?
var body: some View {
ZStack(alignment: .bottom) {
Color.black.ignoresSafeArea()
MultiViewCanvas(
contentInsets: 10,
gap: 10,
videoGravity: .resizeAspect,
cornerRadius: 12,
onSelect: { stream in
selectedStream = StreamSelection(id: stream.id)
}
)
.ignoresSafeArea()
ScoresTickerView()
.allowsHitTesting(false)
.padding(.horizontal, 18)
.padding(.bottom, 12)
}
.onExitCommand {
dismiss()
}
.onChange(of: viewModel.activeStreams.count) { _, count in
if count == 0 {
dismiss()
}
}
.sheet(item: $selectedStream) { selection in
StreamControlSheet(
streamID: selection.id,
onRemove: {
viewModel.removeStream(id: selection.id)
selectedStream = nil
if viewModel.activeStreams.isEmpty {
dismiss()
}
}
)
}
.onAppear {
viewModel.startAutoRefresh()
}
}
}
private func multiViewFrames(
count: Int,
mode: MultiViewLayoutMode,
size: CGSize,
inset: CGFloat,
gap: CGFloat
) -> [CGRect] {
let width = max(size.width - (inset * 2), 0)
let height = max(size.height - (inset * 2), 0)
switch (mode, count) {
case (_, 0):
return []
case (_, 1):
return [CGRect(x: inset, y: inset, width: width, height: height)]
case (.spotlight, 2):
let primaryWidth = width * 0.66
let secondaryWidth = width - primaryWidth - gap
return [
CGRect(x: inset, y: inset, width: primaryWidth, height: height),
CGRect(x: inset + primaryWidth + gap, y: inset, width: secondaryWidth, height: height),
]
case (.spotlight, 3):
let primaryWidth = width * 0.66
let railWidth = width - primaryWidth - gap
let railHeight = (height - gap) / 2
return [
CGRect(x: inset, y: inset, width: primaryWidth, height: height),
CGRect(x: inset + primaryWidth + gap, y: inset, width: railWidth, height: railHeight),
CGRect(x: inset + primaryWidth + gap, y: inset + railHeight + gap, width: railWidth, height: railHeight),
]
case (.spotlight, _):
let primaryWidth = width * 0.68
let railWidth = width - primaryWidth - gap
let railHeight = (height - (gap * 2)) / 3
return [
CGRect(x: inset, y: inset, width: primaryWidth, height: height),
CGRect(x: inset + primaryWidth + gap, y: inset, width: railWidth, height: railHeight),
CGRect(x: inset + primaryWidth + gap, y: inset + railHeight + gap, width: railWidth, height: railHeight),
CGRect(x: inset + primaryWidth + gap, y: inset + (railHeight * 2) + (gap * 2), width: railWidth, height: railHeight),
]
case (.balanced, 2):
let cellWidth = (width - gap) / 2
return [
CGRect(x: inset, y: inset, width: cellWidth, height: height),
CGRect(x: inset + cellWidth + gap, y: inset, width: cellWidth, height: height),
]
case (.balanced, 3):
let cellWidth = (width - gap) / 2
let cellHeight = (height - gap) / 2
return [
CGRect(x: inset, y: inset, width: cellWidth, height: cellHeight),
CGRect(x: inset + cellWidth + gap, y: inset, width: cellWidth, height: cellHeight),
CGRect(x: inset, y: inset + cellHeight + gap, width: width, height: cellHeight),
]
default:
let cellWidth = (width - gap) / 2
let cellHeight = (height - gap) / 2
return [
CGRect(x: inset, y: inset, width: cellWidth, height: cellHeight),
CGRect(x: inset + cellWidth + gap, y: inset, width: cellWidth, height: cellHeight),
CGRect(x: inset, y: inset + cellHeight + gap, width: cellWidth, height: cellHeight),
CGRect(x: inset + cellWidth + gap, y: inset + cellHeight + gap, width: cellWidth, height: cellHeight),
]
}
}
private struct MultiViewFocusEntry {
let streamID: String
let frame: CGRect
}
private func nextMultiViewFocusID(
from currentID: String?,
direction: MoveCommandDirection,
entries: [MultiViewFocusEntry]
) -> String? {
guard !entries.isEmpty else { return nil }
guard
let currentID,
let currentEntry = entries.first(where: { $0.streamID == currentID })
else {
return entries.first?.streamID
}
let candidates: [(entry: MultiViewFocusEntry, primaryDistance: CGFloat, secondaryDistance: CGFloat)] = entries.compactMap { candidate in
guard candidate.streamID != currentEntry.streamID else { return nil }
let xDelta = candidate.frame.midX - currentEntry.frame.midX
let yDelta = candidate.frame.midY - currentEntry.frame.midY
switch direction {
case .left:
guard xDelta < 0 else { return nil }
return (candidate, abs(xDelta), abs(yDelta))
case .right:
guard xDelta > 0 else { return nil }
return (candidate, abs(xDelta), abs(yDelta))
case .up:
guard yDelta < 0 else { return nil }
return (candidate, abs(yDelta), abs(xDelta))
case .down:
guard yDelta > 0 else { return nil }
return (candidate, abs(yDelta), abs(xDelta))
default:
return nil
}
}
return candidates
.sorted { lhs, rhs in
if abs(lhs.primaryDistance - rhs.primaryDistance) > 1 {
return lhs.primaryDistance < rhs.primaryDistance
}
return lhs.secondaryDistance < rhs.secondaryDistance
}
.first?
.entry
.streamID
}