From fda809fd2f0b09103d3316b66f9fa9a55248011e Mon Sep 17 00:00:00 2001 From: Trey t Date: Mon, 30 Mar 2026 21:30:28 -0500 Subject: [PATCH] Add iOS/iPad target with platform-adaptive UI Co-Authored-By: Claude Opus 4.6 (1M context) --- .../AccentColor.colorset/Contents.json | 11 + .../AppIcon.appiconset/Contents.json | 8 + mlbIOS/Assets.xcassets/Contents.json | 6 + mlbIOS/Info.plist | 42 ++++ mlbIOS/Views/mlbIOSRootView.swift | 106 +++++++++ mlbIOS/mlbIOS.entitlements | 6 + mlbIOS/mlbIOSApp.swift | 26 +++ mlbTVOS.xcodeproj/project.pbxproj | 211 +++++++++++++++++- mlbTVOS/Services/MLBServerAPI.swift | 4 +- mlbTVOS/ViewModels/GamesViewModel.swift | 82 +++++-- mlbTVOS/Views/Components/PlatformUI.swift | 39 ++++ mlbTVOS/Views/DashboardView.swift | 122 +++++++--- mlbTVOS/Views/FeaturedGameCard.swift | 20 +- mlbTVOS/Views/GameCardView.swift | 2 +- mlbTVOS/Views/GameCenterView.swift | 2 +- mlbTVOS/Views/GameListView.swift | 41 +++- mlbTVOS/Views/LeagueCenterView.swift | 42 ++-- mlbTVOS/Views/MLBNetworkSheet.swift | 41 +++- mlbTVOS/Views/MultiStreamView.swift | 127 ++++++++--- mlbTVOS/Views/SingleStreamPlayerView.swift | 22 +- project.yml | 20 ++ 21 files changed, 851 insertions(+), 129 deletions(-) create mode 100644 mlbIOS/Assets.xcassets/AccentColor.colorset/Contents.json create mode 100644 mlbIOS/Assets.xcassets/AppIcon.appiconset/Contents.json create mode 100644 mlbIOS/Assets.xcassets/Contents.json create mode 100644 mlbIOS/Info.plist create mode 100644 mlbIOS/Views/mlbIOSRootView.swift create mode 100644 mlbIOS/mlbIOS.entitlements create mode 100644 mlbIOS/mlbIOSApp.swift create mode 100644 mlbTVOS/Views/Components/PlatformUI.swift diff --git a/mlbIOS/Assets.xcassets/AccentColor.colorset/Contents.json b/mlbIOS/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/mlbIOS/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/mlbIOS/Assets.xcassets/AppIcon.appiconset/Contents.json b/mlbIOS/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..31fbaca --- /dev/null +++ b/mlbIOS/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,8 @@ +{ + "images" : [ + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/mlbIOS/Assets.xcassets/Contents.json b/mlbIOS/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/mlbIOS/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/mlbIOS/Info.plist b/mlbIOS/Info.plist new file mode 100644 index 0000000..b023b10 --- /dev/null +++ b/mlbIOS/Info.plist @@ -0,0 +1,42 @@ + + + + + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + NSAppTransportSecurity + + NSAllowsLocalNetworking + + + UILaunchScreen + + UIBackgroundModes + + audio + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + + diff --git a/mlbIOS/Views/mlbIOSRootView.swift b/mlbIOS/Views/mlbIOSRootView.swift new file mode 100644 index 0000000..b9027d9 --- /dev/null +++ b/mlbIOS/Views/mlbIOSRootView.swift @@ -0,0 +1,106 @@ +import SwiftUI + +private enum mlbIOSSection: String, CaseIterable, Identifiable { + case games + case league + case multiView + case settings + + var id: String { rawValue } + + var title: String { + switch self { + case .games: "Games" + case .league: "League" + case .multiView: "Multi-View" + case .settings: "Settings" + } + } + + var systemImage: String { + switch self { + case .games: "sportscourt.fill" + case .league: "list.bullet.rectangle.portrait.fill" + case .multiView: "rectangle.split.2x2.fill" + case .settings: "gearshape.fill" + } + } +} + +struct mlbIOSRootView: View { + @Environment(GamesViewModel.self) private var viewModel + @Environment(\.horizontalSizeClass) private var horizontalSizeClass + @State private var selectedSection: mlbIOSSection? = .games + + private var usesCompactTabs: Bool { + horizontalSizeClass == .compact + } + + private var multiViewTitle: String { + let count = viewModel.activeStreams.count + return count > 0 ? "Multi-View (\(count))" : "Multi-View" + } + + var body: some View { + Group { + if usesCompactTabs { + compactTabs + } else { + splitView + } + } + .task { + if viewModel.games.isEmpty, !viewModel.isLoading { + await viewModel.loadGames() + } + } + } + + private var compactTabs: some View { + TabView { + Tab("Games", systemImage: "sportscourt.fill") { + DashboardView() + } + Tab("League", systemImage: "list.bullet.rectangle.portrait.fill") { + LeagueCenterView() + } + Tab(multiViewTitle, systemImage: "rectangle.split.2x2.fill") { + MultiStreamView() + } + Tab("Settings", systemImage: "gearshape.fill") { + SettingsView() + } + } + } + + private var splitView: some View { + NavigationSplitView { + List(mlbIOSSection.allCases, selection: $selectedSection) { section in + Label(section.title, systemImage: section.systemImage) + .tag(section) + } + .navigationTitle("MLB") + } detail: { + NavigationStack { + detailView(for: selectedSection ?? .games) + .navigationTitle(selectedSection?.title ?? mlbIOSSection.games.title) + .navigationBarTitleDisplayMode(.inline) + } + } + .navigationSplitViewStyle(.balanced) + } + + @ViewBuilder + private func detailView(for section: mlbIOSSection) -> some View { + switch section { + case .games: + DashboardView() + case .league: + LeagueCenterView() + case .multiView: + MultiStreamView() + case .settings: + SettingsView() + } + } +} diff --git a/mlbIOS/mlbIOS.entitlements b/mlbIOS/mlbIOS.entitlements new file mode 100644 index 0000000..6631ffa --- /dev/null +++ b/mlbIOS/mlbIOS.entitlements @@ -0,0 +1,6 @@ + + + + + + diff --git a/mlbIOS/mlbIOSApp.swift b/mlbIOS/mlbIOSApp.swift new file mode 100644 index 0000000..0108249 --- /dev/null +++ b/mlbIOS/mlbIOSApp.swift @@ -0,0 +1,26 @@ +import AVFoundation +import SwiftUI + +@main +struct mlbIOSApp: App { + @State private var viewModel = GamesViewModel() + + init() { + configureAudioSession() + } + + var body: some Scene { + WindowGroup { + mlbIOSRootView() + .environment(viewModel) + } + } + + private func configureAudioSession() { + do { + try AVAudioSession.sharedInstance().setCategory(.ambient) + } catch { + print("Failed to set audio session: \(error)") + } + } +} diff --git a/mlbTVOS.xcodeproj/project.pbxproj b/mlbTVOS.xcodeproj/project.pbxproj index 9b63c9e..9bc531c 100644 --- a/mlbTVOS.xcodeproj/project.pbxproj +++ b/mlbTVOS.xcodeproj/project.pbxproj @@ -7,32 +7,62 @@ objects = { /* Begin PBXBuildFile section */ + 02BA6240A20E957B5337A545 /* Game.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1ABC999C711B074CF0B536DD /* Game.swift */; }; 051C3D14A06061D44E325FCC /* LinescoreView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 327A33F2676712C2B5B4AF2F /* LinescoreView.swift */; }; 0B1C43E83CA856543F58E16A /* MultiStreamView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D10F2D91602CE1FB6D7342E /* MultiStreamView.swift */; }; + 11640185C392B0406BBE619D /* MLBServerAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 70E610D24BA83FB2F7490B00 /* MLBServerAPI.swift */; }; + 1555D223058B858E4C6F419D /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FB99FBEDA096B95C3D2E5B4 /* ContentView.swift */; }; 1AA11AA11AA11AA11AA11AA1 /* GameCenterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2BB22BB22BB22BB22BB22BB1 /* GameCenterView.swift */; }; 1AA11AA11AA11AA11AA11AA2 /* LeagueCenterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2BB22BB22BB22BB22BB22BB2 /* LeagueCenterView.swift */; }; 1AA11AA11AA11AA11AA11AA3 /* GameCenterViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2BB22BB22BB22BB22BB22BB3 /* GameCenterViewModel.swift */; }; 1AA11AA11AA11AA11AA11AA4 /* LeagueCenterViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2BB22BB22BB22BB22BB22BB4 /* LeagueCenterViewModel.swift */; }; 1E31C6F500CEEA5284081D22 /* MLBServerAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 70E610D24BA83FB2F7490B00 /* MLBServerAPI.swift */; }; + 1E5405E1C27A16971961091C /* ScoreOverlayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5F6A7B8C9D0E1F2A3B4C5D6 /* ScoreOverlayView.swift */; }; + 22B1A4EB0E8E279B054FEC3E /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E04B78C78BA9DA73B62AA3EE /* Foundation.framework */; }; 24C6D5F4A3B291807F6E5D4C /* ScoresTickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 24C6D5F4A3B291807F6E5D4B /* ScoresTickerView.swift */; }; 29627CA5EE85BA103B38D1F5 /* Game.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1ABC999C711B074CF0B536DD /* Game.swift */; }; + 31353216A6288D986544F61E /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DE9E708FA75ABB9F49D2392 /* SettingsView.swift */; }; 342EED600E1A5B4A3D083E50 /* TeamAssets.swift in Sources */ = {isa = PBXBuildFile; fileRef = 645E77DD8183D54EFC97CFB5 /* TeamAssets.swift */; }; + 37BFF8231552B49E4775AA59 /* GamesViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9BD2DEA6BBA55E86B2555099 /* GamesViewModel.swift */; }; + 3FB9BCB07B6DBFE6D1D2C523 /* GameCenterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2BB22BB22BB22BB22BB22BB1 /* GameCenterView.swift */; }; + 4318098424F9B3FF3B256EED /* LeagueCenterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2BB22BB22BB22BB22BB22BB2 /* LeagueCenterView.swift */; }; + 43AC37B4F957CDC3986F044A /* mlbIOSApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0209C78036DA08FA794D8D9 /* mlbIOSApp.swift */; }; + 43B16E04582A874E87514C30 /* mlbIOSRootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F874FDD20F82AFEB475CD73 /* mlbIOSRootView.swift */; }; 4F67D44E9A36BED9CD8724CF /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FB99FBEDA096B95C3D2E5B4 /* ContentView.swift */; }; 52498A59FF923A1494A0139B /* GameListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B5D51D6E4990515411D3BCD /* GameListView.swift */; }; + 5A76A7BB97FFA089156A0ABA /* PlatformUI.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBA78A938A07E07B064CEA54 /* PlatformUI.swift */; }; 5DFD3805A76DDE49B0DC1E2D /* SingleStreamPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA4AF68CDF4FC8D07194B4D0 /* SingleStreamPlayerView.swift */; }; 63AF55498896C9BBCD1D4337 /* GameCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E21E3115B8A9B4C798ECE828 /* GameCardView.swift */; }; + 7D2D21DAE73958578FE2E730 /* LeagueCenterViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2BB22BB22BB22BB22BB22BB4 /* LeagueCenterViewModel.swift */; }; 80A690C52C9C8140E7C519E8 /* TeamLogoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2E99F69727800D0CB795503 /* TeamLogoView.swift */; }; 820FB03D0E97C521BA239071 /* MLBNetworkSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5152ABCC9AACFD91BAC8C95B /* MLBNetworkSheet.swift */; }; + 82DEB54FF3BFD51A18ED98A1 /* MLBStatsAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6067FE4F340FC0DCCFA62C67 /* MLBStatsAPI.swift */; }; + 8D5FE07166FAD17836A0AFF4 /* PitcherHeadshotView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4E5F6A7B8C9D0E1F2A3B4C5 /* PitcherHeadshotView.swift */; }; 9D29D3508CD05CB3F4975910 /* GamesViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9BD2DEA6BBA55E86B2555099 /* GamesViewModel.swift */; }; A1B2C3D4E5F60718091A2B3C /* PitcherHeadshotView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4E5F6A7B8C9D0E1F2A3B4C5 /* PitcherHeadshotView.swift */; }; + A506FDC86C3A78F4A2C04C10 /* GameListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B5D51D6E4990515411D3BCD /* GameListView.swift */; }; + AFA76F6EBE7977914A849447 /* FeaturedGameCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 102E5E8674E188A04637D3EB /* FeaturedGameCard.swift */; }; + B215941F5A269C59C0939958 /* TeamLogoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2E99F69727800D0CB795503 /* TeamLogoView.swift */; }; B2C3D4E5F6A70819A1B2C3D4 /* ScoreOverlayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5F6A7B8C9D0E1F2A3B4C5D6 /* ScoreOverlayView.swift */; }; + B921B07F43A13CBBDE9C499D /* GameCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E21E3115B8A9B4C798ECE828 /* GameCardView.swift */; }; + BA1B3240360F79FE2DF42543 /* LinescoreView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 327A33F2676712C2B5B4AF2F /* LinescoreView.swift */; }; + BB48CC4C732672536CD5EF96 /* TeamAssets.swift in Sources */ = {isa = PBXBuildFile; fileRef = 645E77DD8183D54EFC97CFB5 /* TeamAssets.swift */; }; C1DA213471DC246B8DF3F840 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = AC5BCB2E1DA0EC63E422B113 /* Assets.xcassets */; }; + C67442678865108796537BBC /* GameCenterViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2BB22BB22BB22BB22BB22BB3 /* GameCenterViewModel.swift */; }; + CA20CF293D75E6058E0CB78C /* SingleStreamPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA4AF68CDF4FC8D07194B4D0 /* SingleStreamPlayerView.swift */; }; + D199D622D64E3489E049B04A /* LiveIndicator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 81584770090BAFBEF3ABE6FD /* LiveIndicator.swift */; }; + D48041F6CFBCEB2F3492747F /* MultiStreamView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D10F2D91602CE1FB6D7342E /* MultiStreamView.swift */; }; + DA156E98ACD90B582F3BBAB2 /* PlatformUI.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBA78A938A07E07B064CEA54 /* PlatformUI.swift */; }; + DB6E6A4890D0D22526DA5D96 /* ScoresTickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 24C6D5F4A3B291807F6E5D4B /* ScoresTickerView.swift */; }; DC37555DE8C367E929974484 /* MLBStatsAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6067FE4F340FC0DCCFA62C67 /* MLBStatsAPI.swift */; }; E7FB366456557069990F550C /* LiveIndicator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 81584770090BAFBEF3ABE6FD /* LiveIndicator.swift */; }; F36A71E407BC707B8E03457D /* DashboardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5EA4C33F029665B80F1A6B6D /* DashboardView.swift */; }; + F60F24246B08223DC2BA5825 /* DashboardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5EA4C33F029665B80F1A6B6D /* DashboardView.swift */; }; + F761076F826FEB86E972D516 /* MLBNetworkSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5152ABCC9AACFD91BAC8C95B /* MLBNetworkSheet.swift */; }; FAFA9B29273C4D8A54CD5916 /* FeaturedGameCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 102E5E8674E188A04637D3EB /* FeaturedGameCard.swift */; }; FD31074216AC0115B3F1C057 /* mlbTVOSApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 45142562E644AF04F7719083 /* mlbTVOSApp.swift */; }; FD9322BED3A2B827CB021163 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DE9E708FA75ABB9F49D2392 /* SettingsView.swift */; }; + FE80BDA15CC8CE2F18DB4883 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 56358D0D5DC5CC531034287F /* Assets.xcassets */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -46,10 +76,13 @@ 2BB22BB22BB22BB22BB22BB3 /* GameCenterViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameCenterViewModel.swift; sourceTree = ""; }; 2BB22BB22BB22BB22BB22BB4 /* LeagueCenterViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LeagueCenterViewModel.swift; sourceTree = ""; }; 327A33F2676712C2B5B4AF2F /* LinescoreView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinescoreView.swift; sourceTree = ""; }; + 3F874FDD20F82AFEB475CD73 /* mlbIOSRootView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = mlbIOSRootView.swift; sourceTree = ""; }; 3FB99FBEDA096B95C3D2E5B4 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; 45142562E644AF04F7719083 /* mlbTVOSApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = mlbTVOSApp.swift; sourceTree = ""; }; 4DE9E708FA75ABB9F49D2392 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; 5152ABCC9AACFD91BAC8C95B /* MLBNetworkSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MLBNetworkSheet.swift; sourceTree = ""; }; + 55A3DC0B5087BAD6BC59B35A /* mlbIOS.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = mlbIOS.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 56358D0D5DC5CC531034287F /* Assets.xcassets */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 5EA4C33F029665B80F1A6B6D /* DashboardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashboardView.swift; sourceTree = ""; }; 6067FE4F340FC0DCCFA62C67 /* MLBStatsAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MLBStatsAPI.swift; sourceTree = ""; }; 645E77DD8183D54EFC97CFB5 /* TeamAssets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeamAssets.swift; sourceTree = ""; }; @@ -57,17 +90,54 @@ 766441BC900073529EE93D69 /* mlbTVOS.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = mlbTVOS.app; sourceTree = BUILT_PRODUCTS_DIR; }; 81584770090BAFBEF3ABE6FD /* LiveIndicator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveIndicator.swift; sourceTree = ""; }; 8D10F2D91602CE1FB6D7342E /* MultiStreamView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultiStreamView.swift; sourceTree = ""; }; + 95C4DEDEAA64C74AF3DB24F2 /* mlbIOS.entitlements */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.entitlements; path = mlbIOS.entitlements; sourceTree = ""; }; 9BD2DEA6BBA55E86B2555099 /* GamesViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GamesViewModel.swift; sourceTree = ""; }; + A6FE9AFE34BB8CE4F33B62D0 /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; AC5BCB2E1DA0EC63E422B113 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + BBA78A938A07E07B064CEA54 /* PlatformUI.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PlatformUI.swift; sourceTree = ""; }; C1F593C51BC11444A3D514D3 /* mlbTVOS.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = mlbTVOS.entitlements; sourceTree = ""; }; C2E99F69727800D0CB795503 /* TeamLogoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeamLogoView.swift; sourceTree = ""; }; D4E5F6A7B8C9D0E1F2A3B4C5 /* PitcherHeadshotView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PitcherHeadshotView.swift; sourceTree = ""; }; + E04B78C78BA9DA73B62AA3EE /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS14.0.sdk/System/Library/Frameworks/Foundation.framework; sourceTree = DEVELOPER_DIR; }; E21E3115B8A9B4C798ECE828 /* GameCardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameCardView.swift; sourceTree = ""; }; E5F6A7B8C9D0E1F2A3B4C5D6 /* ScoreOverlayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScoreOverlayView.swift; sourceTree = ""; }; + F0209C78036DA08FA794D8D9 /* mlbIOSApp.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = mlbIOSApp.swift; sourceTree = ""; }; FA4AF68CDF4FC8D07194B4D0 /* SingleStreamPlayerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SingleStreamPlayerView.swift; sourceTree = ""; }; /* End PBXFileReference section */ +/* Begin PBXFrameworksBuildPhase section */ + EF6C5375C48E5C8359230651 /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + 22B1A4EB0E8E279B054FEC3E /* Foundation.framework in Frameworks */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + /* Begin PBXGroup section */ + 0B56828FEC5E5902FE078143 /* iOS */ = { + isa = PBXGroup; + children = ( + E04B78C78BA9DA73B62AA3EE /* Foundation.framework */, + ); + name = iOS; + sourceTree = ""; + }; + 1646E84AFB7C540A2D228B64 /* mlbIOS */ = { + isa = PBXGroup; + children = ( + 96E8DA91A0CDD10D8953C8BA /* Views */, + A6FE9AFE34BB8CE4F33B62D0 /* Info.plist */, + 95C4DEDEAA64C74AF3DB24F2 /* mlbIOS.entitlements */, + 56358D0D5DC5CC531034287F /* Assets.xcassets */, + F0209C78036DA08FA794D8D9 /* mlbIOSApp.swift */, + ); + name = mlbIOS; + path = mlbIOS; + sourceTree = ""; + }; 2C171EA64FFA962254F20583 /* ViewModels */ = { isa = PBXGroup; children = ( @@ -93,6 +163,14 @@ path = mlbTVOS; sourceTree = ""; }; + 5AE491A6B2C1767EFEF5CE88 /* Frameworks */ = { + isa = PBXGroup; + children = ( + 0B56828FEC5E5902FE078143 /* iOS */, + ); + name = Frameworks; + sourceTree = ""; + }; 62F95C3E8B0F6AFE3BEDE9C3 /* Components */ = { isa = PBXGroup; children = ( @@ -102,6 +180,7 @@ 24C6D5F4A3B291807F6E5D4B /* ScoresTickerView.swift */, E5F6A7B8C9D0E1F2A3B4C5D6 /* ScoreOverlayView.swift */, C2E99F69727800D0CB795503 /* TeamLogoView.swift */, + BBA78A938A07E07B064CEA54 /* PlatformUI.swift */, ); path = Components; sourceTree = ""; @@ -111,6 +190,8 @@ children = ( 4FAE5C2D72CD78BF7C15C4FC /* mlbTVOS */, 88274023705E1F09B25F86FF /* Products */, + 1646E84AFB7C540A2D228B64 /* mlbIOS */, + 5AE491A6B2C1767EFEF5CE88 /* Frameworks */, ); sourceTree = ""; }; @@ -118,10 +199,20 @@ isa = PBXGroup; children = ( 766441BC900073529EE93D69 /* mlbTVOS.app */, + 55A3DC0B5087BAD6BC59B35A /* mlbIOS.app */, ); name = Products; sourceTree = ""; }; + 96E8DA91A0CDD10D8953C8BA /* Views */ = { + isa = PBXGroup; + children = ( + 3F874FDD20F82AFEB475CD73 /* mlbIOSRootView.swift */, + ); + name = Views; + path = Views; + sourceTree = ""; + }; C6DC4CC5AAB9AF84D3CB9F3B /* Services */ = { isa = PBXGroup; children = ( @@ -174,12 +265,27 @@ dependencies = ( ); name = mlbTVOS; - packageProductDependencies = ( - ); productName = mlbTVOS; productReference = 766441BC900073529EE93D69 /* mlbTVOS.app */; productType = "com.apple.product-type.application"; }; + 9C5FE08A60985D23E089CEE5 /* mlbIOS */ = { + isa = PBXNativeTarget; + buildConfigurationList = 67C1F966BDD84456877DC373 /* Build configuration list for PBXNativeTarget "mlbIOS" */; + buildPhases = ( + 9386CC819A80738E79744F6A /* Sources */, + EF6C5375C48E5C8359230651 /* Frameworks */, + 3CEE2E3AB4AAF6D8CC0F12D7 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = mlbIOS; + productName = mlbIOS; + productReference = 55A3DC0B5087BAD6BC59B35A /* mlbIOS.app */; + productType = "com.apple.product-type.application"; + }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -199,15 +305,25 @@ ); mainGroup = 86C1117ADD32A1A57AA32A08; minimizedProjectReferenceProxies = 1; + productRefGroup = 88274023705E1F09B25F86FF /* Products */; projectDirPath = ""; projectRoot = ""; targets = ( 6ACA532AEDED1D5945D6153A /* mlbTVOS */, + 9C5FE08A60985D23E089CEE5 /* mlbIOS */, ); }; /* End PBXProject section */ /* Begin PBXResourcesBuildPhase section */ + 3CEE2E3AB4AAF6D8CC0F12D7 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + FE80BDA15CC8CE2F18DB4883 /* Assets.xcassets in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; 5C7F99E1451B54AD3CC382CB /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -219,6 +335,40 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ + 9386CC819A80738E79744F6A /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 02BA6240A20E957B5337A545 /* Game.swift in Sources */, + BB48CC4C732672536CD5EF96 /* TeamAssets.swift in Sources */, + 11640185C392B0406BBE619D /* MLBServerAPI.swift in Sources */, + 82DEB54FF3BFD51A18ED98A1 /* MLBStatsAPI.swift in Sources */, + C67442678865108796537BBC /* GameCenterViewModel.swift in Sources */, + 37BFF8231552B49E4775AA59 /* GamesViewModel.swift in Sources */, + 7D2D21DAE73958578FE2E730 /* LeagueCenterViewModel.swift in Sources */, + BA1B3240360F79FE2DF42543 /* LinescoreView.swift in Sources */, + D199D622D64E3489E049B04A /* LiveIndicator.swift in Sources */, + 8D5FE07166FAD17836A0AFF4 /* PitcherHeadshotView.swift in Sources */, + DA156E98ACD90B582F3BBAB2 /* PlatformUI.swift in Sources */, + 1E5405E1C27A16971961091C /* ScoreOverlayView.swift in Sources */, + DB6E6A4890D0D22526DA5D96 /* ScoresTickerView.swift in Sources */, + B215941F5A269C59C0939958 /* TeamLogoView.swift in Sources */, + 1555D223058B858E4C6F419D /* ContentView.swift in Sources */, + F60F24246B08223DC2BA5825 /* DashboardView.swift in Sources */, + AFA76F6EBE7977914A849447 /* FeaturedGameCard.swift in Sources */, + B921B07F43A13CBBDE9C499D /* GameCardView.swift in Sources */, + 3FB9BCB07B6DBFE6D1D2C523 /* GameCenterView.swift in Sources */, + A506FDC86C3A78F4A2C04C10 /* GameListView.swift in Sources */, + 4318098424F9B3FF3B256EED /* LeagueCenterView.swift in Sources */, + F761076F826FEB86E972D516 /* MLBNetworkSheet.swift in Sources */, + D48041F6CFBCEB2F3492747F /* MultiStreamView.swift in Sources */, + 31353216A6288D986544F61E /* SettingsView.swift in Sources */, + CA20CF293D75E6058E0CB78C /* SingleStreamPlayerView.swift in Sources */, + 43AC37B4F957CDC3986F044A /* mlbIOSApp.swift in Sources */, + 43B16E04582A874E87514C30 /* mlbIOSRootView.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; C959C957D7367748B74B83ED /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -248,6 +398,7 @@ 342EED600E1A5B4A3D083E50 /* TeamAssets.swift in Sources */, 80A690C52C9C8140E7C519E8 /* TeamLogoView.swift in Sources */, FD31074216AC0115B3F1C057 /* mlbTVOSApp.swift in Sources */, + 5A76A7BB97FFA089156A0ABA /* PlatformUI.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -318,6 +469,53 @@ }; name = Debug; }; + 0D8A815004F8CC601240D2DB /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CLANG_ENABLE_OBJC_WEAK = NO; + CODE_SIGN_ENTITLEMENTS = mlbIOS/mlbIOS.entitlements; + DEVELOPMENT_TEAM = ""; + INFOPLIST_FILE = mlbIOS/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.treyt.mlbIOS; + PRODUCT_NAME = mlbIOS; + SDKROOT = iphoneos; + SWIFT_STRICT_CONCURRENCY = complete; + SWIFT_VERSION = 6.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 1FC82661BC31026C62934BD7 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CLANG_ENABLE_OBJC_WEAK = NO; + CODE_SIGN_ENTITLEMENTS = mlbIOS/mlbIOS.entitlements; + DEVELOPMENT_TEAM = ""; + INFOPLIST_FILE = mlbIOS/Info.plist; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.treyt.mlbIOS; + PRODUCT_NAME = mlbIOS; + SDKROOT = iphoneos; + SWIFT_STRICT_CONCURRENCY = complete; + SWIFT_VERSION = 6.0; + TARGETED_DEVICE_FAMILY = "1,2"; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; 1FE0846F10814B35388166A8 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -425,6 +623,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Debug; }; + 67C1F966BDD84456877DC373 /* Build configuration list for PBXNativeTarget "mlbIOS" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 1FC82661BC31026C62934BD7 /* Release */, + 0D8A815004F8CC601240D2DB /* Debug */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; B8EE3C08200DF28CFF279E15 /* Build configuration list for PBXProject "mlbTVOS" */ = { isa = XCConfigurationList; buildConfigurations = ( diff --git a/mlbTVOS/Services/MLBServerAPI.swift b/mlbTVOS/Services/MLBServerAPI.swift index 8ffd186..9977892 100644 --- a/mlbTVOS/Services/MLBServerAPI.swift +++ b/mlbTVOS/Services/MLBServerAPI.swift @@ -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 } diff --git a/mlbTVOS/ViewModels/GamesViewModel.swift b/mlbTVOS/ViewModels/GamesViewModel.swift index 20fbbf3..b41062a 100644 --- a/mlbTVOS/ViewModels/GamesViewModel.swift +++ b/mlbTVOS/ViewModels/GamesViewModel.swift @@ -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 } diff --git a/mlbTVOS/Views/Components/PlatformUI.swift b/mlbTVOS/Views/Components/PlatformUI.swift new file mode 100644 index 0000000..6679abf --- /dev/null +++ b/mlbTVOS/Views/Components/PlatformUI.swift @@ -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 + } +} diff --git a/mlbTVOS/Views/DashboardView.swift b/mlbTVOS/Views/DashboardView.swift index ee96356..faa8ba0 100644 --- a/mlbTVOS/Views/DashboardView.swift +++ b/mlbTVOS/Views/DashboardView.swift @@ -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) } diff --git a/mlbTVOS/Views/FeaturedGameCard.swift b/mlbTVOS/Views/FeaturedGameCard.swift index 9e2707b..a0d79fa 100644 --- a/mlbTVOS/Views/FeaturedGameCard.swift +++ b/mlbTVOS/Views/FeaturedGameCard.swift @@ -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 diff --git a/mlbTVOS/Views/GameCardView.swift b/mlbTVOS/Views/GameCardView.swift index 3ab1abf..5d062a8 100644 --- a/mlbTVOS/Views/GameCardView.swift +++ b/mlbTVOS/Views/GameCardView.swift @@ -62,7 +62,7 @@ struct GameCardView: View { y: 8 ) } - .buttonStyle(.card) + .platformCardStyle() } @ViewBuilder diff --git a/mlbTVOS/Views/GameCenterView.swift b/mlbTVOS/Views/GameCenterView.swift index dffc2b1..198e52e 100644 --- a/mlbTVOS/Views/GameCenterView.swift +++ b/mlbTVOS/Views/GameCenterView.swift @@ -76,7 +76,7 @@ struct GameCenterView: View { .background(.white.opacity(0.08)) .clipShape(Capsule()) } - .buttonStyle(.card) + .platformCardStyle() .disabled(game.gamePk == nil) } .padding(22) diff --git a/mlbTVOS/Views/GameListView.swift b/mlbTVOS/Views/GameListView.swift index a1d9236..3f62b44 100644 --- a/mlbTVOS/Views/GameListView.swift +++ b/mlbTVOS/Views/GameListView.swift @@ -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) } diff --git a/mlbTVOS/Views/LeagueCenterView.swift b/mlbTVOS/Views/LeagueCenterView.swift index bdab2f3..7ca41f9 100644 --- a/mlbTVOS/Views/LeagueCenterView.swift +++ b/mlbTVOS/Views/LeagueCenterView.swift @@ -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 { diff --git a/mlbTVOS/Views/MLBNetworkSheet.swift b/mlbTVOS/Views/MLBNetworkSheet.swift index 2f9a8eb..017cde6 100644 --- a/mlbTVOS/Views/MLBNetworkSheet.swift +++ b/mlbTVOS/Views/MLBNetworkSheet.swift @@ -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) } diff --git a/mlbTVOS/Views/MultiStreamView.swift b/mlbTVOS/Views/MultiStreamView.swift index 07fd3be..b6d2115 100644 --- a/mlbTVOS/Views/MultiStreamView.swift +++ b/mlbTVOS/Views/MultiStreamView.swift @@ -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 diff --git a/mlbTVOS/Views/SingleStreamPlayerView.swift b/mlbTVOS/Views/SingleStreamPlayerView.swift index a82dbef..8377130 100644 --- a/mlbTVOS/Views/SingleStreamPlayerView.swift +++ b/mlbTVOS/Views/SingleStreamPlayerView.swift @@ -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) diff --git a/project.yml b/project.yml index 5ac849e..cc4e972 100644 --- a/project.yml +++ b/project.yml @@ -2,6 +2,7 @@ name: mlbTVOS options: bundleIdPrefix: com.treyt deploymentTarget: + iOS: "18.0" tvOS: "18.0" xcodeVersion: "26.3" generateEmptyDirectories: true @@ -22,3 +23,22 @@ targets: CODE_SIGN_ENTITLEMENTS: mlbTVOS/mlbTVOS.entitlements PRODUCT_BUNDLE_IDENTIFIER: com.treyt.mlbTVOS SWIFT_STRICT_CONCURRENCY: complete + mlbIOS: + type: application + platform: iOS + sources: + - path: mlbTVOS + excludes: + - mlbTVOSApp.swift + - Info.plist + - mlbTVOS.entitlements + - Assets.xcassets + - path: mlbIOS + settings: + base: + INFOPLIST_FILE: mlbIOS/Info.plist + CODE_SIGN_ENTITLEMENTS: mlbIOS/mlbIOS.entitlements + PRODUCT_BUNDLE_IDENTIFIER: com.treyt.mlbIOS + TARGETED_DEVICE_FAMILY: "1,2" + ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon + SWIFT_STRICT_CONCURRENCY: complete