Add game center, per-model shuffle, audio focus fixes, README, tests

- README.md with build/architecture overview
- Game Center screen with at-bat timeline, pitch sequence, spray chart,
  and strike zone component views
- VideoShuffle service: per-model bucketed random selection with
  no-back-to-back guarantee; replaces flat shuffle-bag approach
- Refresh JWT token for authenticated NSFW feed; add josie-hamming-2
  and dani-speegle-2 to the user list
- MultiStreamView audio focus: remove redundant isMuted writes during
  startStream and playNextWerkoutClip so audio stops ducking during
  clip transitions; gate AVAudioSession.setCategory(.playback) behind
  a one-shot flag
- GamesViewModel.attachPlayer: skip mute recalculation when the same
  player is re-attached (prevents toggle flicker on item replace)
- mlbTVOSTests target wired through project.yml with
  GENERATE_INFOPLIST_FILE; VideoShuffleTests covers groupByModel,
  pickRandomFromBuckets, real-distribution no-back-to-back invariant,
  and uniform model distribution over 6000 picks

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-04-11 11:02:46 -05:00
parent 58e4c36963
commit 88308b46f5
17 changed files with 2069 additions and 313 deletions

View File

@@ -0,0 +1,80 @@
import SwiftUI
struct AtBatTimelineView: View {
let pitches: [LiveFeedPlayEvent]
var body: some View {
VStack(alignment: .leading, spacing: 10) {
Text("AT-BAT")
.font(.system(size: 12, weight: .bold, design: .rounded))
.foregroundStyle(.white.opacity(0.5))
.kerning(1.2)
HStack(spacing: 6) {
ForEach(Array(pitches.enumerated()), id: \.offset) { index, pitch in
let isLatest = index == pitches.count - 1
let code = pitchCallLetter(pitch.callCode)
let color = pitchCallColor(pitch.callCode)
Text(code)
.font(.system(size: isLatest ? 14 : 13, weight: .black, design: .rounded))
.foregroundStyle(color)
.padding(.horizontal, isLatest ? 12 : 10)
.padding(.vertical, isLatest ? 7 : 6)
.background(color.opacity(isLatest ? 0.25 : 0.15))
.clipShape(Capsule())
.overlay {
if isLatest {
Capsule()
.strokeBorder(color.opacity(0.5), lineWidth: 1)
}
}
}
}
if let last = pitches.last?.count {
Text("\(last.balls)-\(last.strikes), \(last.outs) out\(last.outs == 1 ? "" : "s")")
.font(.system(size: 14, weight: .semibold, design: .rounded))
.foregroundStyle(.white.opacity(0.6))
}
}
}
}
func pitchCallLetter(_ code: String) -> String {
switch code {
case "B": return "B"
case "C": return "S"
case "S": return "S"
case "F": return "F"
case "X", "D", "E": return "X"
default: return "·"
}
}
func pitchCallColor(_ code: String) -> Color {
switch code {
case "B": return .green
case "C": return .red
case "S": return .orange
case "F": return .yellow
case "X", "D", "E": return .blue
default: return .white.opacity(0.5)
}
}
func shortPitchType(_ code: String) -> String {
switch code {
case "FF": return "4SM"
case "SI": return "SNK"
case "FC": return "CUT"
case "SL": return "SLD"
case "CU", "KC": return "CRV"
case "CH": return "CHG"
case "FS": return "SPL"
case "KN": return "KNK"
case "ST": return "SWP"
case "SV": return "SLV"
default: return code
}
}

View File

@@ -0,0 +1,68 @@
import SwiftUI
struct PitchSequenceView: View {
let pitches: [LiveFeedPlayEvent]
var body: some View {
VStack(alignment: .leading, spacing: 8) {
Text("PITCH SEQUENCE")
.font(.system(size: 12, weight: .bold, design: .rounded))
.foregroundStyle(.white.opacity(0.5))
.kerning(1.2)
ForEach(Array(pitches.enumerated()), id: \.offset) { index, pitch in
HStack(spacing: 10) {
Text("#\(pitch.pitchNumber ?? (index + 1))")
.font(.system(size: 13, weight: .bold, design: .rounded))
.foregroundStyle(.white.opacity(0.5))
.frame(width: 28, alignment: .leading)
Text(pitch.pitchTypeDescription)
.font(.system(size: 14, weight: .semibold, design: .rounded))
.foregroundStyle(.white)
.lineLimit(1)
Spacer()
if let speed = pitch.speedMPH {
Text("\(speed, specifier: "%.1f") mph")
.font(.system(size: 13, weight: .medium).monospacedDigit())
.foregroundStyle(.white.opacity(0.7))
}
pitchResultPill(code: pitch.callCode, description: pitch.callDescription)
}
.padding(.vertical, 4)
.padding(.horizontal, 8)
.background(
index == pitches.count - 1
? RoundedRectangle(cornerRadius: 8, style: .continuous).fill(.white.opacity(0.06))
: RoundedRectangle(cornerRadius: 8, style: .continuous).fill(.clear)
)
}
}
}
private func pitchResultPill(code: String, description: String) -> some View {
let color = pitchCallColor(code)
let label: String
switch code {
case "B": label = "Ball"
case "C": label = "Called Strike"
case "S": label = "Swinging Strike"
case "F": label = "Foul"
case "X": label = "In Play"
case "D": label = "In Play (Out)"
case "E": label = "In Play (Error)"
default: label = description
}
return Text(label)
.font(.system(size: 11, weight: .bold, design: .rounded))
.foregroundStyle(color)
.padding(.horizontal, 8)
.padding(.vertical, 4)
.background(color.opacity(0.15))
.clipShape(Capsule())
}
}

View File

