Add iOS/iPad target with platform-adaptive UI
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
11
mlbIOS/Assets.xcassets/AccentColor.colorset/Contents.json
Normal file
11
mlbIOS/Assets.xcassets/AccentColor.colorset/Contents.json
Normal file
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"colors" : [
|
||||
{
|
||||
"idiom" : "universal"
|
||||
}
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
8
mlbIOS/Assets.xcassets/AppIcon.appiconset/Contents.json
Normal file
8
mlbIOS/Assets.xcassets/AppIcon.appiconset/Contents.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"images" : [
|
||||
],
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
6
mlbIOS/Assets.xcassets/Contents.json
Normal file
6
mlbIOS/Assets.xcassets/Contents.json
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"info" : {
|
||||
"author" : "xcode",
|
||||
"version" : 1
|
||||
}
|
||||
}
|
||||
42
mlbIOS/Info.plist
Normal file
42
mlbIOS/Info.plist
Normal file
@@ -0,0 +1,42 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
<key>CFBundleExecutable</key>
|
||||
<string>$(EXECUTABLE_NAME)</string>
|
||||
<key>CFBundleIdentifier</key>
|
||||
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||
<key>CFBundleName</key>
|
||||
<string>$(PRODUCT_NAME)</string>
|
||||
<key>CFBundlePackageType</key>
|
||||
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||
<key>CFBundleShortVersionString</key>
|
||||
<string>1.0</string>
|
||||
<key>CFBundleVersion</key>
|
||||
<string>1</string>
|
||||
<key>NSAppTransportSecurity</key>
|
||||
<dict>
|
||||
<key>NSAllowsLocalNetworking</key>
|
||||
<true/>
|
||||
</dict>
|
||||
<key>UILaunchScreen</key>
|
||||
<dict/>
|
||||
<key>UIBackgroundModes</key>
|
||||
<array>
|
||||
<string>audio</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
<key>UISupportedInterfaceOrientations~ipad</key>
|
||||
<array>
|
||||
<string>UIInterfaceOrientationPortrait</string>
|
||||
<string>UIInterfaceOrientationPortraitUpsideDown</string>
|
||||
<string>UIInterfaceOrientationLandscapeLeft</string>
|
||||
<string>UIInterfaceOrientationLandscapeRight</string>
|
||||
</array>
|
||||
</dict>
|
||||
</plist>
|
||||
106
mlbIOS/Views/mlbIOSRootView.swift
Normal file
106
mlbIOS/Views/mlbIOSRootView.swift
Normal file
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
6
mlbIOS/mlbIOS.entitlements
Normal file
6
mlbIOS/mlbIOS.entitlements
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||
<plist version="1.0">
|
||||
<dict>
|
||||
</dict>
|
||||
</plist>
|
||||
26
mlbIOS/mlbIOSApp.swift
Normal file
26
mlbIOS/mlbIOSApp.swift
Normal file
@@ -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)")
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 = "<group>"; };
|
||||
2BB22BB22BB22BB22BB22BB4 /* LeagueCenterViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LeagueCenterViewModel.swift; sourceTree = "<group>"; };
|
||||
327A33F2676712C2B5B4AF2F /* LinescoreView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinescoreView.swift; sourceTree = "<group>"; };
|
||||
3F874FDD20F82AFEB475CD73 /* mlbIOSRootView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = mlbIOSRootView.swift; sourceTree = "<group>"; };
|
||||
3FB99FBEDA096B95C3D2E5B4 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
|
||||
45142562E644AF04F7719083 /* mlbTVOSApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = mlbTVOSApp.swift; sourceTree = "<group>"; };
|
||||
4DE9E708FA75ABB9F49D2392 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
|
||||
5152ABCC9AACFD91BAC8C95B /* MLBNetworkSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MLBNetworkSheet.swift; sourceTree = "<group>"; };
|
||||
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 = "<group>"; };
|
||||
5EA4C33F029665B80F1A6B6D /* DashboardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashboardView.swift; sourceTree = "<group>"; };
|
||||
6067FE4F340FC0DCCFA62C67 /* MLBStatsAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MLBStatsAPI.swift; sourceTree = "<group>"; };
|
||||
645E77DD8183D54EFC97CFB5 /* TeamAssets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeamAssets.swift; sourceTree = "<group>"; };
|
||||
@@ -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 = "<group>"; };
|
||||
8D10F2D91602CE1FB6D7342E /* MultiStreamView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultiStreamView.swift; sourceTree = "<group>"; };
|
||||
95C4DEDEAA64C74AF3DB24F2 /* mlbIOS.entitlements */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.entitlements; path = mlbIOS.entitlements; sourceTree = "<group>"; };
|
||||
9BD2DEA6BBA55E86B2555099 /* GamesViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GamesViewModel.swift; sourceTree = "<group>"; };
|
||||
A6FE9AFE34BB8CE4F33B62D0 /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
AC5BCB2E1DA0EC63E422B113 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||
BBA78A938A07E07B064CEA54 /* PlatformUI.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PlatformUI.swift; sourceTree = "<group>"; };
|
||||
C1F593C51BC11444A3D514D3 /* mlbTVOS.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = mlbTVOS.entitlements; sourceTree = "<group>"; };
|
||||
C2E99F69727800D0CB795503 /* TeamLogoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeamLogoView.swift; sourceTree = "<group>"; };
|
||||
D4E5F6A7B8C9D0E1F2A3B4C5 /* PitcherHeadshotView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PitcherHeadshotView.swift; sourceTree = "<group>"; };
|
||||
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 = "<group>"; };
|
||||
E5F6A7B8C9D0E1F2A3B4C5D6 /* ScoreOverlayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScoreOverlayView.swift; sourceTree = "<group>"; };
|
||||
F0209C78036DA08FA794D8D9 /* mlbIOSApp.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = mlbIOSApp.swift; sourceTree = "<group>"; };
|
||||
FA4AF68CDF4FC8D07194B4D0 /* SingleStreamPlayerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SingleStreamPlayerView.swift; sourceTree = "<group>"; };
|
||||
/* 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 = "<group>";
|
||||
};
|
||||
1646E84AFB7C540A2D228B64 /* mlbIOS */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
96E8DA91A0CDD10D8953C8BA /* Views */,
|
||||
A6FE9AFE34BB8CE4F33B62D0 /* Info.plist */,
|
||||
95C4DEDEAA64C74AF3DB24F2 /* mlbIOS.entitlements */,
|
||||
56358D0D5DC5CC531034287F /* Assets.xcassets */,
|
||||
F0209C78036DA08FA794D8D9 /* mlbIOSApp.swift */,
|
||||
);
|
||||
name = mlbIOS;
|
||||
path = mlbIOS;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
2C171EA64FFA962254F20583 /* ViewModels */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
@@ -93,6 +163,14 @@
|
||||
path = mlbTVOS;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
5AE491A6B2C1767EFEF5CE88 /* Frameworks */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
0B56828FEC5E5902FE078143 /* iOS */,
|
||||
);
|
||||
name = Frameworks;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
62F95C3E8B0F6AFE3BEDE9C3 /* Components */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
@@ -102,6 +180,7 @@
|
||||
24C6D5F4A3B291807F6E5D4B /* ScoresTickerView.swift */,
|
||||
E5F6A7B8C9D0E1F2A3B4C5D6 /* ScoreOverlayView.swift */,
|
||||
C2E99F69727800D0CB795503 /* TeamLogoView.swift */,
|
||||
BBA78A938A07E07B064CEA54 /* PlatformUI.swift */,
|
||||
);
|
||||
path = Components;
|
||||
sourceTree = "<group>";
|
||||
@@ -111,6 +190,8 @@
|
||||
children = (
|
||||
4FAE5C2D72CD78BF7C15C4FC /* mlbTVOS */,
|
||||
88274023705E1F09B25F86FF /* Products */,
|
||||
1646E84AFB7C540A2D228B64 /* mlbIOS */,
|
||||
5AE491A6B2C1767EFEF5CE88 /* Frameworks */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
@@ -118,10 +199,20 @@
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
766441BC900073529EE93D69 /* mlbTVOS.app */,
|
||||
55A3DC0B5087BAD6BC59B35A /* mlbIOS.app */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
96E8DA91A0CDD10D8953C8BA /* Views */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
3F874FDD20F82AFEB475CD73 /* mlbIOSRootView.swift */,
|
||||
);
|
||||
name = Views;
|
||||
path = Views;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
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 = (
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
39
mlbTVOS/Views/Components/PlatformUI.swift
Normal file
39
mlbTVOS/Views/Components/PlatformUI.swift
Normal file
@@ -0,0 +1,39 @@
|
||||
import SwiftUI
|
||||
|
||||
struct PlatformPressButtonStyle: ButtonStyle {
|
||||
func makeBody(configuration: Configuration) -> some View {
|
||||
configuration.label
|
||||
.scaleEffect(configuration.isPressed ? 0.98 : 1.0)
|
||||
.opacity(configuration.isPressed ? 0.92 : 1.0)
|
||||
.animation(.easeOut(duration: 0.16), value: configuration.isPressed)
|
||||
}
|
||||
}
|
||||
|
||||
extension View {
|
||||
@ViewBuilder
|
||||
func platformCardStyle() -> some View {
|
||||
#if os(tvOS)
|
||||
self.buttonStyle(.card)
|
||||
#else
|
||||
self.buttonStyle(PlatformPressButtonStyle())
|
||||
#endif
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
func platformFocusSection() -> some View {
|
||||
#if os(tvOS)
|
||||
self.focusSection()
|
||||
#else
|
||||
self
|
||||
#endif
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
func platformFocusable(_ enabled: Bool = true) -> some View {
|
||||
#if os(tvOS)
|
||||
self.focusable(enabled)
|
||||
#else
|
||||
self
|
||||
#endif
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -62,7 +62,7 @@ struct GameCardView: View {
|
||||
y: 8
|
||||
)
|
||||
}
|
||||
.buttonStyle(.card)
|
||||
.platformCardStyle()
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
|
||||
@@ -76,7 +76,7 @@ struct GameCenterView: View {
|
||||
.background(.white.opacity(0.08))
|
||||
.clipShape(Capsule())
|
||||
}
|
||||
.buttonStyle(.card)
|
||||
.platformCardStyle()
|
||||
.disabled(game.gamePk == nil)
|
||||
}
|
||||
.padding(22)
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
20
project.yml
20
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
|
||||
|
||||
Reference in New Issue
Block a user