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