@@ -0,0 +1,126 @@
import SwiftUI
struct SprayChartHit: Identifiable {
let id: String
let coordX: Double
let coordY: Double
let event: String?
var color: Color {
switch event?.lowercased() {
case "single": return .blue
case "double": return .green
case "triple": return .orange
case "home_run", "home run": return .red
default: return .white.opacity(0.4)
}
}
var label: String {
switch event?.lowercased() {
case "single": return "1B"
case "double": return "2B"
case "triple": return "3B"
case "home_run", "home run": return "HR"
default: return "Out"
}
}
}
struct SprayChartView: View {
let hits: [SprayChartHit]
var size: CGFloat = 280
// MLB API coordinate system: home plate ~(125, 200), field extends upward
// coordX: 0-250 (left to right), coordY: 0-250 (top to bottom, lower Y = deeper)
private let coordRange: ClosedRange<Double> = 0...250
private let homePlate = CGPoint(x: 125, y: 200)
var body: some View {
Canvas { context, canvasSize in
let w = canvasSize.width
let h = canvasSize.height
let scale = min(w, h) / 250.0
let homeX = homePlate.x * scale
let homeY = homePlate.y * scale
// Draw foul lines from home plate
let foulLineLength: CGFloat = 200 * scale
// Left field foul line (~135 degrees from horizontal)
let leftEnd = CGPoint(
x: homeX - foulLineLength * cos(.pi / 4),
y: homeY - foulLineLength * sin(.pi / 4)
)
var leftLine = Path()
leftLine.move(to: CGPoint(x: homeX, y: homeY))
leftLine.addLine(to: leftEnd)
context.stroke(leftLine, with: .color(.white.opacity(0.15)), lineWidth: 1)
// Right field foul line
let rightEnd = CGPoint(
x: homeX + foulLineLength * cos(.pi / 4),
y: homeY - foulLineLength * sin(.pi / 4)
)
var rightLine = Path()
rightLine.move(to: CGPoint(x: homeX, y: homeY))
rightLine.addLine(to: rightEnd)
context.stroke(rightLine, with: .color(.white.opacity(0.15)), lineWidth: 1)
// Outfield arc
var arc = Path()
arc.addArc(
center: CGPoint(x: homeX, y: homeY),
radius: foulLineLength,
startAngle: .degrees(-135),
endAngle: .degrees(-45),
clockwise: false
)
context.stroke(arc, with: .color(.white.opacity(0.1)), lineWidth: 1)
// Infield arc (smaller)
var infieldArc = Path()
let infieldRadius: CGFloat = 60 * scale
infieldArc.addArc(
center: CGPoint(x: homeX, y: homeY),
radius: infieldRadius,
startAngle: .degrees(-135),
endAngle: .degrees(-45),
clockwise: false
)
context.stroke(infieldArc, with: .color(.white.opacity(0.08)), lineWidth: 0.5)
// Infield diamond
let baseDistance: CGFloat = 40 * scale
let first = CGPoint(x: homeX + baseDistance * cos(.pi / 4), y: homeY - baseDistance * sin(.pi / 4))
let second = CGPoint(x: homeX, y: homeY - baseDistance * sqrt(2))
let third = CGPoint(x: homeX - baseDistance * cos(.pi / 4), y: homeY - baseDistance * sin(.pi / 4))
var diamond = Path()
diamond.move(to: CGPoint(x: homeX, y: homeY))
diamond.addLine(to: first)
diamond.addLine(to: second)
diamond.addLine(to: third)
diamond.closeSubpath()
context.stroke(diamond, with: .color(.white.opacity(0.12)), lineWidth: 0.5)
// Home plate marker
let plateSize: CGFloat = 4
let plateRect = CGRect(x: homeX - plateSize, y: homeY - plateSize, width: plateSize * 2, height: plateSize * 2)
context.fill(Path(plateRect), with: .color(.white.opacity(0.3)))
// Draw hit dots
for hit in hits {
let x = hit.coordX * scale
let y = hit.coordY * scale
let dotRadius: CGFloat = 5
let dotRect = CGRect(x: x - dotRadius, y: y - dotRadius, width: dotRadius * 2, height: dotRadius * 2)
context.fill(Circle().path(in: dotRect), with: .color(hit.color))
context.stroke(Circle().path(in: dotRect), with: .color(.white.opacity(0.2)), lineWidth: 0.5)
}
}
.frame(width: size, height: size)
}
}

View File

