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 ) } .platformCardStyle() Button { viewModel.clearAllStreams() } label: { toolbarButtonLabel( title: "Clear All", icon: "xmark", tint: .red, destructive: true ) } .platformCardStyle() } } } 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 #if os(tvOS) @FocusState private var focusedStreamID: String? #endif 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 ) #if os(tvOS) 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]) } #endif ZStack(alignment: .topLeading) { ForEach(Array(viewModel.activeStreams.enumerated()), id: \.element.id) { index, stream in if index < frames.count { let frame = frames[index] tileView(for: stream, frame: frame, position: index + 1) } } } .platformFocusSection() #if os(tvOS) .onMoveCommand { direction in let currentID = focusedStreamID ?? viewModel.audioFocusStreamID ?? viewModel.activeStreams.first?.id if let nextID = nextMultiViewFocusID( from: currentID, direction: direction, entries: focusEntries ) { focusedStreamID = nextID } } #endif } #if os(tvOS) .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) } } #endif } @ViewBuilder private func tileView(for stream: ActiveStream, frame: CGRect, position: Int) -> some View { let tile = MultiStreamTile( stream: stream, position: position, isPrimary: viewModel.isPrimaryStream(stream.id), isAudioFocused: viewModel.audioFocusStreamID == stream.id, isFocused: { #if os(tvOS) focusedStreamID == stream.id #else false #endif }(), showsPrimaryBadge: viewModel.multiViewLayoutMode == .spotlight && viewModel.isPrimaryStream(stream.id) && viewModel.activeStreams.count > 1, videoGravity: videoGravity, cornerRadius: cornerRadius, onSelect: { onSelect(stream) } ) #if os(tvOS) tile .frame(width: frame.width, height: frame.height) .position(x: frame.midX, y: frame.midY) .focused($focusedStreamID, equals: stream.id) #else tile .frame(width: frame.width, height: frame.height) .position(x: frame.midX, y: frame.midY) #endif } } 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? @State private var qualityUpgradeTask: Task? @State private var clipTimeLimitObserver: Any? @State private var isAdvancingClip = false @StateObject private var playbackDiagnostics = MultiStreamPlaybackDiagnostics() private static let maxClipDuration: Double = 15.0 private static var audioSessionConfigured = false 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 ) .animation(.easeOut(duration: 0.18), value: isFocused) .platformFocusable() .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 if let player { removeClipTimeLimit(from: player) } playbackDiagnostics.clear(streamID: stream.id, reason: "tile disappeared") } #if os(tvOS) .focusEffectDisabled() .onPlayPauseCommand { if player?.rate == 0 { player?.play() } else { player?.pause() } } #endif .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 { playbackDiagnostics.attach( to: player, streamID: stream.id, label: stream.label, onPlaybackEnded: playbackEndedHandler(for: player) ) scheduleStartupPlaybackRecovery(for: player) scheduleQualityUpgrade(for: player) installClipTimeLimit(on: player) logMultiView("startStream reused inline player id=\(stream.id) muted=\(player.isMuted)") return } if !Self.audioSessionConfigured { do { try AVAudioSession.sharedInstance().setCategory(.playback) try AVAudioSession.sharedInstance().setActive(true) Self.audioSessionConfigured = 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 { 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) installClipTimeLimit(on: 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) installClipTimeLimit(on: avPlayer) } 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 installClipTimeLimit(on player: AVPlayer) { removeClipTimeLimit(from: player) guard stream.id == SpecialPlaybackChannelConfig.werkoutNSFWStreamID else { return } let limit = CMTime(seconds: Self.maxClipDuration, preferredTimescale: 600) logMultiView("installClipTimeLimit id=\(stream.id) limit=\(Self.maxClipDuration)s") clipTimeLimitObserver = player.addBoundaryTimeObserver( forTimes: [NSValue(time: limit)], queue: .main ) { [weak player] in guard let player else { logMultiView("clipTimeLimit STOPPED id=\(stream.id) reason=player-deallocated") return } let currentTime = CMTimeGetSeconds(player.currentTime()) logMultiView("clipTimeLimit fired id=\(stream.id) currentTime=\(String(format: "%.1f", currentTime))s rate=\(player.rate) — advancing") Task { @MainActor in await playNextWerkoutClip(on: player) } } } private func removeClipTimeLimit(from player: AVPlayer) { if let observer = clipTimeLimitObserver { player.removeTimeObserver(observer) clipTimeLimitObserver = nil } } private func playbackEndedHandler(for player: AVPlayer) -> (@MainActor @Sendable () async -> Void)? { guard stream.id == SpecialPlaybackChannelConfig.werkoutNSFWStreamID else { return nil } return { let currentTime = CMTimeGetSeconds(player.currentTime()) logMultiView("playbackEnded (didPlayToEnd) id=\(stream.id) currentTime=\(String(format: "%.1f", currentTime))s rate=\(player.rate)") await playNextWerkoutClip(on: player) } } private func playNextWerkoutClip(on player: AVPlayer) async { guard !isAdvancingClip else { logMultiView("playNextWerkoutClip SKIPPED id=\(stream.id) reason=already-advancing") return } isAdvancingClip = true defer { isAdvancingClip = false } let currentURL = currentStreamURL(for: player) let playerRate = player.rate let playerStatus = player.status.rawValue let itemStatus = player.currentItem?.status.rawValue ?? -1 let timeControl = player.timeControlStatus.rawValue logMultiView( "playNextWerkoutClip begin id=\(stream.id) currentURL=\(currentURL?.absoluteString ?? "nil") playerRate=\(playerRate) playerStatus=\(playerStatus) itemStatus=\(itemStatus) timeControl=\(timeControl)" ) let resolveStart = Date() guard let nextURL = await viewModel.resolveNextAuthenticatedFeedURLForActiveStream( id: stream.id, feedURL: SpecialPlaybackChannelConfig.werkoutNSFWFeedURL, headers: SpecialPlaybackChannelConfig.werkoutNSFWHeaders, maxRetries: 3 ) else { let elapsedMs = Int(Date().timeIntervalSince(resolveStart) * 1000) logMultiView("playNextWerkoutClip STOPPED id=\(stream.id) reason=resolve-nil-after-retries elapsedMs=\(elapsedMs)") return } let resolveMs = Int(Date().timeIntervalSince(resolveStart) * 1000) logMultiView("playNextWerkoutClip resolved id=\(stream.id) resolveMs=\(resolveMs) nextURL=\(nextURL.lastPathComponent)") 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) ) scheduleStartupPlaybackRecovery(for: player) logMultiView("playNextWerkoutClip replay id=\(stream.id) url=\(nextURL.lastPathComponent)") player.playImmediately(atRate: 1.0) installClipTimeLimit(on: player) // Monitor for failure and auto-skip to next clip Task { @MainActor in for checkDelay in [1.0, 3.0] { try? await Task.sleep(for: .seconds(checkDelay)) let postItemStatus = player.currentItem?.status let error = player.currentItem?.error?.localizedDescription ?? "nil" logMultiView( "playNextWerkoutClip postCheck id=\(stream.id) delay=\(checkDelay)s rate=\(player.rate) itemStatus=\(postItemStatus?.rawValue ?? -1) error=\(error)" ) if postItemStatus == .failed { logMultiView("playNextWerkoutClip AUTO-SKIP id=\(stream.id) reason=item-failed error=\(error)") await playNextWerkoutClip(on: player) return } } } } } 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: (@MainActor @Sendable () async -> 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)") guard let onPlaybackEnded else { return } Task { @MainActor in await 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) ) } .platformCardStyle() } } } } 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 } private var forceMuteAudio: Bool { stream?.forceMuteAudio == true } 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: forceMuteAudio ? "Video Only" : (isAudioFocused ? "Live Audio" : "Muted"), tint: forceMuteAudio ? .orange : (isAudioFocused ? .green : .white) ) } } VStack(spacing: 14) { HStack(spacing: 14) { actionCard( title: forceMuteAudio ? "Audio Disabled" : (isAudioFocused ? "Mute All" : "Listen Here"), subtitle: forceMuteAudio ? "This channel is always muted." : (isAudioFocused ? "Silence the multiview mix." : "Route game audio to this tile."), icon: forceMuteAudio ? "speaker.slash.circle.fill" : (isAudioFocused ? "speaker.slash.fill" : "speaker.wave.2.fill"), tint: forceMuteAudio ? .orange : (isAudioFocused ? .white : .green), disabled: forceMuteAudio ) { 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) .platformCardStyle() } } 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) } .overlay(alignment: .topTrailing) { #if os(iOS) Button { dismiss() } label: { Image(systemName: "xmark.circle.fill") .font(.system(size: 28, weight: .bold)) .foregroundStyle(.white.opacity(0.9)) .padding(20) } #endif } #if os(tvOS) .onExitCommand { dismiss() } #endif .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 } #if os(tvOS) 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 } #endif