SingleStream: pass preserveServerResolutionWhenBest=false so "best" always reaches the server for a full multi-variant manifest. Increase buffer to 8s and enable automaticallyWaitsToMinimizeStalling so AVPlayer can measure bandwidth and select higher variants. Add quality monitor that nudges AVPlayer if observed bandwidth far exceeds indicated bitrate. MultiStream: remove broken URL-param resolution detection that falsely skipped upgrades, log actual indicatedBitrate instead. Extend upgrade check windows from [2,4,7]s to [2,4,7,15,30]s for slow-to-stabilize streams. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1484 lines
57 KiB
Swift
1484 lines
57 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
|
|
)
|
|
}
|
|
.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<Void, Never>?
|
|
@State private var qualityUpgradeTask: Task<Void, Never>?
|
|
@State private var werkoutMonitorTask: Task<Void, Never>?
|
|
@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
|
|
werkoutMonitorTask?.cancel()
|
|
werkoutMonitorTask = nil
|
|
if let player {
|
|
removeClipTimeLimit(from: player)
|
|
player.pause()
|
|
}
|
|
player = nil
|
|
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, mode: .moviePlayback)
|
|
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
|
|
// Set mute state BEFORE playback to prevent audio spikes
|
|
viewModel.attachPlayer(avPlayer, to: stream.id)
|
|
playbackDiagnostics.attach(
|
|
to: avPlayer,
|
|
streamID: stream.id,
|
|
label: stream.label,
|
|
onPlaybackEnded: playbackEndedHandler(for: avPlayer)
|
|
)
|
|
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, 15.0, 30.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 indicatedBitrate = player.currentItem?.accessLog()?.events.last?.indicatedBitrate ?? 0
|
|
let stable = (itemStatus == "readyToPlay" || likelyToKeepUp) && !bufferEmpty
|
|
|
|
logMultiView(
|
|
"qualityUpgrade check id=\(streamID) delay=\(delay)s targetResolution=\(targetResolution) stable=\(stable) rate=\(player.rate) indicatedBitrate=\(Int(indicatedBitrate))"
|
|
)
|
|
|
|
guard stable else { continue }
|
|
|
|
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 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
|
|
werkoutMonitorTask?.cancel()
|
|
werkoutMonitorTask = Task { @MainActor in
|
|
for checkDelay in [1.0, 3.0] {
|
|
try? await Task.sleep(for: .seconds(checkDelay))
|
|
guard !Task.isCancelled else { return }
|
|
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
|