@@ -0,0 +1,104 @@
import SwiftUI
struct StrikeZoneView: View {
let pitches: [LiveFeedPlayEvent]
var size: CGFloat = 200
// Visible range: enough to show balls just outside the zone
// pX: feet from center of plate. Zone is -0.83 to 0.83. Show -1.5 to 1.5.
// pZ: height in feet. Zone is ~1.5 to 3.5. Show 0.5 to 4.5.
private let viewMinX: Double = -1.5
private let viewMaxX: Double = 1.5
private let viewMinZ: Double = 0.5
private let viewMaxZ: Double = 4.5
private let zoneHalfWidth: Double = 0.83
private var strikeZoneTop: Double {
pitches.compactMap { $0.pitchData?.strikeZoneTop }.last ?? 3.4
}
private var strikeZoneBottom: Double {
pitches.compactMap { $0.pitchData?.strikeZoneBottom }.last ?? 1.6
}
var body: some View {
Canvas { context, canvasSize in
let w = canvasSize.width
let h = canvasSize.height
let rangeX = viewMaxX - viewMinX
let rangeZ = viewMaxZ - viewMinZ
func mapX(_ pX: Double) -> CGFloat {
CGFloat((pX - viewMinX) / rangeX) * w
}
func mapZ(_ pZ: Double) -> CGFloat {
// Higher pZ = higher on screen = lower canvas Y
h - CGFloat((pZ - viewMinZ) / rangeZ) * h
}
// Strike zone rectangle
let zoneLeft = mapX(-zoneHalfWidth)
let zoneRight = mapX(zoneHalfWidth)
let zoneTop = mapZ(strikeZoneTop)
let zoneBottom = mapZ(strikeZoneBottom)
let zoneRect = CGRect(x: zoneLeft, y: zoneTop, width: zoneRight - zoneLeft, height: zoneBottom - zoneTop)
context.fill(Path(zoneRect), with: .color(.white.opacity(0.06)))
context.stroke(Path(zoneRect), with: .color(.white.opacity(0.3)), lineWidth: 1.5)
// 3x3 grid
let zoneW = zoneRight - zoneLeft
let zoneH = zoneBottom - zoneTop
for i in 1...2 {
let xLine = zoneLeft + zoneW * CGFloat(i) / 3.0
var vPath = Path()
vPath.move(to: CGPoint(x: xLine, y: zoneTop))
vPath.addLine(to: CGPoint(x: xLine, y: zoneBottom))
context.stroke(vPath, with: .color(.white.opacity(0.12)), lineWidth: 0.5)
let yLine = zoneTop + zoneH * CGFloat(i) / 3.0
var hPath = Path()
hPath.move(to: CGPoint(x: zoneLeft, y: yLine))
hPath.addLine(to: CGPoint(x: zoneRight, y: yLine))
context.stroke(hPath, with: .color(.white.opacity(0.12)), lineWidth: 0.5)
}
// Pitch dots
let dotScale = min(size / 200.0, 1.0)
for (index, pitch) in pitches.enumerated() {
guard let coords = pitch.pitchData?.coordinates,
let pX = coords.pX, let pZ = coords.pZ else { continue }
let cx = mapX(pX)
let cy = mapZ(pZ)
let isLatest = index == pitches.count - 1
let dotRadius: CGFloat = (isLatest ? 9 : 7) * dotScale
let color = pitchCallColor(pitch.callCode)
let dotRect = CGRect(
x: cx - dotRadius,
y: cy - dotRadius,
width: dotRadius * 2,
height: dotRadius * 2
)
if isLatest {
let glowRect = dotRect.insetBy(dx: -4 * dotScale, dy: -4 * dotScale)
context.fill(Circle().path(in: glowRect), with: .color(color.opacity(0.3)))
}
context.fill(Circle().path(in: dotRect), with: .color(color))
context.stroke(Circle().path(in: dotRect), with: .color(.white.opacity(0.4)), lineWidth: 0.5)
// Pitch number
let fontSize: CGFloat = max(8 * dotScale, 7)
let numText = Text("\(pitch.pitchNumber ?? (index + 1))")
.font(.system(size: fontSize, weight: .bold, design: .rounded))
.foregroundColor(.white)
context.draw(context.resolve(numText), at: CGPoint(x: cx, y: cy), anchor: .center)
}
}
.frame(width: size, height: size * 1.33)
}
}

View File

