Add iOS/iPad target with platform-adaptive UI

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-03-30 21:30:28 -05:00
parent 127125ae1b
commit fda809fd2f
21 changed files with 851 additions and 129 deletions

View File

@@ -1,9 +1,11 @@
import Foundation
actor MLBServerAPI {
static let defaultBaseURL = "https://ballgame.treytartt.com"
let baseURL: String
init(baseURL: String = "http://10.3.3.11:5714") {
init(baseURL: String = defaultBaseURL) {
self.baseURL = baseURL
}

View File

@@ -25,7 +25,8 @@ private func gamesViewModelDebugURLDescription(_ url: URL) -> String {
}
private struct RemoteVideoFeedEntry: Decodable {
let videoFile: String
let videoFile: String?
let hlsUrl: String?
let genderValue: String?
}
@@ -46,7 +47,7 @@ final class GamesViewModel {
var multiViewLayoutMode: MultiViewLayoutMode = .balanced
var audioFocusStreamID: String?
var serverBaseURL: String = "http://10.3.3.11:5714"
var serverBaseURL: String = MLBServerAPI.defaultBaseURL
var defaultResolution: String = "best"
@ObservationIgnored
@@ -172,7 +173,8 @@ final class GamesViewModel {
overrideHeaders: activeStreams[streamIdx].overrideHeaders,
player: activeStreams[streamIdx].player,
isPlaying: activeStreams[streamIdx].isPlaying,
isMuted: activeStreams[streamIdx].isMuted
isMuted: activeStreams[streamIdx].isMuted,
forceMuteAudio: activeStreams[streamIdx].forceMuteAudio
)
}
}
@@ -358,8 +360,6 @@ final class GamesViewModel {
func addStream(broadcast: Broadcast, game: Game) {
guard activeStreams.count < 4 else { return }
guard !activeStreams.contains(where: { $0.id == broadcast.id }) else { return }
let shouldCaptureAudio = activeStreams.isEmpty && audioFocusStreamID == nil
let stream = ActiveStream(
id: broadcast.id,
game: game,
@@ -368,7 +368,7 @@ final class GamesViewModel {
streamURLString: broadcast.streamURL
)
activeStreams.append(stream)
if shouldCaptureAudio {
if shouldCaptureAudio(for: stream) {
audioFocusStreamID = stream.id
}
syncAudioFocus()
@@ -376,8 +376,6 @@ final class GamesViewModel {
func addStreamByTeam(teamCode: String, game: Game) {
guard activeStreams.count < 4 else { return }
let shouldCaptureAudio = activeStreams.isEmpty && audioFocusStreamID == nil
let config = StreamConfig(team: teamCode, resolution: defaultResolution, date: selectedDate)
let stream = ActiveStream(
id: "\(teamCode)-\(game.id)",
@@ -386,7 +384,7 @@ final class GamesViewModel {
config: config
)
activeStreams.append(stream)
if shouldCaptureAudio {
if shouldCaptureAudio(for: stream) {
audioFocusStreamID = stream.id
}
syncAudioFocus()
@@ -397,21 +395,22 @@ final class GamesViewModel {
label: String,
game: Game,
url: URL,
headers: [String: String] = [:]
headers: [String: String] = [:],
forceMuteAudio: Bool = false
) {
guard activeStreams.count < 4 else { return }
guard !activeStreams.contains(where: { $0.id == id }) else { return }
let shouldCaptureAudio = activeStreams.isEmpty && audioFocusStreamID == nil
let stream = ActiveStream(
id: id,
game: game,
label: label,
overrideURL: url,
overrideHeaders: headers.isEmpty ? nil : headers
overrideHeaders: headers.isEmpty ? nil : headers,
forceMuteAudio: forceMuteAudio
)
activeStreams.append(stream)
if shouldCaptureAudio {
if shouldCaptureAudio(for: stream) {
audioFocusStreamID = stream.id
}
syncAudioFocus()
@@ -422,7 +421,8 @@ final class GamesViewModel {
label: String,
game: Game,
feedURL: URL,
headers: [String: String] = [:]
headers: [String: String] = [:],
forceMuteAudio: Bool = false
) async -> Bool {
guard let resolvedURL = await resolveAuthenticatedVideoFeedURL(feedURL: feedURL, headers: headers) else {
return false
@@ -433,7 +433,8 @@ final class GamesViewModel {
label: label,
game: game,
url: resolvedURL,
headers: headers
headers: headers,
forceMuteAudio: forceMuteAudio
)
return true
}
@@ -464,7 +465,7 @@ final class GamesViewModel {
audioFocusStreamID = nil
} else if removedWasAudioFocus {
let replacementIndex = min(index, activeStreams.count - 1)
audioFocusStreamID = activeStreams[replacementIndex].id
audioFocusStreamID = preferredAudioFocusStreamID(preferredIndex: replacementIndex)
}
syncAudioFocus()
}
@@ -496,8 +497,13 @@ final class GamesViewModel {
}
func setAudioFocus(streamID: String?) {
if let streamID, activeStreams.contains(where: { $0.id == streamID }) {
if let streamID,
let stream = activeStreams.first(where: { $0.id == streamID }),
!stream.forceMuteAudio {
audioFocusStreamID = streamID
} else if streamID != nil {
syncAudioFocus()
return
} else {
audioFocusStreamID = nil
}
@@ -512,7 +518,7 @@ final class GamesViewModel {
guard let index = activeStreams.firstIndex(where: { $0.id == streamID }) else { return }
activeStreams[index].player = player
activeStreams[index].isPlaying = true
let shouldMute = audioFocusStreamID != streamID
let shouldMute = shouldMuteAudio(for: activeStreams[index])
activeStreams[index].isMuted = shouldMute
player.isMuted = shouldMute
}
@@ -528,13 +534,42 @@ final class GamesViewModel {
}
private func syncAudioFocus() {
if let audioFocusStreamID,
!activeStreams.contains(where: { $0.id == audioFocusStreamID && !$0.forceMuteAudio }) {
self.audioFocusStreamID = preferredAudioFocusStreamID()
}
for index in activeStreams.indices {
let shouldMute = activeStreams[index].id != audioFocusStreamID
let shouldMute = shouldMuteAudio(for: activeStreams[index])
activeStreams[index].isMuted = shouldMute
activeStreams[index].player?.isMuted = shouldMute
}
}
private func shouldCaptureAudio(for stream: ActiveStream) -> Bool {
!stream.forceMuteAudio && audioFocusStreamID == nil
}
private func shouldMuteAudio(for stream: ActiveStream) -> Bool {
stream.forceMuteAudio || audioFocusStreamID != stream.id
}
private func preferredAudioFocusStreamID(preferredIndex: Int? = nil) -> String? {
let eligibleIndices = activeStreams.indices.filter { !activeStreams[$0].forceMuteAudio }
guard !eligibleIndices.isEmpty else { return nil }
if let preferredIndex {
if let forwardIndex = eligibleIndices.first(where: { $0 >= preferredIndex }) {
return activeStreams[forwardIndex].id
}
if let fallbackIndex = eligibleIndices.last {
return activeStreams[fallbackIndex].id
}
}
return activeStreams[eligibleIndices[0]].id
}
func buildStreamURL(for config: StreamConfig) async -> URL {
let startedAt = Date()
logGamesViewModel("buildStreamURL start mediaId=\(config.mediaId) resolution=\(config.resolution)")
@@ -623,8 +658,9 @@ final class GamesViewModel {
decoder.keyDecodingStrategy = .convertFromSnakeCase
let entries = try decoder.decode([RemoteVideoFeedEntry].self, from: data)
let urls = entries.compactMap { entry in
URL(string: entry.videoFile, relativeTo: feedURL)?.absoluteURL
let urls: [URL] = entries.compactMap { entry -> URL? in
guard let path = entry.hlsUrl ?? entry.videoFile else { return nil }
return URL(string: path, relativeTo: feedURL)?.absoluteURL
}
guard !urls.isEmpty else {
@@ -763,7 +799,6 @@ final class GamesViewModel {
func addMLBNetwork() async {
guard activeStreams.count < 4 else { return }
guard !activeStreams.contains(where: { $0.id == "MLBN" }) else { return }
let shouldCaptureAudio = activeStreams.isEmpty && audioFocusStreamID == nil
let dummyGame = Game(
id: "MLBN",
@@ -778,7 +813,7 @@ final class GamesViewModel {
id: "MLBN", game: dummyGame, label: "MLB Network", overrideURL: url
)
activeStreams.append(stream)
if shouldCaptureAudio {
if shouldCaptureAudio(for: stream) {
audioFocusStreamID = stream.id
}
syncAudioFocus()
@@ -818,4 +853,5 @@ struct ActiveStream: Identifiable, @unchecked Sendable {
var player: AVPlayer?
var isPlaying = false
var isMuted = false
var forceMuteAudio = false
}

View File

@@ -0,0 +1,39 @@
import SwiftUI
struct PlatformPressButtonStyle: ButtonStyle {
func makeBody(configuration: Configuration) -> some View {
configuration.label
.scaleEffect(configuration.isPressed ? 0.98 : 1.0)
.opacity(configuration.isPressed ? 0.92 : 1.0)
.animation(.easeOut(duration: 0.16), value: configuration.isPressed)
}
}
extension View {
@ViewBuilder
func platformCardStyle() -> some View {
#if os(tvOS)
self.buttonStyle(.card)
#else
self.buttonStyle(PlatformPressButtonStyle())
#endif
}
@ViewBuilder
func platformFocusSection() -> some View {
#if os(tvOS)
self.focusSection()
#else
self
#endif
}
@ViewBuilder
func platformFocusable(_ enabled: Bool = true) -> some View {
#if os(tvOS)
self.focusable(enabled)
#else
self
#endif
}
}

View File

@@ -11,14 +11,14 @@ private func logDashboard(_ message: String) {
enum SpecialPlaybackChannelConfig {
static let werkoutNSFWStreamID = "WKNSFW"
static let werkoutNSFWTitle = "Werkout NSFW"
static let werkoutNSFWSubtitle = "Authenticated private HLS feed"
static let werkoutNSFWFeedURLString = "https://dev.werkout.fitness/videos/nsfw_videos/"
static let werkoutNSFWAuthToken = "15d7565cde9e8c904ae934f8235f68f6a24b4a03"
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 werkoutNSFWTeamCode = "WK"
static var werkoutNSFWHeaders: [String: String] {
[
"authorization": "Token \(werkoutNSFWAuthToken)",
"Cookie": werkoutNSFWCookie,
]
}
@@ -50,15 +50,48 @@ enum SpecialPlaybackChannelConfig {
struct DashboardView: View {
@Environment(GamesViewModel.self) private var viewModel
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
@State private var selectedGame: Game?
@State private var fullScreenBroadcast: BroadcastSelection?
@State private var pendingFullScreenBroadcast: BroadcastSelection?
@State private var showMLBNetworkSheet = false
@State private var showWerkoutNSFWSheet = false
private var horizontalPadding: CGFloat {
#if os(iOS)
horizontalSizeClass == .compact ? 20 : 32
#else
60
#endif
}
private var verticalPadding: CGFloat {
#if os(iOS)
horizontalSizeClass == .compact ? 24 : 32
#else
40
#endif
}
private var contentSpacing: CGFloat {
#if os(iOS)
horizontalSizeClass == .compact ? 32 : 42
#else
50
#endif
}
private var shelfCardWidth: CGFloat {
#if os(iOS)
horizontalSizeClass == .compact ? 300 : 360
#else
400
#endif
}
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 50) {
VStack(alignment: .leading, spacing: contentSpacing) {
headerSection
if viewModel.isLoading {
@@ -111,8 +144,8 @@ struct DashboardView: View {
multiViewStatus
}
}
.padding(.horizontal, 60)
.padding(.vertical, 40)
.padding(.horizontal, horizontalPadding)
.padding(.vertical, verticalPadding)
}
.onAppear {
logDashboard("DashboardView appeared")
@@ -210,7 +243,8 @@ struct DashboardView: View {
}
return SingleStreamPlaybackSource(
url: url,
httpHeaders: SpecialPlaybackChannelConfig.werkoutNSFWHeaders
httpHeaders: SpecialPlaybackChannelConfig.werkoutNSFWHeaders,
forceMuteAudio: true
)
}
let stream = ActiveStream(
@@ -240,7 +274,8 @@ struct DashboardView: View {
}
return SingleStreamPlaybackSource(
url: nextURL,
httpHeaders: SpecialPlaybackChannelConfig.werkoutNSFWHeaders
httpHeaders: SpecialPlaybackChannelConfig.werkoutNSFWHeaders,
forceMuteAudio: true
)
}
@@ -303,10 +338,10 @@ struct DashboardView: View {
GameCardView(game: game) {
selectedGame = game
}
.frame(width: 400)
.frame(width: shelfCardWidth)
}
}
.padding(.vertical, 20)
.padding(.vertical, 12)
}
.scrollClipDisabled()
}
@@ -392,12 +427,19 @@ struct DashboardView: View {
@ViewBuilder
private var featuredChannelsSection: some View {
HStack(alignment: .top, spacing: 24) {
mlbNetworkCard
.frame(maxWidth: .infinity)
ViewThatFits {
HStack(alignment: .top, spacing: 24) {
mlbNetworkCard
.frame(maxWidth: .infinity)
nsfwVideosCard
.frame(maxWidth: .infinity)
nsfwVideosCard
.frame(maxWidth: .infinity)
}
VStack(alignment: .leading, spacing: 16) {
mlbNetworkCard
nsfwVideosCard
}
}
}
@@ -436,7 +478,7 @@ struct DashboardView: View {
.background(.regularMaterial)
.clipShape(RoundedRectangle(cornerRadius: 20))
}
.buttonStyle(.card)
.platformCardStyle()
}
@ViewBuilder
@@ -478,7 +520,7 @@ struct DashboardView: View {
.background(.regularMaterial)
.clipShape(RoundedRectangle(cornerRadius: 20))
}
.buttonStyle(.card)
.platformCardStyle()
}
// MARK: - Multi-View Status
@@ -528,6 +570,7 @@ struct BroadcastSelection: Identifiable {
struct WerkoutNSFWSheet: View {
var onWatchFullScreen: () -> Void
@Environment(GamesViewModel.self) private var viewModel
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
@Environment(\.dismiss) private var dismiss
@State private var isResolvingMultiViewSource = false
@State private var multiViewErrorMessage: String?
@@ -540,17 +583,41 @@ struct WerkoutNSFWSheet: View {
added || viewModel.activeStreams.count < 4
}
private var usesStackedLayout: Bool {
#if os(iOS)
horizontalSizeClass == .compact
#else
false
#endif
}
private var outerHorizontalPadding: CGFloat {
usesStackedLayout ? 20 : 94
}
private var outerVerticalPadding: CGFloat {
usesStackedLayout ? 24 : 70
}
var body: some View {
ZStack {
sheetBackground
.ignoresSafeArea()
HStack(alignment: .top, spacing: 32) {
overviewColumn
.frame(maxWidth: .infinity, alignment: .leading)
ViewThatFits {
HStack(alignment: .top, spacing: 32) {
overviewColumn
.frame(maxWidth: .infinity, alignment: .leading)
actionColumn
.frame(width: 360, alignment: .leading)
actionColumn
.frame(width: 360, alignment: .leading)
}
VStack(alignment: .leading, spacing: 24) {
overviewColumn
actionColumn
.frame(maxWidth: .infinity, alignment: .leading)
}
}
.padding(38)
.background(
@@ -561,8 +628,8 @@ struct WerkoutNSFWSheet: View {
.strokeBorder(.white.opacity(0.08), lineWidth: 1)
}
)
.padding(.horizontal, 94)
.padding(.vertical, 70)
.padding(.horizontal, outerHorizontalPadding)
.padding(.vertical, outerVerticalPadding)
}
}
@@ -711,7 +778,8 @@ struct WerkoutNSFWSheet: View {
label: SpecialPlaybackChannelConfig.werkoutNSFWTitle,
game: SpecialPlaybackChannelConfig.werkoutNSFWGame,
feedURL: SpecialPlaybackChannelConfig.werkoutNSFWFeedURL,
headers: SpecialPlaybackChannelConfig.werkoutNSFWHeaders
headers: SpecialPlaybackChannelConfig.werkoutNSFWHeaders,
forceMuteAudio: true
)
isResolvingMultiViewSource = false
if didAddStream {
@@ -770,7 +838,7 @@ struct WerkoutNSFWSheet: View {
.fill(fill)
)
}
.buttonStyle(.card)
.platformCardStyle()
.disabled(disabled)
}

View File

@@ -26,12 +26,20 @@ struct FeaturedGameCard: View {
var body: some View {
Button(action: onSelect) {
VStack(spacing: 0) {
HStack(alignment: .top, spacing: 28) {
matchupColumn
.frame(maxWidth: .infinity, alignment: .leading)
ViewThatFits {
HStack(alignment: .top, spacing: 28) {
matchupColumn
.frame(maxWidth: .infinity, alignment: .leading)
sidePanel
.frame(width: 760, alignment: .leading)
sidePanel
.frame(width: 760, alignment: .leading)
}
VStack(alignment: .leading, spacing: 24) {
matchupColumn
sidePanel
.frame(maxWidth: .infinity, alignment: .leading)
}
}
.padding(.horizontal, 34)
.padding(.top, 30)
@@ -60,7 +68,7 @@ struct FeaturedGameCard: View {
)
.shadow(color: shadowColor, radius: 24, y: 10)
}
.buttonStyle(.card)
.platformCardStyle()
}
@ViewBuilder

View File

@@ -62,7 +62,7 @@ struct GameCardView: View {
y: 8
)
}
.buttonStyle(.card)
.platformCardStyle()
}
@ViewBuilder

View File

@@ -76,7 +76,7 @@ struct GameCenterView: View {
.background(.white.opacity(0.08))
.clipShape(Capsule())
}
.buttonStyle(.card)
.platformCardStyle()
.disabled(game.gamePk == nil)
}
.padding(22)

View File

@@ -4,6 +4,7 @@ struct StreamOptionsSheet: View {
let game: Game
var onWatch: ((BroadcastSelection) -> Void)?
@Environment(GamesViewModel.self) private var viewModel
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
@Environment(\.dismiss) private var dismiss
private var awayColor: Color { TeamAssets.color(for: game.awayTeam.code) }
@@ -17,6 +18,22 @@ struct StreamOptionsSheet: View {
viewModel.activeStreams.count < 4
}
private var usesStackedLayout: Bool {
#if os(iOS)
horizontalSizeClass == .compact
#else
false
#endif
}
private var horizontalPadding: CGFloat {
usesStackedLayout ? 20 : 56
}
private var verticalPadding: CGFloat {
usesStackedLayout ? 24 : 42
}
private var awayPitcherName: String? {
guard let pitchers = game.pitchers else { return nil }
let parts = pitchers.components(separatedBy: " vs ")
@@ -32,12 +49,20 @@ struct StreamOptionsSheet: View {
var body: some View {
ScrollView {
VStack(spacing: 28) {
HStack(alignment: .top, spacing: 28) {
matchupColumn
.frame(maxWidth: .infinity, alignment: .leading)
ViewThatFits {
HStack(alignment: .top, spacing: 28) {
matchupColumn
.frame(maxWidth: .infinity, alignment: .leading)
actionRail
.frame(width: 520, alignment: .leading)
actionRail
.frame(width: 520, alignment: .leading)
}
VStack(alignment: .leading, spacing: 24) {
matchupColumn
actionRail
.frame(maxWidth: .infinity, alignment: .leading)
}
}
if !canAddMoreStreams {
@@ -53,8 +78,8 @@ struct StreamOptionsSheet: View {
.background(panelBackground)
}
}
.padding(.horizontal, 56)
.padding(.vertical, 42)
.padding(.horizontal, horizontalPadding)
.padding(.vertical, verticalPadding)
}
.background(sheetBackground.ignoresSafeArea())
}
@@ -470,7 +495,7 @@ struct StreamOptionsSheet: View {
.fill(fill)
)
}
.buttonStyle(.card)
.platformCardStyle()
.disabled(disabled)
}

View File

@@ -2,14 +2,28 @@ import SwiftUI
struct LeagueCenterView: View {
@Environment(GamesViewModel.self) private var gamesViewModel
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
@State private var viewModel = LeagueCenterViewModel()
@State private var selectedGame: Game?
private let rosterColumns = [
GridItem(.flexible(), spacing: 14),
GridItem(.flexible(), spacing: 14),
GridItem(.flexible(), spacing: 14),
]
private var rosterColumns: [GridItem] {
let columnCount: Int
#if os(iOS)
columnCount = horizontalSizeClass == .compact ? 1 : 2
#else
columnCount = 3
#endif
return Array(repeating: GridItem(.flexible(), spacing: 14), count: columnCount)
}
private var horizontalPadding: CGFloat {
#if os(iOS)
horizontalSizeClass == .compact ? 20 : 32
#else
56
#endif
}
var body: some View {
ScrollView {
@@ -40,7 +54,7 @@ struct LeagueCenterView: View {
messagePanel(playerErrorMessage, tint: .orange)
}
}
.padding(.horizontal, 56)
.padding(.horizontal, horizontalPadding)
.padding(.vertical, 40)
}
.background(screenBackground.ignoresSafeArea())
@@ -158,7 +172,7 @@ struct LeagueCenterView: View {
.padding(22)
.background(sectionPanel)
}
.buttonStyle(.card)
.platformCardStyle()
.disabled(linkedGame == nil)
}
@@ -218,7 +232,7 @@ struct LeagueCenterView: View {
}
.padding(.vertical, 4)
}
.focusSection()
.platformFocusSection()
.scrollClipDisabled()
}
}
@@ -306,12 +320,12 @@ struct LeagueCenterView: View {
.stroke(.white.opacity(0.08), lineWidth: 1)
)
}
.buttonStyle(.card)
.platformCardStyle()
}
}
.padding(.vertical, 4)
}
.focusSection()
.platformFocusSection()
.scrollClipDisabled()
}
}
@@ -356,7 +370,7 @@ struct LeagueCenterView: View {
.padding(24)
.background(sectionPanel)
.contentShape(RoundedRectangle(cornerRadius: 24, style: .continuous))
.focusable(true)
.platformFocusable()
}
}
}
@@ -391,7 +405,7 @@ struct LeagueCenterView: View {
.padding(16)
.background(sectionPanel)
}
.buttonStyle(.card)
.platformCardStyle()
}
}
}
@@ -482,7 +496,7 @@ struct LeagueCenterView: View {
.padding(24)
.background(sectionPanel)
.contentShape(RoundedRectangle(cornerRadius: 24, style: .continuous))
.focusable(true)
.platformFocusable()
}
}
}
@@ -580,7 +594,7 @@ struct LeagueCenterView: View {
.background(.white.opacity(0.08))
.clipShape(Capsule())
}
.buttonStyle(.card)
.platformCardStyle()
}
private func loadingPanel(title: String) -> some View {

View File

@@ -3,6 +3,7 @@ import SwiftUI
struct MLBNetworkSheet: View {
var onWatchFullScreen: () -> Void
@Environment(GamesViewModel.self) private var viewModel
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
@Environment(\.dismiss) private var dismiss
private var added: Bool {
@@ -13,17 +14,41 @@ struct MLBNetworkSheet: View {
added || viewModel.activeStreams.count < 4
}
private var usesStackedLayout: Bool {
#if os(iOS)
horizontalSizeClass == .compact
#else
false
#endif
}
private var outerHorizontalPadding: CGFloat {
usesStackedLayout ? 20 : 94
}
private var outerVerticalPadding: CGFloat {
usesStackedLayout ? 24 : 70
}
var body: some View {
ZStack {
sheetBackground
.ignoresSafeArea()
HStack(alignment: .top, spacing: 32) {
networkOverview
.frame(maxWidth: .infinity, alignment: .leading)
ViewThatFits {
HStack(alignment: .top, spacing: 32) {
networkOverview
.frame(maxWidth: .infinity, alignment: .leading)
actionColumn
.frame(width: 360, alignment: .leading)
actionColumn
.frame(width: 360, alignment: .leading)
}
VStack(alignment: .leading, spacing: 24) {
networkOverview
actionColumn
.frame(maxWidth: .infinity, alignment: .leading)
}
}
.padding(38)
.background(
@@ -34,8 +59,8 @@ struct MLBNetworkSheet: View {
.strokeBorder(.white.opacity(0.08), lineWidth: 1)
}
)
.padding(.horizontal, 94)
.padding(.vertical, 70)
.padding(.horizontal, outerHorizontalPadding)
.padding(.vertical, outerVerticalPadding)
}
}
@@ -209,7 +234,7 @@ struct MLBNetworkSheet: View {
.fill(fill)
)
}
.buttonStyle(.card)
.platformCardStyle()
.disabled(disabled)
}

View File

@@ -180,7 +180,7 @@ struct MultiStreamView: View {
destructive: false
)
}
.buttonStyle(.card)
.platformCardStyle()
Button {
viewModel.clearAllStreams()
@@ -192,7 +192,7 @@ struct MultiStreamView: View {
destructive: true
)
}
.buttonStyle(.card)
.platformCardStyle()
}
}
}
@@ -233,7 +233,9 @@ struct MultiStreamView: View {
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
@@ -250,35 +252,23 @@ private struct MultiViewCanvas: View {
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]
MultiStreamTile(
stream: stream,
position: index + 1,
isPrimary: viewModel.isPrimaryStream(stream.id),
isAudioFocused: viewModel.audioFocusStreamID == stream.id,
isFocused: focusedStreamID == stream.id,
showsPrimaryBadge: viewModel.multiViewLayoutMode == .spotlight
&& viewModel.isPrimaryStream(stream.id)
&& viewModel.activeStreams.count > 1,
videoGravity: videoGravity,
cornerRadius: cornerRadius,
onSelect: { onSelect(stream) }
)
.frame(width: frame.width, height: frame.height)
.position(x: frame.midX, y: frame.midY)
.focused($focusedStreamID, equals: stream.id)
tileView(for: stream, frame: frame, position: index + 1)
}
}
}
.focusSection()
.platformFocusSection()
#if os(tvOS)
.onMoveCommand { direction in
let currentID = focusedStreamID ?? viewModel.audioFocusStreamID ?? viewModel.activeStreams.first?.id
if let nextID = nextMultiViewFocusID(
@@ -289,7 +279,9 @@ private struct MultiViewCanvas: View {
focusedStreamID = nextID
}
}
#endif
}
#if os(tvOS)
.onAppear {
if focusedStreamID == nil {
focusedStreamID = viewModel.audioFocusStreamID ?? viewModel.activeStreams.first?.id
@@ -307,6 +299,41 @@ private struct MultiViewCanvas: View {
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
}
}
@@ -404,9 +431,8 @@ private struct MultiStreamTile: View {
radius: isFocused ? 26 : 20,
y: 10
)
.focusEffectDisabled()
.focusable(true)
.animation(.easeOut(duration: 0.18), value: isFocused)
.platformFocusable()
.onAppear {
logMultiView("tile appeared id=\(stream.id) label=\(stream.label)")
}
@@ -418,6 +444,8 @@ private struct MultiStreamTile: View {
qualityUpgradeTask = nil
playbackDiagnostics.clear(streamID: stream.id, reason: "tile disappeared")
}
#if os(tvOS)
.focusEffectDisabled()
.onPlayPauseCommand {
if player?.rate == 0 {
player?.play()
@@ -425,6 +453,7 @@ private struct MultiStreamTile: View {
player?.pause()
}
}
#endif
.onTapGesture {
onSelect()
}
@@ -507,7 +536,7 @@ private struct MultiStreamTile: View {
)
if let player {
player.isMuted = viewModel.audioFocusStreamID != stream.id
player.isMuted = stream.forceMuteAudio || viewModel.audioFocusStreamID != stream.id
playbackDiagnostics.attach(
to: player,
streamID: stream.id,
@@ -529,7 +558,7 @@ private struct MultiStreamTile: View {
}
if let existingPlayer = stream.player {
existingPlayer.isMuted = viewModel.audioFocusStreamID != stream.id
existingPlayer.isMuted = stream.forceMuteAudio || viewModel.audioFocusStreamID != stream.id
self.player = existingPlayer
hasError = false
playbackDiagnostics.attach(
@@ -725,12 +754,10 @@ private struct MultiStreamTile: View {
.value
}
private func playbackEndedHandler(for player: AVPlayer) -> (() -> Void)? {
private func playbackEndedHandler(for player: AVPlayer) -> (@MainActor @Sendable () async -> Void)? {
guard stream.id == SpecialPlaybackChannelConfig.werkoutNSFWStreamID else { return nil }
return {
Task { @MainActor in
await playNextWerkoutClip(on: player)
}
await playNextWerkoutClip(on: player)
}
}
@@ -832,7 +859,7 @@ private final class MultiStreamPlaybackDiagnostics: ObservableObject {
to player: AVPlayer,
streamID: String,
label: String,
onPlaybackEnded: (() -> Void)? = nil
onPlaybackEnded: (@MainActor @Sendable () async -> Void)? = nil
) {
let playerIdentifier = ObjectIdentifier(player)
let itemIdentifier = player.currentItem.map { ObjectIdentifier($0) }
@@ -922,7 +949,10 @@ private final class MultiStreamPlaybackDiagnostics: ObservableObject {
queue: .main
) { _ in
logMultiView("playerItem didPlayToEnd id=\(streamID)")
onPlaybackEnded?()
guard let onPlaybackEnded else { return }
Task { @MainActor in
await onPlaybackEnded()
}
}
)
@@ -1002,7 +1032,7 @@ private struct MultiViewLayoutPicker: View {
.stroke(viewModel.multiViewLayoutMode == mode ? .blue.opacity(0.9) : .white.opacity(0.12), lineWidth: 1)
)
}
.buttonStyle(.card)
.platformCardStyle()
}
}
}
@@ -1027,6 +1057,10 @@ struct StreamControlSheet: View {
viewModel.audioFocusStreamID == streamID
}
private var forceMuteAudio: Bool {
stream?.forceMuteAudio == true
}
var body: some View {
ZStack {
LinearGradient(
@@ -1052,18 +1086,21 @@ struct StreamControlSheet: View {
HStack(spacing: 10) {
controlBadge(title: isPrimary ? "Primary Tile" : "Secondary Tile", tint: .blue)
controlBadge(title: isAudioFocused ? "Live Audio" : "Muted", tint: isAudioFocused ? .green : .white)
controlBadge(
title: forceMuteAudio ? "Video Only" : (isAudioFocused ? "Live Audio" : "Muted"),
tint: forceMuteAudio ? .orange : (isAudioFocused ? .green : .white)
)
}
}
VStack(spacing: 14) {
HStack(spacing: 14) {
actionCard(
title: isAudioFocused ? "Mute All" : "Listen Here",
subtitle: isAudioFocused ? "Silence the multiview mix." : "Route game audio to this tile.",
icon: isAudioFocused ? "speaker.slash.fill" : "speaker.wave.2.fill",
tint: isAudioFocused ? .white : .green,
disabled: false
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)
}
@@ -1181,7 +1218,7 @@ struct StreamControlSheet: View {
)
}
.disabled(disabled)
.buttonStyle(.card)
.platformCardStyle()
}
}
@@ -1210,9 +1247,23 @@ struct MultiStreamFullScreenView: View {
.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()
@@ -1308,6 +1359,7 @@ private struct MultiViewFocusEntry {
let frame: CGRect
}
#if os(tvOS)
private func nextMultiViewFocusID(
from currentID: String?,
direction: MoveCommandDirection,
@@ -1357,3 +1409,4 @@ private func nextMultiViewFocusID(
.entry
.streamID
}
#endif

View File

@@ -76,6 +76,7 @@ private func makeSingleStreamPlayerItem(from source: SingleStreamPlaybackSource)
}
struct SingleStreamPlaybackScreen: View {
@Environment(\.dismiss) private var dismiss
let resolveSource: @Sendable () async -> SingleStreamPlaybackSource?
var resolveNextSource: (@Sendable (URL?) async -> SingleStreamPlaybackSource?)? = nil
let tickerGames: [Game]
@@ -90,6 +91,18 @@ struct SingleStreamPlaybackScreen: View {
.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)
}
#endif
}
.ignoresSafeArea()
.onAppear {
logSingleStream("SingleStreamPlaybackScreen appeared tickerGames=\(tickerGames.count) tickerMode=marqueeOverlay")
@@ -103,10 +116,12 @@ struct SingleStreamPlaybackScreen: View {
struct SingleStreamPlaybackSource: Sendable {
let url: URL
let httpHeaders: [String: String]
let forceMuteAudio: Bool
init(url: URL, httpHeaders: [String: String] = [:]) {
init(url: URL, httpHeaders: [String: String] = [:], forceMuteAudio: Bool = false) {
self.url = url
self.httpHeaders = httpHeaders
self.forceMuteAudio = forceMuteAudio
}
}
@@ -285,6 +300,9 @@ struct SingleStreamPlayerView: UIViewControllerRepresentable {
let controller = AVPlayerViewController()
controller.allowsPictureInPicturePlayback = true
controller.showsPlaybackControls = true
#if os(iOS)
controller.canStartPictureInPictureAutomaticallyFromInline = true
#endif
logSingleStream("AVPlayerViewController configured without contentOverlayView ticker")
Task { @MainActor in
@@ -311,6 +329,7 @@ struct SingleStreamPlayerView: UIViewControllerRepresentable {
let playerItem = makeSingleStreamPlayerItem(from: source)
let player = AVPlayer(playerItem: playerItem)
player.automaticallyWaitsToMinimizeStalling = false
player.isMuted = source.forceMuteAudio
logSingleStream("Configured player for fast start preferredForwardBufferDuration=2 automaticallyWaitsToMinimizeStalling=false")
context.coordinator.attachDebugObservers(to: player, url: url, resolveNextSource: resolveNextSource)
controller.player = player
@@ -433,6 +452,7 @@ struct SingleStreamPlayerView: UIViewControllerRepresentable {
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)
logSingleStream("Autoplay replacing item and replaying url=\(singleStreamDebugURLDescription(nextSource.url))")
player.playImmediately(atRate: 1.0)