@@ -12,8 +12,8 @@ enum SpecialPlaybackChannelConfig {
static let werkoutNSFWStreamID = "WKNSFW"
static let werkoutNSFWTitle = "Werkout NSFW"
static let werkoutNSFWSubtitle = "Authenticated OF media feed"
static let werkoutNSFWFeedURLString = "https://ofapp.treytartt.com/api/media?users=tabatachang,werkout&type=video"
static let werkoutNSFWCookie = "ofapp_token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjEsImlhdCI6MTc3NDY0MjEyNiwiZXhwIjoxNzc1MjQ2OTI2fQ.rDvZzLQ70MM9drHwA8hRxmqcTTgBGBHXxv1Cc55HSqc"
static let werkoutNSFWFeedURLString = "https://ofapp.treytartt.com/api/media?users=tabatachang,werkout,kayla-lauren,ray-mattos,josie-hamming-2,dani-speegle-2&type=video"
static let werkoutNSFWCookie = "ofapp_token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjEsImlhdCI6MTc3NDY0MjEyNiwiZXhwIjoxNzc3MjM0MTI2fQ.RdYGvyBPbR6tmK0kaEVhSnIVW6TZlm3Ef42Lxpvl_oQ"
static let werkoutNSFWTeamCode = "WK"
static var werkoutNSFWHeaders: [String: String] {
@@ -56,6 +56,7 @@ struct DashboardView: View {
@State private var pendingFullScreenBroadcast: BroadcastSelection?
@State private var showMLBNetworkSheet = false
@State private var showWerkoutNSFWSheet = false
@State private var isPiPActive = false
private var horizontalPadding: CGFloat {
#if os(iOS)
@@ -218,7 +219,11 @@ struct DashboardView: View {
await resolveFullScreenSource(for: selection)
},
resolveNextSource: nextFullScreenSourceResolver(for: selection),
tickerGames: tickerGames(for: selection)
tickerGames: tickerGames(for: selection),
game: selection.game,
onPiPActiveChanged: { active in
isPiPActive = active
}
)
.ignoresSafeArea()
.onAppear {

View File

@@ -1,9 +1,11 @@
import SwiftUI
import AVKit
struct GameCenterView: View {
let game: Game
@State private var viewModel = GameCenterViewModel()
@State private var highlightPlayer: AVPlayer?
var body: some View {
VStack(alignment: .leading, spacing: 18) {
@@ -15,6 +17,14 @@ struct GameCenterView: View {
situationStrip(feed: feed)
matchupPanel(feed: feed)
if !feed.currentAtBatPitches.isEmpty {
atBatPanel(feed: feed)
}
if let wpHome = viewModel.winProbabilityHome, let wpAway = viewModel.winProbabilityAway {
winProbabilityPanel(home: wpHome, away: wpAway)
}
if !feed.decisionsSummary.isEmpty || !feed.officialSummary.isEmpty || feed.weatherSummary != nil {
contextPanel(feed: feed)
}
@@ -23,6 +33,14 @@ struct GameCenterView: View {
lineupPanel(feed: feed)
}
if !viewModel.highlights.isEmpty {
highlightsPanel
}
if !feed.allGameHits.isEmpty {
sprayChartPanel(feed: feed)
}
if !feed.scoringPlays.isEmpty {
timelineSection(
title: "Scoring Plays",
@@ -43,6 +61,16 @@ struct GameCenterView: View {
.task(id: game.id) {
await viewModel.watch(game: game)
}
.fullScreenCover(isPresented: Binding(
get: { highlightPlayer != nil },
set: { if !$0 { highlightPlayer?.pause(); highlightPlayer = nil } }
)) {
if let player = highlightPlayer {
VideoPlayer(player: player)
.ignoresSafeArea()
.onAppear { player.play() }
}
}
}
private var header: some View {
@@ -126,46 +154,175 @@ struct GameCenterView: View {
private func matchupPanel(feed: LiveGameFeed) -> some View {
VStack(alignment: .leading, spacing: 16) {
HStack(alignment: .top, spacing: 18) {
VStack(alignment: .leading, spacing: 5) {
Text("At Bat")
.font(.system(size: 13, weight: .bold, design: .rounded))
.foregroundStyle(.white.opacity(0.5))
HStack(spacing: 14) {
PitcherHeadshotView(
url: feed.currentBatter?.headshotURL,
teamCode: game.status.isLive ? nil : game.awayTeam.code,
size: 46
)
Text(feed.currentBatter?.displayName ?? "Awaiting matchup")
.font(.system(size: 24, weight: .bold, design: .rounded))
.foregroundStyle(.white)
VStack(alignment: .leading, spacing: 5) {
Text("At Bat")
.font(.system(size: 13, weight: .bold, design: .rounded))
.foregroundStyle(.white.opacity(0.5))
if let onDeck = feed.onDeckBatter?.displayName {
Text("On deck: \(onDeck)")
.font(.system(size: 15, weight: .medium))
.foregroundStyle(.white.opacity(0.55))
}
Text(feed.currentBatter?.displayName ?? "Awaiting matchup")
.font(.system(size: 24, weight: .bold, design: .rounded))
.foregroundStyle(.white)
if let inHole = feed.inHoleBatter?.displayName {
Text("In hole: \(inHole)")
.font(.system(size: 15, weight: .medium))
.foregroundStyle(.white.opacity(0.46))
if let onDeck = feed.onDeckBatter?.displayName {
Text("On deck: \(onDeck)")
.font(.system(size: 15, weight: .medium))
.foregroundStyle(.white.opacity(0.55))
}
if let inHole = feed.inHoleBatter?.displayName {
Text("In hole: \(inHole)")
.font(.system(size: 15, weight: .medium))
.foregroundStyle(.white.opacity(0.46))
}
}
}
Spacer()
VStack(alignment: .trailing, spacing: 5) {
Text("Pitching")
.font(.system(size: 13, weight: .bold, design: .rounded))
.foregroundStyle(.white.opacity(0.5))
HStack(spacing: 14) {
VStack(alignment: .trailing, spacing: 5) {
Text("Pitching")
.font(.system(size: 13, weight: .bold, design: .rounded))
.foregroundStyle(.white.opacity(0.5))
Text(feed.currentPitcher?.displayName ?? "Awaiting pitcher")
.font(.system(size: 24, weight: .bold, design: .rounded))
.foregroundStyle(.white)
.multilineTextAlignment(.trailing)
if let play = feed.currentPlay {
Text(play.summaryText)
.font(.system(size: 15, weight: .medium))
.foregroundStyle(.white.opacity(0.58))
.frame(maxWidth: 420, alignment: .trailing)
Text(feed.currentPitcher?.displayName ?? "Awaiting pitcher")
.font(.system(size: 24, weight: .bold, design: .rounded))
.foregroundStyle(.white)
.multilineTextAlignment(.trailing)
if let play = feed.currentPlay {
Text(play.summaryText)
.font(.system(size: 15, weight: .medium))
.foregroundStyle(.white.opacity(0.58))
.frame(maxWidth: 420, alignment: .trailing)
.multilineTextAlignment(.trailing)
}
}
PitcherHeadshotView(
url: feed.currentPitcher?.headshotURL,
teamCode: nil,
size: 46
)
}
}
}
.padding(22)
.background(panelBackground)
}
private func atBatPanel(feed: LiveGameFeed) -> some View {
let pitches = feed.currentAtBatPitches
return VStack(alignment: .leading, spacing: 16) {
HStack(alignment: .top, spacing: 24) {
StrikeZoneView(pitches: pitches, size: 160)
VStack(alignment: .leading, spacing: 16) {
AtBatTimelineView(pitches: pitches)
PitchSequenceView(pitches: pitches)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
.padding(22)
.background(panelBackground)
}
private func sprayChartPanel(feed: LiveGameFeed) -> some View {
let hits = feed.allGameHits.map { item in
SprayChartHit(
id: item.play.id,
coordX: item.hitData.coordinates?.coordX ?? 0,
coordY: item.hitData.coordinates?.coordY ?? 0,
event: item.play.result?.eventType ?? item.play.result?.event
)
}
return VStack(alignment: .leading, spacing: 16) {
VStack(alignment: .leading, spacing: 4) {
Text("Spray Chart")
.font(.system(size: 24, weight: .bold, design: .rounded))
.foregroundStyle(.white)
Text("Batted ball locations this game.")
.font(.system(size: 15, weight: .medium))
.foregroundStyle(.white.opacity(0.55))
}
HStack(spacing: 24) {
SprayChartView(hits: hits, size: 220)
VStack(alignment: .leading, spacing: 8) {
sprayChartLegendItem(color: .red, label: "Home Run")
sprayChartLegendItem(color: .orange, label: "Triple")
sprayChartLegendItem(color: .green, label: "Double")
sprayChartLegendItem(color: .blue, label: "Single")
sprayChartLegendItem(color: .white.opacity(0.4), label: "Out")
}
}
}
.padding(22)
.background(panelBackground)
}
private func sprayChartLegendItem(color: Color, label: String) -> some View {
HStack(spacing: 8) {
Circle()
.fill(color)
.frame(width: 10, height: 10)
Text(label)
.font(.system(size: 13, weight: .medium, design: .rounded))
.foregroundStyle(.white.opacity(0.7))
}
}
private var highlightsPanel: some View {
VStack(alignment: .leading, spacing: 14) {
VStack(alignment: .leading, spacing: 4) {
Text("Highlights")
.font(.system(size: 24, weight: .bold, design: .rounded))
.foregroundStyle(.white)
Text("Key moments and plays from this game.")
.font(.system(size: 15, weight: .medium))
.foregroundStyle(.white.opacity(0.55))
}
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 12) {
ForEach(viewModel.highlights) { highlight in
Button {
if let urlStr = highlight.hlsURL ?? highlight.mp4URL,
let url = URL(string: urlStr) {
highlightPlayer = AVPlayer(url: url)
}
} label: {
HStack(spacing: 10) {
Image(systemName: "play.fill")
.font(.system(size: 14, weight: .bold))
.foregroundStyle(.purple)
Text(highlight.headline ?? "Highlight")
.font(.system(size: 14, weight: .semibold, design: .rounded))
.foregroundStyle(.white)
.lineLimit(2)
.multilineTextAlignment(.leading)
}
.padding(.horizontal, 16)
.padding(.vertical, 12)
.background(
RoundedRectangle(cornerRadius: 14, style: .continuous)
.fill(.white.opacity(0.06))
)
}
.platformCardStyle()
}
}
}
@@ -174,6 +331,60 @@ struct GameCenterView: View {
.background(panelBackground)
}
private func winProbabilityPanel(home: Double, away: Double) -> some View {
let homeColor = TeamAssets.color(for: game.homeTeam.code)
let awayColor = TeamAssets.color(for: game.awayTeam.code)
let homePct = home / 100.0
let awayPct = away / 100.0
return VStack(alignment: .leading, spacing: 14) {
Text("WIN PROBABILITY")
.font(.system(size: 12, weight: .bold, design: .rounded))
.foregroundStyle(.white.opacity(0.5))
.kerning(1.2)
HStack(spacing: 16) {
VStack(alignment: .leading, spacing: 6) {
Text(game.awayTeam.code)
.font(.system(size: 16, weight: .black, design: .rounded))
.foregroundStyle(.white)
Text("\(Int(away))%")
.font(.system(size: 28, weight: .bold).monospacedDigit())
.foregroundStyle(awayColor)
}
.frame(width: 80)
GeometryReader { geo in
HStack(spacing: 2) {
RoundedRectangle(cornerRadius: 6, style: .continuous)
.fill(awayColor)
.frame(width: max(geo.size.width * awayPct, 4))
RoundedRectangle(cornerRadius: 6, style: .continuous)
.fill(homeColor)
.frame(width: max(geo.size.width * homePct, 4))
}
.frame(height: 24)
}
.frame(height: 24)
VStack(alignment: .trailing, spacing: 6) {
Text(game.homeTeam.code)
.font(.system(size: 16, weight: .black, design: .rounded))
.foregroundStyle(.white)
Text("\(Int(home))%")
.font(.system(size: 28, weight: .bold).monospacedDigit())
.foregroundStyle(homeColor)
}
.frame(width: 80)
}
}
.padding(22)
.background(panelBackground)
}
private func contextPanel(feed: LiveGameFeed) -> some View {
VStack(alignment: .leading, spacing: 16) {
if let weatherSummary = feed.weatherSummary {
@@ -256,6 +467,11 @@ struct GameCenterView: View {
.foregroundStyle(.white.opacity(0.6))
.frame(width: 18, alignment: .leading)
PitcherHeadshotView(
url: player.person.headshotURL,
size: 28
)
Text(player.person.displayName)
.font(.system(size: 15, weight: .semibold))
.foregroundStyle(.white.opacity(0.88))

View File

@@ -77,6 +77,8 @@ struct StreamOptionsSheet: View {
.padding(.vertical, 18)
.background(panelBackground)
}
GameCenterView(game: game)
}
.padding(.horizontal, horizontalPadding)
.padding(.vertical, verticalPadding)
@@ -129,8 +131,6 @@ struct StreamOptionsSheet: View {
.padding(22)
.background(panelBackground)
}
GameCenterView(game: game)
}
}

View File

@@ -353,8 +353,13 @@ private struct MultiStreamTile: View {
@State private var hasError = false
@State private var startupPlaybackTask: Task<Void, Never>?
@State private var qualityUpgradeTask: 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
@@ -442,6 +447,7 @@ private struct MultiStreamTile: View {
startupPlaybackTask = nil
qualityUpgradeTask?.cancel()
qualityUpgradeTask = nil
if let player { removeClipTimeLimit(from: player) }
playbackDiagnostics.clear(streamID: stream.id, reason: "tile disappeared")
}
#if os(tvOS)
@@ -536,7 +542,6 @@ private struct MultiStreamTile: View {
)
if let player {
player.isMuted = stream.forceMuteAudio || viewModel.audioFocusStreamID != stream.id
playbackDiagnostics.attach(
to: player,
streamID: stream.id,
@@ -545,20 +550,23 @@ private struct MultiStreamTile: View {
)
scheduleStartupPlaybackRecovery(for: player)
scheduleQualityUpgrade(for: player)
installClipTimeLimit(on: 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 !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 {
existingPlayer.isMuted = stream.forceMuteAudio || viewModel.audioFocusStreamID != stream.id
self.player = existingPlayer
hasError = false
playbackDiagnostics.attach(
@@ -569,6 +577,7 @@ private struct MultiStreamTile: View {
)
scheduleStartupPlaybackRecovery(for: existingPlayer)
scheduleQualityUpgrade(for: existingPlayer)
installClipTimeLimit(on: existingPlayer)
logMultiView("startStream reused shared player id=\(stream.id) muted=\(existingPlayer.isMuted)")
return
}
@@ -606,6 +615,7 @@ private struct MultiStreamTile: View {
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 {
@@ -754,28 +764,73 @@ private struct MultiStreamTile: View {
.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")"
"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 {
logMultiView("playNextWerkoutClip failed id=\(stream.id) reason=resolve-nil-after-retries")
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,
@@ -790,10 +845,27 @@ private struct MultiStreamTile: View {
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)")
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
}
}
}
}
}

View File

@@ -80,28 +80,104 @@ struct SingleStreamPlaybackScreen: View {
let resolveSource: @Sendable () async -> SingleStreamPlaybackSource?
var resolveNextSource: (@Sendable (URL?) async -> SingleStreamPlaybackSource?)? = nil
let tickerGames: [Game]
var game: Game? = nil
var onPiPActiveChanged: ((Bool) -> Void)? = nil
@State private var showGameCenter = false
@State private var showPitchInfo = false
@State private var pitchViewModel = GameCenterViewModel()
@State private var isPiPActive = false
var body: some View {
ZStack(alignment: .bottom) {
SingleStreamPlayerView(resolveSource: resolveSource, resolveNextSource: resolveNextSource)
.ignoresSafeArea()
SingleStreamPlayerView(
resolveSource: resolveSource,
resolveNextSource: resolveNextSource,
hasGamePk: game?.gamePk != nil,
onTogglePitchInfo: {
showPitchInfo.toggle()
if showPitchInfo { showGameCenter = false }
},
onToggleGameCenter: {
showGameCenter.toggle()
if showGameCenter { showPitchInfo = false }
},
onPiPStateChanged: { active in
isPiPActive = active
onPiPActiveChanged?(active)
},
showPitchInfo: showPitchInfo,
showGameCenter: showGameCenter
)
.ignoresSafeArea()
SingleStreamScoreStripView(games: tickerGames)
.allowsHitTesting(false)
.padding(.horizontal, 18)
.padding(.bottom, 14)
}
.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)
if !showGameCenter && !showPitchInfo {
SingleStreamScoreStripView(games: tickerGames)
.allowsHitTesting(false)
.padding(.horizontal, 18)
.padding(.bottom, 14)
.transition(.opacity)
}
if showGameCenter, let game {
gameCenterOverlay(game: game)
.transition(.move(edge: .bottom).combined(with: .opacity))
}
}
.animation(.easeInOut(duration: 0.3), value: showGameCenter)
.animation(.easeInOut(duration: 0.3), value: showPitchInfo)
.overlay(alignment: .bottomLeading) {
if showPitchInfo, let feed = pitchViewModel.feed {
pitchInfoBox(feed: feed)
.transition(.move(edge: .leading).combined(with: .opacity))
}
}
#if os(iOS)
.overlay(alignment: .topTrailing) {
HStack(spacing: 12) {
if game?.gamePk != nil {
Button {
showPitchInfo.toggle()
if showPitchInfo { showGameCenter = false }
} label: {
Image(systemName: showPitchInfo ? "xmark.circle.fill" : "baseball.fill")
.font(.system(size: 28, weight: .bold))
.foregroundStyle(.white.opacity(0.9))
.padding(6)
.background(.black.opacity(0.5))
.clipShape(Circle())
}
Button {
showGameCenter.toggle()
if showGameCenter { showPitchInfo = false }
} label: {
Image(systemName: showGameCenter ? "xmark.circle.fill" : "chart.bar.fill")
.font(.system(size: 28, weight: .bold))
.foregroundStyle(.white.opacity(0.9))
.padding(6)
.background(.black.opacity(0.5))
.clipShape(Circle())
}
Button {
dismiss()
} label: {
Image(systemName: "xmark.circle.fill")
.font(.system(size: 28, weight: .bold))
.foregroundStyle(.white.opacity(0.9))
}
}
}
.padding(20)
}
#endif
.task(id: game?.gamePk) {
guard let gamePk = game?.gamePk else { return }
while !Task.isCancelled {
await pitchViewModel.refresh(gamePk: gamePk)
try? await Task.sleep(for: .seconds(5))
}
#endif
}
.ignoresSafeArea()
.onAppear {
@@ -111,6 +187,135 @@ struct SingleStreamPlaybackScreen: View {
logSingleStream("SingleStreamPlaybackScreen disappeared")
}
}
private func gameCenterOverlay(game: Game) -> some View {
ScrollView {
GameCenterView(game: game)
.padding(.horizontal, 20)
.padding(.top, 60)
.padding(.bottom, 40)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(.black.opacity(0.82))
}
private func pitchInfoBox(feed: LiveGameFeed) -> some View {
let pitches = feed.currentAtBatPitches
let batter = feed.currentBatter?.displayName ?? ""
let pitcher = feed.currentPitcher?.displayName ?? ""
let countText = feed.currentCountText ?? ""
return VStack(alignment: .leading, spacing: 8) {
// Matchup header use last name only to save space
HStack(spacing: 8) {
VStack(alignment: .leading, spacing: 2) {
Text("AB")
.font(.system(size: 10, weight: .bold, design: .rounded))
.foregroundStyle(.white.opacity(0.4))
Text(batter)
.font(.system(size: 14, weight: .bold, design: .rounded))
.foregroundStyle(.white)
.lineLimit(1)
.minimumScaleFactor(0.7)
}
Spacer()
VStack(alignment: .trailing, spacing: 2) {
Text("P")
.font(.system(size: 10, weight: .bold, design: .rounded))
.foregroundStyle(.white.opacity(0.4))
Text(pitcher)
.font(.system(size: 14, weight: .bold, design: .rounded))
.foregroundStyle(.white)
.lineLimit(1)
.minimumScaleFactor(0.7)
}
}
if !countText.isEmpty {
Text(countText)
.font(.system(size: 13, weight: .semibold, design: .rounded))
.foregroundStyle(.white.opacity(0.7))
}
if !pitches.isEmpty {
// Latest pitch bold and prominent
if let last = pitches.last {
let color = pitchCallColor(last.callCode)
HStack(spacing: 6) {
if let speed = last.speedMPH {
Text("\(speed, specifier: "%.1f")")
.font(.system(size: 24, weight: .black).monospacedDigit())
.foregroundStyle(.white)
Text("mph")
.font(.system(size: 12, weight: .bold, design: .rounded))
.foregroundStyle(.white.opacity(0.5))
}
Spacer()
VStack(alignment: .trailing, spacing: 2) {
Text(last.pitchTypeDescription)
.font(.system(size: 14, weight: .bold, design: .rounded))
.foregroundStyle(.white)
.lineLimit(1)
Text(last.callDescription)
.font(.system(size: 12, weight: .bold, design: .rounded))
.foregroundStyle(color)
}
}
}
// Strike zone + previous pitches side by side
HStack(alignment: .top, spacing: 14) {
StrikeZoneView(pitches: pitches, size: 120)
// Previous pitches compact rows
if pitches.count > 1 {
VStack(alignment: .leading, spacing: 3) {
ForEach(Array(pitches.dropLast().reversed().prefix(8).enumerated()), id: \.offset) { _, pitch in
let color = pitchCallColor(pitch.callCode)
HStack(spacing: 4) {
Text("\(pitch.pitchNumber ?? 0)")
.font(.system(size: 10, weight: .bold).monospacedDigit())
.foregroundStyle(.white.opacity(0.35))
.frame(width: 14, alignment: .trailing)
Text(shortPitchType(pitch.pitchTypeCode))
.font(.system(size: 11, weight: .semibold, design: .rounded))
.foregroundStyle(.white.opacity(0.7))
if let speed = pitch.speedMPH {
Text("\(speed, specifier: "%.0f")")
.font(.system(size: 11, weight: .bold).monospacedDigit())
.foregroundStyle(.white.opacity(0.45))
}
Spacer(minLength: 0)
Circle()
.fill(color)
.frame(width: 6, height: 6)
}
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
}
} else {
Text("Waiting for pitch data...")
.font(.system(size: 12, weight: .medium))
.foregroundStyle(.white.opacity(0.5))
}
}
.frame(width: 300)
.padding(16)
.background(
RoundedRectangle(cornerRadius: 18, style: .continuous)
.fill(.black.opacity(0.78))
.overlay {
RoundedRectangle(cornerRadius: 18, style: .continuous)
.strokeBorder(.white.opacity(0.1), lineWidth: 1)
}
)
.padding(.leading, 24)
.padding(.bottom, 50)
}
}
struct SingleStreamPlaybackSource: Sendable {
@@ -290,6 +495,12 @@ private final class SingleStreamMarqueeContainerView: UIView {
struct SingleStreamPlayerView: UIViewControllerRepresentable {
let resolveSource: @Sendable () async -> SingleStreamPlaybackSource?
var resolveNextSource: (@Sendable (URL?) async -> SingleStreamPlaybackSource?)? = nil
var hasGamePk: Bool = false
var onTogglePitchInfo: (() -> Void)? = nil
var onToggleGameCenter: (() -> Void)? = nil
var onPiPStateChanged: ((Bool) -> Void)? = nil
var showPitchInfo: Bool = false
var showGameCenter: Bool = false
func makeCoordinator() -> Coordinator {
Coordinator()
@@ -300,10 +511,24 @@ struct SingleStreamPlayerView: UIViewControllerRepresentable {
let controller = AVPlayerViewController()
controller.allowsPictureInPicturePlayback = true
controller.showsPlaybackControls = true
context.coordinator.onPiPStateChanged = onPiPStateChanged
controller.delegate = context.coordinator
#if os(iOS)
controller.canStartPictureInPictureAutomaticallyFromInline = true
#endif
logSingleStream("AVPlayerViewController configured without contentOverlayView ticker")
#if os(tvOS)
if hasGamePk {
context.coordinator.onTogglePitchInfo = onTogglePitchInfo
context.coordinator.onToggleGameCenter = onToggleGameCenter
controller.transportBarCustomMenuItems = context.coordinator.buildTransportBarItems(
showPitchInfo: showPitchInfo,
showGameCenter: showGameCenter
)
}
#endif
logSingleStream("AVPlayerViewController configured")
Task { @MainActor in
let resolveStartedAt = Date()
@@ -335,26 +560,102 @@ struct SingleStreamPlayerView: UIViewControllerRepresentable {
controller.player = player
logSingleStream("AVPlayer assigned to controller; calling playImmediately(atRate: 1.0)")
player.playImmediately(atRate: 1.0)
context.coordinator.installClipTimeLimit(on: player, resolveNextSource: resolveNextSource)
context.coordinator.scheduleStartupRecovery(for: player)
}
return controller
}
func updateUIViewController(_ uiViewController: AVPlayerViewController, context: Context) {}
func updateUIViewController(_ uiViewController: AVPlayerViewController, context: Context) {
context.coordinator.onPiPStateChanged = onPiPStateChanged
#if os(tvOS)
if hasGamePk {
context.coordinator.onTogglePitchInfo = onTogglePitchInfo
context.coordinator.onToggleGameCenter = onToggleGameCenter
uiViewController.transportBarCustomMenuItems = context.coordinator.buildTransportBarItems(
showPitchInfo: showPitchInfo,
showGameCenter: showGameCenter
)
}
#endif
}
static func dismantleUIViewController(_ uiViewController: AVPlayerViewController, coordinator: Coordinator) {
logSingleStream("dismantleUIViewController start")
logSingleStream("dismantleUIViewController start isPiPActive=\(coordinator.isPiPActive)")
if coordinator.isPiPActive {
logSingleStream("dismantleUIViewController skipped — PiP is active")
return
}
coordinator.clearDebugObservers()
uiViewController.player?.pause()
uiViewController.player = nil
logSingleStream("dismantleUIViewController complete")
}
final class Coordinator: NSObject, @unchecked Sendable {
final class Coordinator: NSObject, @unchecked Sendable, AVPlayerViewControllerDelegate {
private var playerObservations: [NSKeyValueObservation] = []
private var notificationTokens: [NSObjectProtocol] = []
private var startupRecoveryTask: Task<Void, Never>?
private var clipTimeLimitObserver: Any?
private static let maxClipDuration: Double = 15.0
var onTogglePitchInfo: (() -> Void)?
var onToggleGameCenter: (() -> Void)?
var isPiPActive = false
var onPiPStateChanged: ((Bool) -> Void)?
func playerViewControllerShouldAutomaticallyDismissAtPictureInPictureStart(_ playerViewController: AVPlayerViewController) -> Bool {
logSingleStream("PiP: shouldAutomaticallyDismiss returning false")
return false
}
func playerViewControllerWillStartPictureInPicture(_ playerViewController: AVPlayerViewController) {
logSingleStream("PiP: willStart")
isPiPActive = true
onPiPStateChanged?(true)
}
func playerViewControllerDidStartPictureInPicture(_ playerViewController: AVPlayerViewController) {
logSingleStream("PiP: didStart")
}
func playerViewControllerWillStopPictureInPicture(_ playerViewController: AVPlayerViewController) {
logSingleStream("PiP: willStop")
}
func playerViewControllerDidStopPictureInPicture(_ playerViewController: AVPlayerViewController) {
logSingleStream("PiP: didStop")
isPiPActive = false
onPiPStateChanged?(false)
}
func playerViewController(
_ playerViewController: AVPlayerViewController,
restoreUserInterfaceForPictureInPictureStopWithCompletionHandler completionHandler: @escaping (Bool) -> Void
) {
logSingleStream("PiP: restoreUserInterface")
completionHandler(true)
}
#if os(tvOS)
func buildTransportBarItems(showPitchInfo: Bool, showGameCenter: Bool) -> [UIAction] {
let pitchAction = UIAction(
title: showPitchInfo ? "Hide Pitch Info" : "Pitch Info",
image: UIImage(systemName: showPitchInfo ? "xmark.circle.fill" : "baseball.fill")
) { [weak self] _ in
self?.onTogglePitchInfo?()
}
let gcAction = UIAction(
title: showGameCenter ? "Hide Game Center" : "Game Center",
image: UIImage(systemName: showGameCenter ? "xmark.circle.fill" : "chart.bar.fill")
) { [weak self] _ in
self?.onToggleGameCenter?()
}
return [pitchAction, gcAction]
}
#endif
func attachDebugObservers(
to player: AVPlayer,
@@ -456,6 +757,7 @@ struct SingleStreamPlayerView: UIViewControllerRepresentable {
self.attachDebugObservers(to: player, url: nextSource.url, resolveNextSource: resolveNextSource)
logSingleStream("Autoplay replacing item and replaying url=\(singleStreamDebugURLDescription(nextSource.url))")
player.playImmediately(atRate: 1.0)
self.installClipTimeLimit(on: player, resolveNextSource: resolveNextSource)
self.scheduleStartupRecovery(for: player)
}
}
@@ -522,6 +824,45 @@ struct SingleStreamPlayerView: UIViewControllerRepresentable {
}
}
func installClipTimeLimit(
on player: AVPlayer,
resolveNextSource: (@Sendable (URL?) async -> SingleStreamPlaybackSource?)?
) {
removeClipTimeLimit(from: player)
guard resolveNextSource != nil else { return }
let limit = CMTime(seconds: Self.maxClipDuration, preferredTimescale: 600)
clipTimeLimitObserver = player.addBoundaryTimeObserver(
forTimes: [NSValue(time: limit)],
queue: .main
) { [weak self, weak player] in
guard let self, let player, let resolveNextSource else { return }
logSingleStream("clipTimeLimit hit \(Self.maxClipDuration)s — advancing to next clip")
Task { @MainActor [weak self] in
guard let self else { return }
let currentURL = (player.currentItem?.asset as? AVURLAsset)?.url
guard let nextSource = await resolveNextSource(currentURL) else {
logSingleStream("clipTimeLimit next source nil")
return
}
let nextItem = makeSingleStreamPlayerItem(from: nextSource)
player.replaceCurrentItem(with: nextItem)
player.automaticallyWaitsToMinimizeStalling = false
player.isMuted = nextSource.forceMuteAudio
self.attachDebugObservers(to: player, url: nextSource.url, resolveNextSource: resolveNextSource)
player.playImmediately(atRate: 1.0)
self.installClipTimeLimit(on: player, resolveNextSource: resolveNextSource)
self.scheduleStartupRecovery(for: player)
}
}
}
private func removeClipTimeLimit(from player: AVPlayer) {
if let observer = clipTimeLimitObserver {
player.removeTimeObserver(observer)
clipTimeLimitObserver = nil
}
}
func clearDebugObservers() {
startupRecoveryTask?.cancel()
startupRecoveryTask = nil