Initial commit
This commit is contained in:
38
.gitignore
vendored
Normal file
38
.gitignore
vendored
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# Local tool config
|
||||||
|
.claude/
|
||||||
|
|
||||||
|
# Xcode user data
|
||||||
|
*.xcuserstate
|
||||||
|
*.xccheckout
|
||||||
|
*.xcscmblueprint
|
||||||
|
xcuserdata/
|
||||||
|
**/xcuserdata/
|
||||||
|
|
||||||
|
# Xcode / Swift build products
|
||||||
|
build/
|
||||||
|
DerivedData/
|
||||||
|
.build/
|
||||||
|
|
||||||
|
# Swift Package Manager
|
||||||
|
.swiftpm/
|
||||||
|
Package.resolved
|
||||||
|
|
||||||
|
# Fastlane / screenshots / test artifacts
|
||||||
|
fastlane/report.xml
|
||||||
|
fastlane/Preview.html
|
||||||
|
fastlane/screenshots/
|
||||||
|
fastlane/test_output/
|
||||||
|
|
||||||
|
# Simulator / profiling output
|
||||||
|
*.moved-aside
|
||||||
|
*.ipa
|
||||||
|
*.dSYM.zip
|
||||||
|
*.dSYM
|
||||||
|
*.profdata
|
||||||
|
*.hmap
|
||||||
|
|
||||||
|
# App packaging
|
||||||
|
*.app
|
||||||
|
*.xcarchive
|
||||||
440
mlbTVOS.xcodeproj/project.pbxproj
Normal file
440
mlbTVOS.xcodeproj/project.pbxproj
Normal file
@@ -0,0 +1,440 @@
|
|||||||
|
// !$*UTF8*$!
|
||||||
|
{
|
||||||
|
archiveVersion = 1;
|
||||||
|
classes = {
|
||||||
|
};
|
||||||
|
objectVersion = 63;
|
||||||
|
objects = {
|
||||||
|
|
||||||
|
/* Begin PBXBuildFile section */
|
||||||
|
051C3D14A06061D44E325FCC /* LinescoreView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 327A33F2676712C2B5B4AF2F /* LinescoreView.swift */; };
|
||||||
|
0B1C43E83CA856543F58E16A /* MultiStreamView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D10F2D91602CE1FB6D7342E /* MultiStreamView.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 */; };
|
||||||
|
24C6D5F4A3B291807F6E5D4C /* ScoresTickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 24C6D5F4A3B291807F6E5D4B /* ScoresTickerView.swift */; };
|
||||||
|
29627CA5EE85BA103B38D1F5 /* Game.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1ABC999C711B074CF0B536DD /* Game.swift */; };
|
||||||
|
342EED600E1A5B4A3D083E50 /* TeamAssets.swift in Sources */ = {isa = PBXBuildFile; fileRef = 645E77DD8183D54EFC97CFB5 /* TeamAssets.swift */; };
|
||||||
|
4F67D44E9A36BED9CD8724CF /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FB99FBEDA096B95C3D2E5B4 /* ContentView.swift */; };
|
||||||
|
52498A59FF923A1494A0139B /* GameListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B5D51D6E4990515411D3BCD /* GameListView.swift */; };
|
||||||
|
5DFD3805A76DDE49B0DC1E2D /* SingleStreamPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA4AF68CDF4FC8D07194B4D0 /* SingleStreamPlayerView.swift */; };
|
||||||
|
63AF55498896C9BBCD1D4337 /* GameCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E21E3115B8A9B4C798ECE828 /* GameCardView.swift */; };
|
||||||
|
80A690C52C9C8140E7C519E8 /* TeamLogoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2E99F69727800D0CB795503 /* TeamLogoView.swift */; };
|
||||||
|
820FB03D0E97C521BA239071 /* MLBNetworkSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5152ABCC9AACFD91BAC8C95B /* MLBNetworkSheet.swift */; };
|
||||||
|
9D29D3508CD05CB3F4975910 /* GamesViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9BD2DEA6BBA55E86B2555099 /* GamesViewModel.swift */; };
|
||||||
|
A1B2C3D4E5F60718091A2B3C /* PitcherHeadshotView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4E5F6A7B8C9D0E1F2A3B4C5 /* PitcherHeadshotView.swift */; };
|
||||||
|
B2C3D4E5F6A70819A1B2C3D4 /* ScoreOverlayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5F6A7B8C9D0E1F2A3B4C5D6 /* ScoreOverlayView.swift */; };
|
||||||
|
C1DA213471DC246B8DF3F840 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = AC5BCB2E1DA0EC63E422B113 /* Assets.xcassets */; };
|
||||||
|
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 */; };
|
||||||
|
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 */; };
|
||||||
|
/* End PBXBuildFile section */
|
||||||
|
|
||||||
|
/* Begin PBXFileReference section */
|
||||||
|
0B5D51D6E4990515411D3BCD /* GameListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameListView.swift; sourceTree = "<group>"; };
|
||||||
|
102E5E8674E188A04637D3EB /* FeaturedGameCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeaturedGameCard.swift; sourceTree = "<group>"; };
|
||||||
|
1ABC999C711B074CF0B536DD /* Game.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Game.swift; sourceTree = "<group>"; };
|
||||||
|
24B58FDA7A8CA27BC512CBA6 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
|
||||||
|
24C6D5F4A3B291807F6E5D4B /* ScoresTickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScoresTickerView.swift; sourceTree = "<group>"; };
|
||||||
|
2BB22BB22BB22BB22BB22BB1 /* GameCenterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameCenterView.swift; sourceTree = "<group>"; };
|
||||||
|
2BB22BB22BB22BB22BB22BB2 /* LeagueCenterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LeagueCenterView.swift; sourceTree = "<group>"; };
|
||||||
|
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>"; };
|
||||||
|
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>"; };
|
||||||
|
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>"; };
|
||||||
|
70E610D24BA83FB2F7490B00 /* MLBServerAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MLBServerAPI.swift; sourceTree = "<group>"; };
|
||||||
|
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>"; };
|
||||||
|
9BD2DEA6BBA55E86B2555099 /* GamesViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GamesViewModel.swift; sourceTree = "<group>"; };
|
||||||
|
AC5BCB2E1DA0EC63E422B113 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; 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>"; };
|
||||||
|
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>"; };
|
||||||
|
FA4AF68CDF4FC8D07194B4D0 /* SingleStreamPlayerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SingleStreamPlayerView.swift; sourceTree = "<group>"; };
|
||||||
|
/* End PBXFileReference section */
|
||||||
|
|
||||||
|
/* Begin PBXGroup section */
|
||||||
|
2C171EA64FFA962254F20583 /* ViewModels */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
2BB22BB22BB22BB22BB22BB3 /* GameCenterViewModel.swift */,
|
||||||
|
9BD2DEA6BBA55E86B2555099 /* GamesViewModel.swift */,
|
||||||
|
2BB22BB22BB22BB22BB22BB4 /* LeagueCenterViewModel.swift */,
|
||||||
|
);
|
||||||
|
path = ViewModels;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
4FAE5C2D72CD78BF7C15C4FC /* mlbTVOS */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
AC5BCB2E1DA0EC63E422B113 /* Assets.xcassets */,
|
||||||
|
24B58FDA7A8CA27BC512CBA6 /* Info.plist */,
|
||||||
|
C1F593C51BC11444A3D514D3 /* mlbTVOS.entitlements */,
|
||||||
|
45142562E644AF04F7719083 /* mlbTVOSApp.swift */,
|
||||||
|
D7F65F5A1205CF61737AA4BF /* Models */,
|
||||||
|
C6DC4CC5AAB9AF84D3CB9F3B /* Services */,
|
||||||
|
2C171EA64FFA962254F20583 /* ViewModels */,
|
||||||
|
CB58EA802D6A1E30F2C24BC9 /* Views */,
|
||||||
|
);
|
||||||
|
path = mlbTVOS;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
62F95C3E8B0F6AFE3BEDE9C3 /* Components */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
327A33F2676712C2B5B4AF2F /* LinescoreView.swift */,
|
||||||
|
81584770090BAFBEF3ABE6FD /* LiveIndicator.swift */,
|
||||||
|
D4E5F6A7B8C9D0E1F2A3B4C5 /* PitcherHeadshotView.swift */,
|
||||||
|
24C6D5F4A3B291807F6E5D4B /* ScoresTickerView.swift */,
|
||||||
|
E5F6A7B8C9D0E1F2A3B4C5D6 /* ScoreOverlayView.swift */,
|
||||||
|
C2E99F69727800D0CB795503 /* TeamLogoView.swift */,
|
||||||
|
);
|
||||||
|
path = Components;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
86C1117ADD32A1A57AA32A08 = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
4FAE5C2D72CD78BF7C15C4FC /* mlbTVOS */,
|
||||||
|
88274023705E1F09B25F86FF /* Products */,
|
||||||
|
);
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
88274023705E1F09B25F86FF /* Products */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
766441BC900073529EE93D69 /* mlbTVOS.app */,
|
||||||
|
);
|
||||||
|
name = Products;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
C6DC4CC5AAB9AF84D3CB9F3B /* Services */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
70E610D24BA83FB2F7490B00 /* MLBServerAPI.swift */,
|
||||||
|
6067FE4F340FC0DCCFA62C67 /* MLBStatsAPI.swift */,
|
||||||
|
);
|
||||||
|
path = Services;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
CB58EA802D6A1E30F2C24BC9 /* Views */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
3FB99FBEDA096B95C3D2E5B4 /* ContentView.swift */,
|
||||||
|
5EA4C33F029665B80F1A6B6D /* DashboardView.swift */,
|
||||||
|
102E5E8674E188A04637D3EB /* FeaturedGameCard.swift */,
|
||||||
|
E21E3115B8A9B4C798ECE828 /* GameCardView.swift */,
|
||||||
|
2BB22BB22BB22BB22BB22BB1 /* GameCenterView.swift */,
|
||||||
|
0B5D51D6E4990515411D3BCD /* GameListView.swift */,
|
||||||
|
2BB22BB22BB22BB22BB22BB2 /* LeagueCenterView.swift */,
|
||||||
|
5152ABCC9AACFD91BAC8C95B /* MLBNetworkSheet.swift */,
|
||||||
|
8D10F2D91602CE1FB6D7342E /* MultiStreamView.swift */,
|
||||||
|
4DE9E708FA75ABB9F49D2392 /* SettingsView.swift */,
|
||||||
|
FA4AF68CDF4FC8D07194B4D0 /* SingleStreamPlayerView.swift */,
|
||||||
|
62F95C3E8B0F6AFE3BEDE9C3 /* Components */,
|
||||||
|
);
|
||||||
|
path = Views;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
D7F65F5A1205CF61737AA4BF /* Models */ = {
|
||||||
|
isa = PBXGroup;
|
||||||
|
children = (
|
||||||
|
1ABC999C711B074CF0B536DD /* Game.swift */,
|
||||||
|
645E77DD8183D54EFC97CFB5 /* TeamAssets.swift */,
|
||||||
|
);
|
||||||
|
path = Models;
|
||||||
|
sourceTree = "<group>";
|
||||||
|
};
|
||||||
|
/* End PBXGroup section */
|
||||||
|
|
||||||
|
/* Begin PBXNativeTarget section */
|
||||||
|
6ACA532AEDED1D5945D6153A /* mlbTVOS */ = {
|
||||||
|
isa = PBXNativeTarget;
|
||||||
|
buildConfigurationList = 36D4353993A0A22378BC4770 /* Build configuration list for PBXNativeTarget "mlbTVOS" */;
|
||||||
|
buildPhases = (
|
||||||
|
C959C957D7367748B74B83ED /* Sources */,
|
||||||
|
5C7F99E1451B54AD3CC382CB /* Resources */,
|
||||||
|
);
|
||||||
|
buildRules = (
|
||||||
|
);
|
||||||
|
dependencies = (
|
||||||
|
);
|
||||||
|
name = mlbTVOS;
|
||||||
|
packageProductDependencies = (
|
||||||
|
);
|
||||||
|
productName = mlbTVOS;
|
||||||
|
productReference = 766441BC900073529EE93D69 /* mlbTVOS.app */;
|
||||||
|
productType = "com.apple.product-type.application";
|
||||||
|
};
|
||||||
|
/* End PBXNativeTarget section */
|
||||||
|
|
||||||
|
/* Begin PBXProject section */
|
||||||
|
89437C3F6A1DA0DA4ADEA3AB /* Project object */ = {
|
||||||
|
isa = PBXProject;
|
||||||
|
attributes = {
|
||||||
|
BuildIndependentTargetsInParallel = YES;
|
||||||
|
LastUpgradeCheck = 2630;
|
||||||
|
};
|
||||||
|
buildConfigurationList = B8EE3C08200DF28CFF279E15 /* Build configuration list for PBXProject "mlbTVOS" */;
|
||||||
|
compatibilityVersion = "Xcode 14.0";
|
||||||
|
developmentRegion = en;
|
||||||
|
hasScannedForEncodings = 0;
|
||||||
|
knownRegions = (
|
||||||
|
Base,
|
||||||
|
en,
|
||||||
|
);
|
||||||
|
mainGroup = 86C1117ADD32A1A57AA32A08;
|
||||||
|
minimizedProjectReferenceProxies = 1;
|
||||||
|
projectDirPath = "";
|
||||||
|
projectRoot = "";
|
||||||
|
targets = (
|
||||||
|
6ACA532AEDED1D5945D6153A /* mlbTVOS */,
|
||||||
|
);
|
||||||
|
};
|
||||||
|
/* End PBXProject section */
|
||||||
|
|
||||||
|
/* Begin PBXResourcesBuildPhase section */
|
||||||
|
5C7F99E1451B54AD3CC382CB /* Resources */ = {
|
||||||
|
isa = PBXResourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
C1DA213471DC246B8DF3F840 /* Assets.xcassets in Resources */,
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
/* End PBXResourcesBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin PBXSourcesBuildPhase section */
|
||||||
|
C959C957D7367748B74B83ED /* Sources */ = {
|
||||||
|
isa = PBXSourcesBuildPhase;
|
||||||
|
buildActionMask = 2147483647;
|
||||||
|
files = (
|
||||||
|
4F67D44E9A36BED9CD8724CF /* ContentView.swift in Sources */,
|
||||||
|
F36A71E407BC707B8E03457D /* DashboardView.swift in Sources */,
|
||||||
|
FAFA9B29273C4D8A54CD5916 /* FeaturedGameCard.swift in Sources */,
|
||||||
|
29627CA5EE85BA103B38D1F5 /* Game.swift in Sources */,
|
||||||
|
63AF55498896C9BBCD1D4337 /* GameCardView.swift in Sources */,
|
||||||
|
1AA11AA11AA11AA11AA11AA1 /* GameCenterView.swift in Sources */,
|
||||||
|
1AA11AA11AA11AA11AA11AA3 /* GameCenterViewModel.swift in Sources */,
|
||||||
|
52498A59FF923A1494A0139B /* GameListView.swift in Sources */,
|
||||||
|
9D29D3508CD05CB3F4975910 /* GamesViewModel.swift in Sources */,
|
||||||
|
1AA11AA11AA11AA11AA11AA2 /* LeagueCenterView.swift in Sources */,
|
||||||
|
1AA11AA11AA11AA11AA11AA4 /* LeagueCenterViewModel.swift in Sources */,
|
||||||
|
051C3D14A06061D44E325FCC /* LinescoreView.swift in Sources */,
|
||||||
|
E7FB366456557069990F550C /* LiveIndicator.swift in Sources */,
|
||||||
|
820FB03D0E97C521BA239071 /* MLBNetworkSheet.swift in Sources */,
|
||||||
|
1E31C6F500CEEA5284081D22 /* MLBServerAPI.swift in Sources */,
|
||||||
|
DC37555DE8C367E929974484 /* MLBStatsAPI.swift in Sources */,
|
||||||
|
0B1C43E83CA856543F58E16A /* MultiStreamView.swift in Sources */,
|
||||||
|
A1B2C3D4E5F60718091A2B3C /* PitcherHeadshotView.swift in Sources */,
|
||||||
|
24C6D5F4A3B291807F6E5D4C /* ScoresTickerView.swift in Sources */,
|
||||||
|
B2C3D4E5F6A70819A1B2C3D4 /* ScoreOverlayView.swift in Sources */,
|
||||||
|
FD9322BED3A2B827CB021163 /* SettingsView.swift in Sources */,
|
||||||
|
5DFD3805A76DDE49B0DC1E2D /* SingleStreamPlayerView.swift in Sources */,
|
||||||
|
342EED600E1A5B4A3D083E50 /* TeamAssets.swift in Sources */,
|
||||||
|
80A690C52C9C8140E7C519E8 /* TeamLogoView.swift in Sources */,
|
||||||
|
FD31074216AC0115B3F1C057 /* mlbTVOSApp.swift in Sources */,
|
||||||
|
);
|
||||||
|
runOnlyForDeploymentPostprocessing = 0;
|
||||||
|
};
|
||||||
|
/* End PBXSourcesBuildPhase section */
|
||||||
|
|
||||||
|
/* Begin XCBuildConfiguration section */
|
||||||
|
0AEA011CF6D6485B876BC988 /* Debug */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||||
|
CLANG_ANALYZER_NONNULL = YES;
|
||||||
|
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||||
|
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
|
||||||
|
CLANG_CXX_LIBRARY = "libc++";
|
||||||
|
CLANG_ENABLE_MODULES = YES;
|
||||||
|
CLANG_ENABLE_OBJC_ARC = YES;
|
||||||
|
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||||
|
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||||
|
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_COMMA = YES;
|
||||||
|
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||||
|
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||||
|
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||||
|
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||||
|
CLANG_WARN_EMPTY_BODY = YES;
|
||||||
|
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||||
|
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||||
|
CLANG_WARN_INT_CONVERSION = YES;
|
||||||
|
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||||
|
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||||
|
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||||
|
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||||
|
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||||
|
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||||
|
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||||
|
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||||
|
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||||
|
COPY_PHASE_STRIP = NO;
|
||||||
|
DEBUG_INFORMATION_FORMAT = dwarf;
|
||||||
|
DEVELOPMENT_TEAM = "";
|
||||||
|
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||||
|
ENABLE_TESTABILITY = YES;
|
||||||
|
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||||
|
GCC_DYNAMIC_NO_PIC = NO;
|
||||||
|
GCC_NO_COMMON_BLOCKS = YES;
|
||||||
|
GCC_OPTIMIZATION_LEVEL = 0;
|
||||||
|
GCC_PREPROCESSOR_DEFINITIONS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"DEBUG=1",
|
||||||
|
);
|
||||||
|
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||||
|
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||||
|
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||||
|
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||||
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
|
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||||
|
MTL_FAST_MATH = YES;
|
||||||
|
ONLY_ACTIVE_ARCH = YES;
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
SDKROOT = appletvos;
|
||||||
|
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||||
|
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||||
|
SWIFT_VERSION = 6.0;
|
||||||
|
TVOS_DEPLOYMENT_TARGET = 18.0;
|
||||||
|
};
|
||||||
|
name = Debug;
|
||||||
|
};
|
||||||
|
1FE0846F10814B35388166A8 /* Debug */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ASSETCATALOG_COMPILER_APPICON_NAME = "App Icon & Top Shelf Image";
|
||||||
|
ASSETCATALOG_COMPILER_LAUNCHIMAGE_NAME = LaunchImage;
|
||||||
|
CODE_SIGN_ENTITLEMENTS = mlbTVOS/mlbTVOS.entitlements;
|
||||||
|
DEVELOPMENT_TEAM = V3PF3M6B6U;
|
||||||
|
INFOPLIST_FILE = mlbTVOS/Info.plist;
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/Frameworks",
|
||||||
|
);
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = com.treyt.mlbTVOS;
|
||||||
|
SDKROOT = appletvos;
|
||||||
|
SWIFT_STRICT_CONCURRENCY = complete;
|
||||||
|
TARGETED_DEVICE_FAMILY = 3;
|
||||||
|
};
|
||||||
|
name = Debug;
|
||||||
|
};
|
||||||
|
2CB296EE945F2BD3DDB36695 /* Release */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ALWAYS_SEARCH_USER_PATHS = NO;
|
||||||
|
CLANG_ANALYZER_NONNULL = YES;
|
||||||
|
CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
|
||||||
|
CLANG_CXX_LANGUAGE_STANDARD = "gnu++14";
|
||||||
|
CLANG_CXX_LIBRARY = "libc++";
|
||||||
|
CLANG_ENABLE_MODULES = YES;
|
||||||
|
CLANG_ENABLE_OBJC_ARC = YES;
|
||||||
|
CLANG_ENABLE_OBJC_WEAK = YES;
|
||||||
|
CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES;
|
||||||
|
CLANG_WARN_BOOL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_COMMA = YES;
|
||||||
|
CLANG_WARN_CONSTANT_CONVERSION = YES;
|
||||||
|
CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES;
|
||||||
|
CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
|
||||||
|
CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
|
||||||
|
CLANG_WARN_EMPTY_BODY = YES;
|
||||||
|
CLANG_WARN_ENUM_CONVERSION = YES;
|
||||||
|
CLANG_WARN_INFINITE_RECURSION = YES;
|
||||||
|
CLANG_WARN_INT_CONVERSION = YES;
|
||||||
|
CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES;
|
||||||
|
CLANG_WARN_OBJC_LITERAL_CONVERSION = YES;
|
||||||
|
CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
|
||||||
|
CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES;
|
||||||
|
CLANG_WARN_RANGE_LOOP_ANALYSIS = YES;
|
||||||
|
CLANG_WARN_STRICT_PROTOTYPES = YES;
|
||||||
|
CLANG_WARN_SUSPICIOUS_MOVE = YES;
|
||||||
|
CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE;
|
||||||
|
CLANG_WARN_UNREACHABLE_CODE = YES;
|
||||||
|
CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
|
||||||
|
COPY_PHASE_STRIP = NO;
|
||||||
|
DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
|
||||||
|
DEVELOPMENT_TEAM = "";
|
||||||
|
ENABLE_NS_ASSERTIONS = NO;
|
||||||
|
ENABLE_STRICT_OBJC_MSGSEND = YES;
|
||||||
|
GCC_C_LANGUAGE_STANDARD = gnu11;
|
||||||
|
GCC_NO_COMMON_BLOCKS = YES;
|
||||||
|
GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
|
||||||
|
GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
|
||||||
|
GCC_WARN_UNDECLARED_SELECTOR = YES;
|
||||||
|
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||||
|
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||||
|
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||||
|
MTL_ENABLE_DEBUG_INFO = NO;
|
||||||
|
MTL_FAST_MATH = YES;
|
||||||
|
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||||
|
SDKROOT = appletvos;
|
||||||
|
SWIFT_COMPILATION_MODE = wholemodule;
|
||||||
|
SWIFT_OPTIMIZATION_LEVEL = "-O";
|
||||||
|
SWIFT_VERSION = 6.0;
|
||||||
|
TVOS_DEPLOYMENT_TARGET = 18.0;
|
||||||
|
};
|
||||||
|
name = Release;
|
||||||
|
};
|
||||||
|
F266D26959D831BC0C0C782C /* Release */ = {
|
||||||
|
isa = XCBuildConfiguration;
|
||||||
|
buildSettings = {
|
||||||
|
ASSETCATALOG_COMPILER_APPICON_NAME = "App Icon & Top Shelf Image";
|
||||||
|
ASSETCATALOG_COMPILER_LAUNCHIMAGE_NAME = LaunchImage;
|
||||||
|
CODE_SIGN_ENTITLEMENTS = mlbTVOS/mlbTVOS.entitlements;
|
||||||
|
DEVELOPMENT_TEAM = V3PF3M6B6U;
|
||||||
|
INFOPLIST_FILE = mlbTVOS/Info.plist;
|
||||||
|
LD_RUNPATH_SEARCH_PATHS = (
|
||||||
|
"$(inherited)",
|
||||||
|
"@executable_path/Frameworks",
|
||||||
|
);
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER = com.treyt.mlbTVOS;
|
||||||
|
SDKROOT = appletvos;
|
||||||
|
SWIFT_STRICT_CONCURRENCY = complete;
|
||||||
|
TARGETED_DEVICE_FAMILY = 3;
|
||||||
|
};
|
||||||
|
name = Release;
|
||||||
|
};
|
||||||
|
/* End XCBuildConfiguration section */
|
||||||
|
|
||||||
|
/* Begin XCConfigurationList section */
|
||||||
|
36D4353993A0A22378BC4770 /* Build configuration list for PBXNativeTarget "mlbTVOS" */ = {
|
||||||
|
isa = XCConfigurationList;
|
||||||
|
buildConfigurations = (
|
||||||
|
1FE0846F10814B35388166A8 /* Debug */,
|
||||||
|
F266D26959D831BC0C0C782C /* Release */,
|
||||||
|
);
|
||||||
|
defaultConfigurationIsVisible = 0;
|
||||||
|
defaultConfigurationName = Debug;
|
||||||
|
};
|
||||||
|
B8EE3C08200DF28CFF279E15 /* Build configuration list for PBXProject "mlbTVOS" */ = {
|
||||||
|
isa = XCConfigurationList;
|
||||||
|
buildConfigurations = (
|
||||||
|
0AEA011CF6D6485B876BC988 /* Debug */,
|
||||||
|
2CB296EE945F2BD3DDB36695 /* Release */,
|
||||||
|
);
|
||||||
|
defaultConfigurationIsVisible = 0;
|
||||||
|
defaultConfigurationName = Debug;
|
||||||
|
};
|
||||||
|
/* End XCConfigurationList section */
|
||||||
|
};
|
||||||
|
rootObject = 89437C3F6A1DA0DA4ADEA3AB /* Project object */;
|
||||||
|
}
|
||||||
7
mlbTVOS.xcodeproj/project.xcworkspace/contents.xcworkspacedata
generated
Normal file
7
mlbTVOS.xcodeproj/project.xcworkspace/contents.xcworkspacedata
generated
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<Workspace
|
||||||
|
version = "1.0">
|
||||||
|
<FileRef
|
||||||
|
location = "self:">
|
||||||
|
</FileRef>
|
||||||
|
</Workspace>
|
||||||
20
mlbTVOS/Assets.xcassets/AccentColor.colorset/Contents.json
Normal file
20
mlbTVOS/Assets.xcassets/AccentColor.colorset/Contents.json
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
{
|
||||||
|
"colors" : [
|
||||||
|
{
|
||||||
|
"color" : {
|
||||||
|
"color-space" : "srgb",
|
||||||
|
"components" : {
|
||||||
|
"alpha" : "1.000",
|
||||||
|
"blue" : "0.459",
|
||||||
|
"green" : "0.231",
|
||||||
|
"red" : "0.000"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"idiom" : "universal"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"images" : [ { "idiom" : "tv", "scale" : "1x" } ],
|
||||||
|
"info" : { "author" : "xcode", "version" : 1 }
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"info" : { "author" : "xcode", "version" : 1 }
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
},
|
||||||
|
"layers" : [
|
||||||
|
{ "filename" : "Front.imagestacklayer" },
|
||||||
|
{ "filename" : "Middle.imagestacklayer" },
|
||||||
|
{ "filename" : "Back.imagestacklayer" }
|
||||||
|
]
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"images" : [ { "idiom" : "tv", "scale" : "1x" } ],
|
||||||
|
"info" : { "author" : "xcode", "version" : 1 }
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"info" : { "author" : "xcode", "version" : 1 }
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"images" : [ { "idiom" : "tv", "scale" : "1x" } ],
|
||||||
|
"info" : { "author" : "xcode", "version" : 1 }
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"info" : { "author" : "xcode", "version" : 1 }
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
{
|
||||||
|
"assets" : [
|
||||||
|
{
|
||||||
|
"filename" : "App Icon.imagestack",
|
||||||
|
"idiom" : "tv",
|
||||||
|
"role" : "primary-app-icon",
|
||||||
|
"size" : "1280x768"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "tv",
|
||||||
|
"role" : "top-shelf-image",
|
||||||
|
"size" : "1920x720"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"idiom" : "tv",
|
||||||
|
"role" : "top-shelf-image-wide",
|
||||||
|
"size" : "2320x720"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
6
mlbTVOS/Assets.xcassets/Contents.json
Normal file
6
mlbTVOS/Assets.xcassets/Contents.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"info" : {
|
||||||
|
"author" : "xcode",
|
||||||
|
"version" : 1
|
||||||
|
}
|
||||||
|
}
|
||||||
27
mlbTVOS/Info.plist
Normal file
27
mlbTVOS/Info.plist
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
<?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>CFBundleName</key>
|
||||||
|
<string>$(PRODUCT_NAME)</string>
|
||||||
|
<key>CFBundleIdentifier</key>
|
||||||
|
<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
|
||||||
|
<key>CFBundleVersion</key>
|
||||||
|
<string>1</string>
|
||||||
|
<key>CFBundleShortVersionString</key>
|
||||||
|
<string>1.0</string>
|
||||||
|
<key>CFBundlePackageType</key>
|
||||||
|
<string>$(PRODUCT_BUNDLE_PACKAGE_TYPE)</string>
|
||||||
|
<key>CFBundleExecutable</key>
|
||||||
|
<string>$(EXECUTABLE_NAME)</string>
|
||||||
|
<key>UIBackgroundModes</key>
|
||||||
|
<array>
|
||||||
|
<string>audio</string>
|
||||||
|
</array>
|
||||||
|
<key>NSAppTransportSecurity</key>
|
||||||
|
<dict>
|
||||||
|
<key>NSAllowsLocalNetworking</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
200
mlbTVOS/Models/Game.swift
Normal file
200
mlbTVOS/Models/Game.swift
Normal file
@@ -0,0 +1,200 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
struct Game: Identifiable, Sendable {
|
||||||
|
let id: String
|
||||||
|
let awayTeam: TeamInfo
|
||||||
|
let homeTeam: TeamInfo
|
||||||
|
let status: GameStatus
|
||||||
|
let gameType: String?
|
||||||
|
let startTime: String?
|
||||||
|
let venue: String?
|
||||||
|
let pitchers: String?
|
||||||
|
let gamePk: String?
|
||||||
|
let gameDate: String
|
||||||
|
let broadcasts: [Broadcast]
|
||||||
|
let isBlackedOut: Bool
|
||||||
|
|
||||||
|
// Rich data from Stats API
|
||||||
|
var linescore: StatsLinescore?
|
||||||
|
var currentInningDisplay: String?
|
||||||
|
var awayPitcherId: Int?
|
||||||
|
var homePitcherId: Int?
|
||||||
|
|
||||||
|
var awayPitcherHeadshotURL: URL? {
|
||||||
|
guard let id = awayPitcherId else { return nil }
|
||||||
|
return URL(string: "https://img.mlbstatic.com/mlb-photos/image/upload/w_213,q_auto:best/v1/people/\(id)/headshot/67/current")
|
||||||
|
}
|
||||||
|
|
||||||
|
var homePitcherHeadshotURL: URL? {
|
||||||
|
guard let id = homePitcherId else { return nil }
|
||||||
|
return URL(string: "https://img.mlbstatic.com/mlb-photos/image/upload/w_213,q_auto:best/v1/people/\(id)/headshot/67/current")
|
||||||
|
}
|
||||||
|
|
||||||
|
var displayTitle: String {
|
||||||
|
"\(awayTeam.displayName) @ \(homeTeam.displayName)"
|
||||||
|
}
|
||||||
|
|
||||||
|
var scoreDisplay: String? {
|
||||||
|
guard let awayScore = awayTeam.score, let homeScore = homeTeam.score else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return "\(awayScore) - \(homeScore)"
|
||||||
|
}
|
||||||
|
|
||||||
|
var hasStreams: Bool {
|
||||||
|
!broadcasts.isEmpty && !isBlackedOut
|
||||||
|
}
|
||||||
|
|
||||||
|
var isLive: Bool { status.isLive }
|
||||||
|
var isFinal: Bool { if case .final_ = status { return true }; return false }
|
||||||
|
}
|
||||||
|
|
||||||
|
struct TeamInfo: Sendable {
|
||||||
|
let code: String
|
||||||
|
let name: String
|
||||||
|
let score: Int?
|
||||||
|
var teamId: Int?
|
||||||
|
var record: String?
|
||||||
|
var divisionRank: String?
|
||||||
|
var gamesBack: String?
|
||||||
|
var streak: String?
|
||||||
|
|
||||||
|
var displayName: String {
|
||||||
|
if !name.isEmpty { return name }
|
||||||
|
return TeamDirectory.name(for: code)
|
||||||
|
}
|
||||||
|
|
||||||
|
var logoURL: URL? {
|
||||||
|
if let teamId { return TeamAssets.logoURL(forId: teamId) }
|
||||||
|
return TeamAssets.logoURL(for: code)
|
||||||
|
}
|
||||||
|
|
||||||
|
var standingSummary: String? {
|
||||||
|
var parts: [String] = []
|
||||||
|
if let rank = divisionRank {
|
||||||
|
let suffix: String
|
||||||
|
switch rank {
|
||||||
|
case "1": suffix = "st"
|
||||||
|
case "2": suffix = "nd"
|
||||||
|
case "3": suffix = "rd"
|
||||||
|
default: suffix = "th"
|
||||||
|
}
|
||||||
|
parts.append("\(rank)\(suffix)")
|
||||||
|
}
|
||||||
|
if let gb = gamesBack {
|
||||||
|
parts.append("\(gb) GB")
|
||||||
|
}
|
||||||
|
if let streak {
|
||||||
|
parts.append(streak)
|
||||||
|
}
|
||||||
|
return parts.isEmpty ? nil : parts.joined(separator: " · ")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Broadcast: Identifiable, Sendable {
|
||||||
|
let id: String
|
||||||
|
let teamCode: String
|
||||||
|
let name: String
|
||||||
|
let mediaId: String
|
||||||
|
let streamURL: String
|
||||||
|
|
||||||
|
var displayLabel: String {
|
||||||
|
"\(teamCode): \(name)"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum GameStatus: Sendable {
|
||||||
|
case scheduled(String)
|
||||||
|
case live(String?)
|
||||||
|
case final_
|
||||||
|
case unknown
|
||||||
|
|
||||||
|
var label: String {
|
||||||
|
switch self {
|
||||||
|
case .scheduled(let time): time
|
||||||
|
case .live(let info): info ?? "Live"
|
||||||
|
case .final_: "Final"
|
||||||
|
case .unknown: ""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var isLive: Bool {
|
||||||
|
if case .live = self { return true }
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
var isScheduled: Bool {
|
||||||
|
if case .scheduled = self { return true }
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct StreamConfig: Sendable {
|
||||||
|
let mediaId: String?
|
||||||
|
let team: String?
|
||||||
|
let resolution: String
|
||||||
|
let date: String?
|
||||||
|
let level: String?
|
||||||
|
let skip: String?
|
||||||
|
let audioTrack: String?
|
||||||
|
let mediaType: String?
|
||||||
|
let game: Int?
|
||||||
|
|
||||||
|
init(
|
||||||
|
mediaId: String,
|
||||||
|
resolution: String = "best",
|
||||||
|
audioTrack: String? = nil
|
||||||
|
) {
|
||||||
|
self.mediaId = mediaId
|
||||||
|
self.team = nil
|
||||||
|
self.resolution = resolution
|
||||||
|
self.date = nil
|
||||||
|
self.level = nil
|
||||||
|
self.skip = nil
|
||||||
|
self.audioTrack = audioTrack
|
||||||
|
self.mediaType = nil
|
||||||
|
self.game = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
init(
|
||||||
|
team: String,
|
||||||
|
resolution: String = "best",
|
||||||
|
date: String? = nil,
|
||||||
|
level: String? = nil,
|
||||||
|
skip: String? = nil,
|
||||||
|
audioTrack: String? = nil,
|
||||||
|
mediaType: String? = nil,
|
||||||
|
game: Int? = nil
|
||||||
|
) {
|
||||||
|
self.mediaId = nil
|
||||||
|
self.team = team
|
||||||
|
self.resolution = resolution
|
||||||
|
self.date = date
|
||||||
|
self.level = level
|
||||||
|
self.skip = skip
|
||||||
|
self.audioTrack = audioTrack
|
||||||
|
self.mediaType = mediaType
|
||||||
|
self.game = game
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum TeamDirectory {
|
||||||
|
static let teams: [String: String] = [
|
||||||
|
"ARI": "D-backs", "AZ": "D-backs",
|
||||||
|
"ATL": "Braves", "BAL": "Orioles", "BOS": "Red Sox",
|
||||||
|
"CHC": "Cubs", "CWS": "White Sox", "CIN": "Reds",
|
||||||
|
"CLE": "Guardians", "COL": "Rockies", "DET": "Tigers",
|
||||||
|
"HOU": "Astros", "KC": "Royals", "LAA": "Angels",
|
||||||
|
"LAD": "Dodgers", "MIA": "Marlins", "MIL": "Brewers",
|
||||||
|
"MIN": "Twins", "NYM": "Mets", "NYY": "Yankees",
|
||||||
|
"OAK": "Athletics", "ATH": "Athletics",
|
||||||
|
"PHI": "Phillies", "PIT": "Pirates", "SD": "Padres",
|
||||||
|
"SF": "Giants", "SEA": "Mariners", "STL": "Cardinals",
|
||||||
|
"TB": "Rays", "TEX": "Rangers", "TOR": "Blue Jays",
|
||||||
|
"WSH": "Nationals",
|
||||||
|
]
|
||||||
|
|
||||||
|
static func name(for code: String) -> String {
|
||||||
|
teams[code.uppercased()] ?? code
|
||||||
|
}
|
||||||
|
}
|
||||||
62
mlbTVOS/Models/TeamAssets.swift
Normal file
62
mlbTVOS/Models/TeamAssets.swift
Normal file
@@ -0,0 +1,62 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
enum TeamAssets {
|
||||||
|
// MLB team ID mapping for logo URLs
|
||||||
|
static let teamIds: [String: Int] = [
|
||||||
|
"ARI": 109, "AZ": 109, "ATL": 144, "BAL": 110, "BOS": 111,
|
||||||
|
"CHC": 112, "CWS": 145, "CIN": 113, "CLE": 114, "COL": 115,
|
||||||
|
"DET": 116, "HOU": 117, "KC": 118, "LAA": 108, "LAD": 119,
|
||||||
|
"MIA": 146, "MIL": 158, "MIN": 142, "NYM": 121, "NYY": 147,
|
||||||
|
"OAK": 133, "ATH": 133, "PHI": 143, "PIT": 134, "SD": 135,
|
||||||
|
"SF": 137, "SEA": 136, "STL": 138, "TB": 139, "TEX": 140,
|
||||||
|
"TOR": 141, "WSH": 120,
|
||||||
|
]
|
||||||
|
|
||||||
|
// Primary team colors
|
||||||
|
static let teamColors: [String: Color] = [
|
||||||
|
"ARI": Color(red: 0.65, green: 0.10, blue: 0.19),
|
||||||
|
"ATL": Color(red: 0.81, green: 0.07, blue: 0.26),
|
||||||
|
"BAL": Color(red: 0.87, green: 0.31, blue: 0.07),
|
||||||
|
"BOS": Color(red: 0.74, green: 0.09, blue: 0.13),
|
||||||
|
"CHC": Color(red: 0.00, green: 0.19, blue: 0.56),
|
||||||
|
"CWS": Color(red: 0.15, green: 0.15, blue: 0.15),
|
||||||
|
"CIN": Color(red: 0.77, green: 0.06, blue: 0.15),
|
||||||
|
"CLE": Color(red: 0.00, green: 0.17, blue: 0.38),
|
||||||
|
"COL": Color(red: 0.20, green: 0.11, blue: 0.35),
|
||||||
|
"DET": Color(red: 0.00, green: 0.18, blue: 0.42),
|
||||||
|
"HOU": Color(red: 0.00, green: 0.18, blue: 0.39),
|
||||||
|
"KC": Color(red: 0.00, green: 0.22, blue: 0.53),
|
||||||
|
"LAA": Color(red: 0.73, green: 0.07, blue: 0.15),
|
||||||
|
"LAD": Color(red: 0.00, green: 0.22, blue: 0.56),
|
||||||
|
"MIA": Color(red: 0.00, green: 0.63, blue: 0.79),
|
||||||
|
"MIL": Color(red: 0.07, green: 0.15, blue: 0.34),
|
||||||
|
"MIN": Color(red: 0.00, green: 0.17, blue: 0.38),
|
||||||
|
"NYM": Color(red: 0.00, green: 0.18, blue: 0.48),
|
||||||
|
"NYY": Color(red: 0.00, green: 0.14, blue: 0.30),
|
||||||
|
"OAK": Color(red: 0.00, green: 0.30, blue: 0.18),
|
||||||
|
"ATH": Color(red: 0.00, green: 0.30, blue: 0.18),
|
||||||
|
"PHI": Color(red: 0.76, green: 0.08, blue: 0.18),
|
||||||
|
"PIT": Color(red: 0.99, green: 0.73, blue: 0.01),
|
||||||
|
"SD": Color(red: 0.18, green: 0.12, blue: 0.07),
|
||||||
|
"SF": Color(red: 0.99, green: 0.31, blue: 0.07),
|
||||||
|
"SEA": Color(red: 0.00, green: 0.22, blue: 0.36),
|
||||||
|
"STL": Color(red: 0.76, green: 0.10, blue: 0.15),
|
||||||
|
"TB": Color(red: 0.00, green: 0.18, blue: 0.46),
|
||||||
|
"TEX": Color(red: 0.00, green: 0.21, blue: 0.52),
|
||||||
|
"TOR": Color(red: 0.08, green: 0.25, blue: 0.52),
|
||||||
|
"WSH": Color(red: 0.67, green: 0.09, blue: 0.19),
|
||||||
|
]
|
||||||
|
|
||||||
|
static func color(for code: String) -> Color {
|
||||||
|
teamColors[code.uppercased()] ?? .gray
|
||||||
|
}
|
||||||
|
|
||||||
|
static func logoURL(for code: String) -> URL? {
|
||||||
|
guard let id = teamIds[code.uppercased()] else { return nil }
|
||||||
|
return URL(string: "https://midfield.mlbstatic.com/v1/team/\(id)/spots/72")
|
||||||
|
}
|
||||||
|
|
||||||
|
static func logoURL(forId id: Int) -> URL {
|
||||||
|
URL(string: "https://midfield.mlbstatic.com/v1/team/\(id)/spots/72")!
|
||||||
|
}
|
||||||
|
}
|
||||||
336
mlbTVOS/Services/MLBServerAPI.swift
Normal file
336
mlbTVOS/Services/MLBServerAPI.swift
Normal file
@@ -0,0 +1,336 @@
|
|||||||
|
import Foundation
|
||||||
|
|
||||||
|
actor MLBServerAPI {
|
||||||
|
let baseURL: String
|
||||||
|
|
||||||
|
init(baseURL: String = "http://10.3.3.11:5714") {
|
||||||
|
self.baseURL = baseURL
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Stream URL Construction
|
||||||
|
|
||||||
|
func streamURL(for config: StreamConfig) -> URL {
|
||||||
|
var components = URLComponents(string: "\(baseURL)/stream.m3u8")!
|
||||||
|
var items: [URLQueryItem] = []
|
||||||
|
|
||||||
|
if let mediaId = config.mediaId {
|
||||||
|
items.append(URLQueryItem(name: "mediaId", value: mediaId))
|
||||||
|
} else if let team = config.team {
|
||||||
|
items.append(URLQueryItem(name: "team", value: team))
|
||||||
|
}
|
||||||
|
|
||||||
|
items.append(URLQueryItem(name: "resolution", value: config.resolution))
|
||||||
|
|
||||||
|
if let date = config.date {
|
||||||
|
items.append(URLQueryItem(name: "date", value: date))
|
||||||
|
}
|
||||||
|
if let level = config.level {
|
||||||
|
items.append(URLQueryItem(name: "level", value: level))
|
||||||
|
}
|
||||||
|
if let skip = config.skip {
|
||||||
|
items.append(URLQueryItem(name: "skip", value: skip))
|
||||||
|
}
|
||||||
|
if let audioTrack = config.audioTrack {
|
||||||
|
items.append(URLQueryItem(name: "audio_track", value: audioTrack))
|
||||||
|
}
|
||||||
|
if let mediaType = config.mediaType {
|
||||||
|
items.append(URLQueryItem(name: "mediaType", value: mediaType))
|
||||||
|
}
|
||||||
|
if let game = config.game {
|
||||||
|
items.append(URLQueryItem(name: "game", value: String(game)))
|
||||||
|
}
|
||||||
|
|
||||||
|
components.queryItems = items
|
||||||
|
return components.url!
|
||||||
|
}
|
||||||
|
|
||||||
|
func eventStreamURL(event: String, resolution: String = "best") -> URL {
|
||||||
|
var components = URLComponents(string: "\(baseURL)/stream.m3u8")!
|
||||||
|
components.queryItems = [
|
||||||
|
URLQueryItem(name: "event", value: event),
|
||||||
|
URLQueryItem(name: "resolution", value: resolution),
|
||||||
|
]
|
||||||
|
return components.url!
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Fetch Games
|
||||||
|
|
||||||
|
func fetchGames(date: String? = nil) async throws -> [Game] {
|
||||||
|
var urlString = baseURL + "/?scores=Show"
|
||||||
|
if let date {
|
||||||
|
urlString += "&date=\(date)"
|
||||||
|
}
|
||||||
|
guard let url = URL(string: urlString) else {
|
||||||
|
throw APIError.invalidURL
|
||||||
|
}
|
||||||
|
|
||||||
|
let (data, response) = try await URLSession.shared.data(from: url)
|
||||||
|
guard let httpResponse = response as? HTTPURLResponse,
|
||||||
|
httpResponse.statusCode == 200 else {
|
||||||
|
throw APIError.serverError
|
||||||
|
}
|
||||||
|
guard let html = String(data: data, encoding: .utf8) else {
|
||||||
|
throw APIError.invalidData
|
||||||
|
}
|
||||||
|
|
||||||
|
return parseGames(from: html, date: date)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Fetch Highlights
|
||||||
|
|
||||||
|
func fetchHighlights(gamePk: String, gameDate: String) async throws -> [Highlight] {
|
||||||
|
let urlString = "\(baseURL)/highlights?gamePk=\(gamePk)&gameDate=\(gameDate)"
|
||||||
|
guard let url = URL(string: urlString) else {
|
||||||
|
throw APIError.invalidURL
|
||||||
|
}
|
||||||
|
|
||||||
|
let (data, _) = try await URLSession.shared.data(from: url)
|
||||||
|
let highlights = try JSONDecoder().decode([Highlight].self, from: data)
|
||||||
|
return highlights
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - HTML Parsing
|
||||||
|
|
||||||
|
private func parseGames(from html: String, date: String?) -> [Game] {
|
||||||
|
var games: [Game] = []
|
||||||
|
|
||||||
|
let todayDate = date ?? {
|
||||||
|
let f = DateFormatter()
|
||||||
|
f.dateFormat = "yyyy-MM-dd"
|
||||||
|
return f.string(from: Date())
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Split HTML into table rows
|
||||||
|
// Game rows have the pattern: <tr...><td>AWAY @ HOME...</td><td>...streams...</td></tr>
|
||||||
|
let rowPattern = #"<tr[^>]*>\s*<td>([^<]*(?:<(?!/?tr)[^>]*>[^<]*)*)</td>\s*<td>(.*?)</td>"#
|
||||||
|
guard let rowRegex = try? NSRegularExpression(pattern: rowPattern, options: .dotMatchesLineSeparators) else {
|
||||||
|
return games
|
||||||
|
}
|
||||||
|
|
||||||
|
let matches = rowRegex.matches(in: html, range: NSRange(html.startIndex..., in: html))
|
||||||
|
|
||||||
|
for match in matches {
|
||||||
|
guard let gameInfoRange = Range(match.range(at: 1), in: html),
|
||||||
|
let streamInfoRange = Range(match.range(at: 2), in: html) else { continue }
|
||||||
|
|
||||||
|
let gameInfo = String(html[gameInfoRange])
|
||||||
|
let streamInfo = String(html[streamInfoRange])
|
||||||
|
|
||||||
|
// Must contain " @ " to be a game row (not MLB Network, not docs)
|
||||||
|
guard gameInfo.contains(" @ ") else { continue }
|
||||||
|
|
||||||
|
if let game = parseGameRow(
|
||||||
|
gameInfo: gameInfo,
|
||||||
|
streamInfo: streamInfo,
|
||||||
|
gameDate: todayDate
|
||||||
|
) {
|
||||||
|
games.append(game)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return games
|
||||||
|
}
|
||||||
|
|
||||||
|
private func parseGameRow(gameInfo: String, streamInfo: String, gameDate: String) -> Game? {
|
||||||
|
// Strip HTML tags for text parsing
|
||||||
|
let cleanInfo = gameInfo
|
||||||
|
.replacingOccurrences(of: "<br/>", with: "\n")
|
||||||
|
.replacingOccurrences(of: "<br>", with: "\n")
|
||||||
|
.replacingOccurrences(of: "<[^>]+>", with: "", options: .regularExpression)
|
||||||
|
.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||||
|
|
||||||
|
let lines = cleanInfo.split(separator: "\n").map { $0.trimmingCharacters(in: .whitespaces) }
|
||||||
|
guard !lines.isEmpty else { return nil }
|
||||||
|
|
||||||
|
// Line 1: "[Type: ]AWAY [score] @ HOME [score]"
|
||||||
|
let matchupLine = lines[0]
|
||||||
|
|
||||||
|
// Extract game type prefix (e.g. "Spring Training: ", "Exhibition Game: ")
|
||||||
|
var gameType: String?
|
||||||
|
var matchupText = matchupLine
|
||||||
|
if let colonRange = matchupLine.range(of: ": ") {
|
||||||
|
let prefix = String(matchupLine[matchupLine.startIndex..<colonRange.lowerBound])
|
||||||
|
// Only treat as game type if it doesn't look like a team name
|
||||||
|
if prefix.contains(" ") || prefix.count > 5 {
|
||||||
|
gameType = prefix
|
||||||
|
matchupText = String(matchupLine[colonRange.upperBound...])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Parse "AWAY [score] @ HOME [score]"
|
||||||
|
let atParts = matchupText.components(separatedBy: " @ ")
|
||||||
|
guard atParts.count == 2 else { return nil }
|
||||||
|
|
||||||
|
let (awayName, awayScore) = parseTeamAndScore(atParts[0].trimmingCharacters(in: .whitespaces))
|
||||||
|
let (homeName, homeScore) = parseTeamAndScore(atParts[1].trimmingCharacters(in: .whitespaces))
|
||||||
|
|
||||||
|
// Line 2: "Pitcher vs Pitcher" (optional)
|
||||||
|
let pitchers: String? = lines.count > 1 ? lines[1] : nil
|
||||||
|
|
||||||
|
// Line 3+: Status ("Final", "7:05 PM", "In Progress", "Top 5th", etc.) and venue
|
||||||
|
var statusText = ""
|
||||||
|
var venue: String?
|
||||||
|
for i in 2..<lines.count {
|
||||||
|
let line = lines[i]
|
||||||
|
if line.hasPrefix("at ") {
|
||||||
|
venue = line
|
||||||
|
} else if !line.isEmpty {
|
||||||
|
statusText = line
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine game status
|
||||||
|
let status: GameStatus
|
||||||
|
let lower = statusText.lowercased()
|
||||||
|
if lower.contains("final") || lower.contains("game over") || lower.contains("completed") {
|
||||||
|
status = .final_
|
||||||
|
} else if lower.contains("progress") || lower.contains("top") || lower.contains("bot")
|
||||||
|
|| lower.contains("mid") || lower.contains("end") || lower.contains("delay") {
|
||||||
|
status = .live(statusText)
|
||||||
|
} else if statusText.contains("PM") || statusText.contains("AM") || statusText.contains(":") {
|
||||||
|
status = .scheduled(statusText)
|
||||||
|
} else if statusText.isEmpty {
|
||||||
|
// If no status line, check if scores exist → probably final or live
|
||||||
|
if awayScore != nil {
|
||||||
|
status = .final_
|
||||||
|
} else {
|
||||||
|
status = .unknown
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
status = .unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for blackout
|
||||||
|
let isBlackedOut = streamInfo.contains("blackout")
|
||||||
|
|
||||||
|
// Extract broadcasts from stream info
|
||||||
|
let broadcasts = parseBroadcasts(from: streamInfo)
|
||||||
|
|
||||||
|
// Extract gamePk from highlights link
|
||||||
|
let gamePk: String? = {
|
||||||
|
let pattern = #"showhighlights\('(\d+)'"#
|
||||||
|
guard let regex = try? NSRegularExpression(pattern: pattern),
|
||||||
|
let match = regex.firstMatch(in: streamInfo, range: NSRange(streamInfo.startIndex..., in: streamInfo)),
|
||||||
|
let range = Range(match.range(at: 1), in: streamInfo) else { return nil }
|
||||||
|
return String(streamInfo[range])
|
||||||
|
}()
|
||||||
|
|
||||||
|
// Extract team codes from addmultiview
|
||||||
|
let teamCodes = extractTeamCodes(from: streamInfo)
|
||||||
|
let awayCode = teamCodes.count > 0 ? teamCodes[0] : awayName
|
||||||
|
let homeCode = teamCodes.count > 1 ? teamCodes[1] : homeName
|
||||||
|
|
||||||
|
let id = gamePk ?? "\(awayCode)-\(homeCode)-\(gameDate)"
|
||||||
|
|
||||||
|
return Game(
|
||||||
|
id: id,
|
||||||
|
awayTeam: TeamInfo(code: awayCode, name: awayName, score: awayScore),
|
||||||
|
homeTeam: TeamInfo(code: homeCode, name: homeName, score: homeScore),
|
||||||
|
status: status,
|
||||||
|
gameType: gameType,
|
||||||
|
startTime: {
|
||||||
|
if case .scheduled(let t) = status { return t }
|
||||||
|
return nil
|
||||||
|
}(),
|
||||||
|
venue: venue,
|
||||||
|
pitchers: pitchers,
|
||||||
|
gamePk: gamePk,
|
||||||
|
gameDate: gameDate,
|
||||||
|
broadcasts: broadcasts,
|
||||||
|
isBlackedOut: isBlackedOut
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parse "TeamName 5" or just "TeamName" → (name, score?)
|
||||||
|
private func parseTeamAndScore(_ text: String) -> (String, Int?) {
|
||||||
|
let parts = text.split(separator: " ")
|
||||||
|
guard parts.count >= 2,
|
||||||
|
let score = Int(parts.last!) else {
|
||||||
|
return (text, nil)
|
||||||
|
}
|
||||||
|
let name = parts.dropLast().joined(separator: " ")
|
||||||
|
return (name, score)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract broadcast info from the stream cell HTML
|
||||||
|
private func parseBroadcasts(from html: String) -> [Broadcast] {
|
||||||
|
var broadcasts: [Broadcast] = []
|
||||||
|
|
||||||
|
// Pattern: TEAM: <a href="/embed.html?mediaId=UUID">NAME</a><input ... value="streamURL" onclick="addmultiview(this, ['X', 'Y'])">
|
||||||
|
// Split by broadcast entries: look for "CODE: <a" patterns
|
||||||
|
let broadcastPattern = #"(\w+):\s*<a[^>]*href="/embed\.html\?mediaId=([^"&]+)"[^>]*>([^<]+)</a>\s*<input[^>]*value="([^"]*)"#
|
||||||
|
guard let regex = try? NSRegularExpression(pattern: broadcastPattern) else {
|
||||||
|
return broadcasts
|
||||||
|
}
|
||||||
|
|
||||||
|
let matches = regex.matches(in: html, range: NSRange(html.startIndex..., in: html))
|
||||||
|
for match in matches {
|
||||||
|
guard let teamRange = Range(match.range(at: 1), in: html),
|
||||||
|
let mediaIdRange = Range(match.range(at: 2), in: html),
|
||||||
|
let nameRange = Range(match.range(at: 3), in: html),
|
||||||
|
let urlRange = Range(match.range(at: 4), in: html) else { continue }
|
||||||
|
|
||||||
|
let broadcast = Broadcast(
|
||||||
|
id: String(html[mediaIdRange]),
|
||||||
|
teamCode: String(html[teamRange]),
|
||||||
|
name: String(html[nameRange]),
|
||||||
|
mediaId: String(html[mediaIdRange]),
|
||||||
|
streamURL: String(html[urlRange])
|
||||||
|
)
|
||||||
|
broadcasts.append(broadcast)
|
||||||
|
}
|
||||||
|
|
||||||
|
return broadcasts
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Extract team codes from addmultiview onclick
|
||||||
|
private func extractTeamCodes(from html: String) -> [String] {
|
||||||
|
let pattern = #"addmultiview\(this,\s*\[([^\]]*)\]"#
|
||||||
|
guard let regex = try? NSRegularExpression(pattern: pattern),
|
||||||
|
let match = regex.firstMatch(in: html, range: NSRange(html.startIndex..., in: html)),
|
||||||
|
let range = Range(match.range(at: 1), in: html) else { return [] }
|
||||||
|
|
||||||
|
return String(html[range])
|
||||||
|
.replacingOccurrences(of: "'", with: "")
|
||||||
|
.replacingOccurrences(of: "\"", with: "")
|
||||||
|
.split(separator: ",")
|
||||||
|
.map { $0.trimmingCharacters(in: .whitespaces) }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Types
|
||||||
|
|
||||||
|
struct Highlight: Codable, Sendable, Identifiable {
|
||||||
|
let id: String?
|
||||||
|
let headline: String?
|
||||||
|
let playbacks: [Playback]?
|
||||||
|
|
||||||
|
struct Playback: Codable, Sendable {
|
||||||
|
let name: String?
|
||||||
|
let url: String?
|
||||||
|
}
|
||||||
|
|
||||||
|
var hlsURL: String? {
|
||||||
|
playbacks?.first(where: { $0.name == "HTTP_CLOUD_WIRED_60" })?.url
|
||||||
|
}
|
||||||
|
|
||||||
|
var mp4URL: String? {
|
||||||
|
playbacks?.first(where: { $0.name == "mp4Avc" })?.url
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum APIError: Error, LocalizedError {
|
||||||
|
case invalidURL
|
||||||
|
case serverError
|
||||||
|
case invalidData
|
||||||
|
case noStreamsAvailable
|
||||||
|
|
||||||
|
var errorDescription: String? {
|
||||||
|
switch self {
|
||||||
|
case .invalidURL: "Invalid server URL"
|
||||||
|
case .serverError: "Server returned an error"
|
||||||
|
case .invalidData: "Could not parse server response"
|
||||||
|
case .noStreamsAvailable: "No streams available"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1056
mlbTVOS/Services/MLBStatsAPI.swift
Normal file
1056
mlbTVOS/Services/MLBStatsAPI.swift
Normal file
File diff suppressed because it is too large
Load Diff
45
mlbTVOS/ViewModels/GameCenterViewModel.swift
Normal file
45
mlbTVOS/ViewModels/GameCenterViewModel.swift
Normal file
@@ -0,0 +1,45 @@
|
|||||||
|
import Foundation
|
||||||
|
import Observation
|
||||||
|
|
||||||
|
@Observable
|
||||||
|
@MainActor
|
||||||
|
final class GameCenterViewModel {
|
||||||
|
var feed: LiveGameFeed?
|
||||||
|
var isLoading = false
|
||||||
|
var errorMessage: String?
|
||||||
|
var lastUpdated: Date?
|
||||||
|
|
||||||
|
private let statsAPI = MLBStatsAPI()
|
||||||
|
|
||||||
|
func watch(game: Game) async {
|
||||||
|
guard let gamePk = game.gamePk else {
|
||||||
|
errorMessage = "No live game feed is available for this matchup."
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
while !Task.isCancelled {
|
||||||
|
await refresh(gamePk: gamePk)
|
||||||
|
|
||||||
|
let liveState = feed?.gameData.status?.abstractGameState == "Live"
|
||||||
|
if !liveState {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
|
try? await Task.sleep(for: .seconds(12))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func refresh(gamePk: String) async {
|
||||||
|
isLoading = true
|
||||||
|
errorMessage = nil
|
||||||
|
|
||||||
|
do {
|
||||||
|
feed = try await statsAPI.fetchGameFeed(gamePk: gamePk)
|
||||||
|
lastUpdated = Date()
|
||||||
|
} catch {
|
||||||
|
errorMessage = "Failed to load game center."
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
640
mlbTVOS/ViewModels/GamesViewModel.swift
Normal file
640
mlbTVOS/ViewModels/GamesViewModel.swift
Normal file
@@ -0,0 +1,640 @@
|
|||||||
|
import AVFoundation
|
||||||
|
import Foundation
|
||||||
|
import Observation
|
||||||
|
import OSLog
|
||||||
|
|
||||||
|
private let gamesViewModelLogger = Logger(subsystem: "com.treyt.mlbTVOS", category: "GamesViewModel")
|
||||||
|
|
||||||
|
private func logGamesViewModel(_ message: String) {
|
||||||
|
gamesViewModelLogger.debug("\(message, privacy: .public)")
|
||||||
|
print("[GamesViewModel] \(message)")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func gamesViewModelDebugURLDescription(_ url: URL) -> String {
|
||||||
|
var host = url.host ?? "unknown-host"
|
||||||
|
if let port = url.port {
|
||||||
|
host += ":\(port)"
|
||||||
|
}
|
||||||
|
|
||||||
|
let queryKeys = URLComponents(url: url, resolvingAgainstBaseURL: false)?
|
||||||
|
.queryItems?
|
||||||
|
.map(\.name) ?? []
|
||||||
|
let querySuffix = queryKeys.isEmpty ? "" : "?\(queryKeys.joined(separator: "&"))"
|
||||||
|
|
||||||
|
return "\(url.scheme ?? "unknown")://\(host)\(url.path)\(querySuffix)"
|
||||||
|
}
|
||||||
|
|
||||||
|
@Observable
|
||||||
|
@MainActor
|
||||||
|
final class GamesViewModel {
|
||||||
|
var games: [Game] = []
|
||||||
|
var isLoading = false
|
||||||
|
var errorMessage: String?
|
||||||
|
var selectedDate: String?
|
||||||
|
|
||||||
|
var activeStreams: [ActiveStream] = []
|
||||||
|
var multiViewLayoutMode: MultiViewLayoutMode = .balanced
|
||||||
|
var audioFocusStreamID: String?
|
||||||
|
|
||||||
|
var serverBaseURL: String = "http://10.3.3.11:5714"
|
||||||
|
var defaultResolution: String = "best"
|
||||||
|
|
||||||
|
@ObservationIgnored
|
||||||
|
private var refreshTask: Task<Void, Never>?
|
||||||
|
|
||||||
|
// Computed properties for dashboard
|
||||||
|
var liveGames: [Game] { games.filter(\.isLive) }
|
||||||
|
var scheduledGames: [Game] { games.filter { $0.status.isScheduled } }
|
||||||
|
var finalGames: [Game] { games.filter(\.isFinal) }
|
||||||
|
var featuredGame: Game? {
|
||||||
|
let astrosGame = games.first { $0.awayTeam.code == "HOU" || $0.homeTeam.code == "HOU" }
|
||||||
|
return astrosGame ?? liveGames.first ?? scheduledGames.first ?? games.first
|
||||||
|
}
|
||||||
|
var activeAudioStream: ActiveStream? {
|
||||||
|
guard let audioFocusStreamID else { return nil }
|
||||||
|
return activeStreams.first { $0.id == audioFocusStreamID }
|
||||||
|
}
|
||||||
|
|
||||||
|
private var mlbServerAPI: MLBServerAPI { MLBServerAPI(baseURL: serverBaseURL) }
|
||||||
|
private let statsAPI = MLBStatsAPI()
|
||||||
|
|
||||||
|
private static let dateFormatter: DateFormatter = {
|
||||||
|
let f = DateFormatter()
|
||||||
|
f.dateFormat = "yyyy-MM-dd"
|
||||||
|
return f
|
||||||
|
}()
|
||||||
|
|
||||||
|
private var currentDate: Date {
|
||||||
|
if let s = selectedDate, let d = Self.dateFormatter.date(from: s) { return d }
|
||||||
|
return Date()
|
||||||
|
}
|
||||||
|
|
||||||
|
var todayDateString: String {
|
||||||
|
Self.dateFormatter.string(from: currentDate)
|
||||||
|
}
|
||||||
|
|
||||||
|
var displayDateString: String {
|
||||||
|
let display = DateFormatter()
|
||||||
|
display.dateFormat = "EEEE, MMMM d"
|
||||||
|
return display.string(from: currentDate)
|
||||||
|
}
|
||||||
|
|
||||||
|
var isToday: Bool {
|
||||||
|
Calendar.current.isDateInToday(currentDate)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Auto-Refresh
|
||||||
|
|
||||||
|
func startAutoRefresh() {
|
||||||
|
stopAutoRefresh()
|
||||||
|
refreshTask = Task { [weak self] in
|
||||||
|
while !Task.isCancelled {
|
||||||
|
try? await Task.sleep(for: .seconds(60))
|
||||||
|
guard !Task.isCancelled else { break }
|
||||||
|
guard let self else { break }
|
||||||
|
// Refresh if there are live games or active streams
|
||||||
|
if !self.liveGames.isEmpty || !self.activeStreams.isEmpty {
|
||||||
|
await self.refreshScores()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func stopAutoRefresh() {
|
||||||
|
refreshTask?.cancel()
|
||||||
|
refreshTask = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
private func refreshScores() async {
|
||||||
|
let statsGames = await fetchStatsGames()
|
||||||
|
guard !statsGames.isEmpty else { return }
|
||||||
|
|
||||||
|
// Update scores/innings on existing games without full reload
|
||||||
|
for sg in statsGames {
|
||||||
|
let pkStr = String(sg.gamePk)
|
||||||
|
if let idx = games.firstIndex(where: { $0.id == pkStr }) {
|
||||||
|
if !sg.isScheduled {
|
||||||
|
games[idx] = Game(
|
||||||
|
id: games[idx].id,
|
||||||
|
awayTeam: TeamInfo(
|
||||||
|
code: games[idx].awayTeam.code,
|
||||||
|
name: games[idx].awayTeam.name,
|
||||||
|
score: sg.teams.away.score,
|
||||||
|
teamId: games[idx].awayTeam.teamId,
|
||||||
|
record: games[idx].awayTeam.record
|
||||||
|
),
|
||||||
|
homeTeam: TeamInfo(
|
||||||
|
code: games[idx].homeTeam.code,
|
||||||
|
name: games[idx].homeTeam.name,
|
||||||
|
score: sg.teams.home.score,
|
||||||
|
teamId: games[idx].homeTeam.teamId,
|
||||||
|
record: games[idx].homeTeam.record
|
||||||
|
),
|
||||||
|
status: sg.isLive ? .live(sg.linescore?.currentInningDisplay) : sg.isFinal ? .final_ : games[idx].status,
|
||||||
|
gameType: games[idx].gameType,
|
||||||
|
startTime: games[idx].startTime,
|
||||||
|
venue: games[idx].venue,
|
||||||
|
pitchers: games[idx].pitchers,
|
||||||
|
gamePk: games[idx].gamePk,
|
||||||
|
gameDate: games[idx].gameDate,
|
||||||
|
broadcasts: games[idx].broadcasts,
|
||||||
|
isBlackedOut: games[idx].isBlackedOut,
|
||||||
|
linescore: sg.linescore,
|
||||||
|
currentInningDisplay: sg.linescore?.currentInningDisplay,
|
||||||
|
awayPitcherId: games[idx].awayPitcherId,
|
||||||
|
homePitcherId: games[idx].homePitcherId
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Also update the game reference in active streams
|
||||||
|
for streamIdx in activeStreams.indices {
|
||||||
|
if activeStreams[streamIdx].game.id == pkStr {
|
||||||
|
activeStreams[streamIdx] = ActiveStream(
|
||||||
|
id: activeStreams[streamIdx].id,
|
||||||
|
game: games[idx],
|
||||||
|
label: activeStreams[streamIdx].label,
|
||||||
|
mediaId: activeStreams[streamIdx].mediaId,
|
||||||
|
streamURLString: activeStreams[streamIdx].streamURLString,
|
||||||
|
config: activeStreams[streamIdx].config,
|
||||||
|
overrideURL: activeStreams[streamIdx].overrideURL,
|
||||||
|
player: activeStreams[streamIdx].player,
|
||||||
|
isPlaying: activeStreams[streamIdx].isPlaying,
|
||||||
|
isMuted: activeStreams[streamIdx].isMuted
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Date Navigation
|
||||||
|
|
||||||
|
func goToPreviousDay() async {
|
||||||
|
let prev = Calendar.current.date(byAdding: .day, value: -1, to: currentDate)!
|
||||||
|
selectedDate = Self.dateFormatter.string(from: prev)
|
||||||
|
await loadGames()
|
||||||
|
}
|
||||||
|
|
||||||
|
func goToNextDay() async {
|
||||||
|
let next = Calendar.current.date(byAdding: .day, value: 1, to: currentDate)!
|
||||||
|
selectedDate = Self.dateFormatter.string(from: next)
|
||||||
|
await loadGames()
|
||||||
|
}
|
||||||
|
|
||||||
|
func goToToday() async {
|
||||||
|
selectedDate = nil
|
||||||
|
await loadGames()
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Load Games
|
||||||
|
|
||||||
|
func loadGames() async {
|
||||||
|
isLoading = true
|
||||||
|
errorMessage = nil
|
||||||
|
|
||||||
|
// Fetch all sources concurrently
|
||||||
|
async let serverGamesTask = fetchServerGames()
|
||||||
|
async let statsGamesTask = fetchStatsGames()
|
||||||
|
async let standingsTask = fetchStandings()
|
||||||
|
|
||||||
|
let serverGames = await serverGamesTask
|
||||||
|
let statsGames = await statsGamesTask
|
||||||
|
let standings = await standingsTask
|
||||||
|
|
||||||
|
// Merge: Stats API is primary for rich data, mlbserver provides stream links
|
||||||
|
games = mergeGames(serverGames: serverGames, statsGames: statsGames, standings: standings)
|
||||||
|
|
||||||
|
if games.isEmpty {
|
||||||
|
errorMessage = "No games found"
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoading = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private func fetchServerGames() async -> [Game] {
|
||||||
|
do {
|
||||||
|
return try await mlbServerAPI.fetchGames(date: selectedDate)
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func fetchStatsGames() async -> [StatsGame] {
|
||||||
|
do {
|
||||||
|
return try await statsAPI.fetchSchedule(date: todayDateString)
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func fetchStandings() async -> [Int: TeamStanding] {
|
||||||
|
let year = String(todayDateString.prefix(4))
|
||||||
|
do {
|
||||||
|
return try await statsAPI.fetchStandings(season: year)
|
||||||
|
} catch {
|
||||||
|
return [:]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func mergeGames(serverGames: [Game], statsGames: [StatsGame], standings: [Int: TeamStanding] = [:]) -> [Game] {
|
||||||
|
// Build lookup from server games by gamePk
|
||||||
|
var serverByPk: [String: Game] = [:]
|
||||||
|
for g in serverGames {
|
||||||
|
if let pk = g.gamePk { serverByPk[pk] = g }
|
||||||
|
}
|
||||||
|
|
||||||
|
var merged: [Game] = []
|
||||||
|
|
||||||
|
for sg in statsGames {
|
||||||
|
let pkStr = String(sg.gamePk)
|
||||||
|
let serverGame = serverByPk[pkStr]
|
||||||
|
|
||||||
|
let awayAbbr = sg.teams.away.team.abbreviation ?? "???"
|
||||||
|
let homeAbbr = sg.teams.home.team.abbreviation ?? "???"
|
||||||
|
|
||||||
|
let awayRecord: String? = {
|
||||||
|
guard let r = sg.teams.away.leagueRecord else { return nil }
|
||||||
|
return "\(r.wins)-\(r.losses)"
|
||||||
|
}()
|
||||||
|
let homeRecord: String? = {
|
||||||
|
guard let r = sg.teams.home.leagueRecord else { return nil }
|
||||||
|
return "\(r.wins)-\(r.losses)"
|
||||||
|
}()
|
||||||
|
|
||||||
|
let pitchers: String? = {
|
||||||
|
let away = sg.teams.away.probablePitcher?.fullName
|
||||||
|
let home = sg.teams.home.probablePitcher?.fullName
|
||||||
|
if let away, let home { return "\(away) vs \(home)" }
|
||||||
|
return away ?? home
|
||||||
|
}()
|
||||||
|
|
||||||
|
let status: GameStatus
|
||||||
|
if sg.isLive {
|
||||||
|
status = .live(sg.linescore?.currentInningDisplay)
|
||||||
|
} else if sg.isFinal {
|
||||||
|
status = .final_
|
||||||
|
} else if let time = sg.startTime {
|
||||||
|
status = .scheduled(time)
|
||||||
|
} else {
|
||||||
|
status = .unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
let awayStanding = standings[sg.teams.away.team.id]
|
||||||
|
let homeStanding = standings[sg.teams.home.team.id]
|
||||||
|
|
||||||
|
let game = Game(
|
||||||
|
id: pkStr,
|
||||||
|
awayTeam: TeamInfo(
|
||||||
|
code: awayAbbr,
|
||||||
|
name: sg.teams.away.team.name ?? awayAbbr,
|
||||||
|
score: sg.isScheduled ? nil : sg.teams.away.score,
|
||||||
|
teamId: sg.teams.away.team.id,
|
||||||
|
record: awayRecord,
|
||||||
|
divisionRank: awayStanding?.divisionRank,
|
||||||
|
gamesBack: awayStanding?.gamesBack,
|
||||||
|
streak: awayStanding?.streak
|
||||||
|
),
|
||||||
|
homeTeam: TeamInfo(
|
||||||
|
code: homeAbbr,
|
||||||
|
name: sg.teams.home.team.name ?? homeAbbr,
|
||||||
|
score: sg.isScheduled ? nil : sg.teams.home.score,
|
||||||
|
teamId: sg.teams.home.team.id,
|
||||||
|
record: homeRecord,
|
||||||
|
divisionRank: homeStanding?.divisionRank,
|
||||||
|
gamesBack: homeStanding?.gamesBack,
|
||||||
|
streak: homeStanding?.streak
|
||||||
|
),
|
||||||
|
status: status,
|
||||||
|
gameType: sg.seriesDescription ?? sg.gameType,
|
||||||
|
startTime: sg.startTime,
|
||||||
|
venue: sg.venue?.name,
|
||||||
|
pitchers: pitchers ?? serverGame?.pitchers,
|
||||||
|
gamePk: pkStr,
|
||||||
|
gameDate: todayDateString,
|
||||||
|
broadcasts: serverGame?.broadcasts ?? [],
|
||||||
|
isBlackedOut: serverGame?.isBlackedOut ?? false,
|
||||||
|
linescore: sg.linescore,
|
||||||
|
currentInningDisplay: sg.linescore?.currentInningDisplay,
|
||||||
|
awayPitcherId: sg.teams.away.probablePitcher?.id,
|
||||||
|
homePitcherId: sg.teams.home.probablePitcher?.id
|
||||||
|
)
|
||||||
|
merged.append(game)
|
||||||
|
serverByPk.removeValue(forKey: pkStr)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add any server-only games not in Stats API
|
||||||
|
for (_, serverGame) in serverByPk {
|
||||||
|
merged.append(serverGame)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort: live first, then scheduled by time, then final
|
||||||
|
merged.sort { a, b in
|
||||||
|
let order: (Game) -> Int = { g in
|
||||||
|
if g.isLive { return 0 }
|
||||||
|
if g.status.isScheduled { return 1 }
|
||||||
|
return 2
|
||||||
|
}
|
||||||
|
return order(a) < order(b)
|
||||||
|
}
|
||||||
|
|
||||||
|
return merged
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Stream Management
|
||||||
|
|
||||||
|
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,
|
||||||
|
label: broadcast.displayLabel,
|
||||||
|
mediaId: broadcast.mediaId,
|
||||||
|
streamURLString: broadcast.streamURL
|
||||||
|
)
|
||||||
|
activeStreams.append(stream)
|
||||||
|
if shouldCaptureAudio {
|
||||||
|
audioFocusStreamID = stream.id
|
||||||
|
}
|
||||||
|
syncAudioFocus()
|
||||||
|
}
|
||||||
|
|
||||||
|
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)",
|
||||||
|
game: game,
|
||||||
|
label: teamCode,
|
||||||
|
config: config
|
||||||
|
)
|
||||||
|
activeStreams.append(stream)
|
||||||
|
if shouldCaptureAudio {
|
||||||
|
audioFocusStreamID = stream.id
|
||||||
|
}
|
||||||
|
syncAudioFocus()
|
||||||
|
}
|
||||||
|
|
||||||
|
func removeStream(id: String) {
|
||||||
|
if let index = activeStreams.firstIndex(where: { $0.id == id }) {
|
||||||
|
let removedWasAudioFocus = activeStreams[index].id == audioFocusStreamID
|
||||||
|
activeStreams[index].player?.pause()
|
||||||
|
activeStreams.remove(at: index)
|
||||||
|
if activeStreams.isEmpty {
|
||||||
|
audioFocusStreamID = nil
|
||||||
|
} else if removedWasAudioFocus {
|
||||||
|
let replacementIndex = min(index, activeStreams.count - 1)
|
||||||
|
audioFocusStreamID = activeStreams[replacementIndex].id
|
||||||
|
}
|
||||||
|
syncAudioFocus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func clearAllStreams() {
|
||||||
|
for s in activeStreams { s.player?.pause() }
|
||||||
|
activeStreams.removeAll()
|
||||||
|
audioFocusStreamID = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func promoteStream(id: String) {
|
||||||
|
guard let index = activeStreams.firstIndex(where: { $0.id == id }), index > 0 else { return }
|
||||||
|
let stream = activeStreams.remove(at: index)
|
||||||
|
activeStreams.insert(stream, at: 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func canMoveStream(id: String, direction: Int) -> Bool {
|
||||||
|
guard let index = activeStreams.firstIndex(where: { $0.id == id }) else { return false }
|
||||||
|
let newIndex = index + direction
|
||||||
|
return activeStreams.indices.contains(newIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
func moveStream(id: String, direction: Int) {
|
||||||
|
guard let index = activeStreams.firstIndex(where: { $0.id == id }) else { return }
|
||||||
|
let newIndex = index + direction
|
||||||
|
guard activeStreams.indices.contains(newIndex) else { return }
|
||||||
|
activeStreams.swapAt(index, newIndex)
|
||||||
|
}
|
||||||
|
|
||||||
|
func setAudioFocus(streamID: String?) {
|
||||||
|
if let streamID, activeStreams.contains(where: { $0.id == streamID }) {
|
||||||
|
audioFocusStreamID = streamID
|
||||||
|
} else {
|
||||||
|
audioFocusStreamID = nil
|
||||||
|
}
|
||||||
|
syncAudioFocus()
|
||||||
|
}
|
||||||
|
|
||||||
|
func toggleAudioFocus(streamID: String) {
|
||||||
|
setAudioFocus(streamID: audioFocusStreamID == streamID ? nil : streamID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func attachPlayer(_ player: AVPlayer, to streamID: String) {
|
||||||
|
guard let index = activeStreams.firstIndex(where: { $0.id == streamID }) else { return }
|
||||||
|
activeStreams[index].player = player
|
||||||
|
activeStreams[index].isPlaying = true
|
||||||
|
let shouldMute = audioFocusStreamID != streamID
|
||||||
|
activeStreams[index].isMuted = shouldMute
|
||||||
|
player.isMuted = shouldMute
|
||||||
|
}
|
||||||
|
|
||||||
|
func isPrimaryStream(_ streamID: String) -> Bool {
|
||||||
|
activeStreams.first?.id == streamID
|
||||||
|
}
|
||||||
|
|
||||||
|
private func syncAudioFocus() {
|
||||||
|
for index in activeStreams.indices {
|
||||||
|
let shouldMute = activeStreams[index].id != audioFocusStreamID
|
||||||
|
activeStreams[index].isMuted = shouldMute
|
||||||
|
activeStreams[index].player?.isMuted = shouldMute
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildStreamURL(for config: StreamConfig) async -> URL {
|
||||||
|
let startedAt = Date()
|
||||||
|
logGamesViewModel("buildStreamURL start mediaId=\(config.mediaId) resolution=\(config.resolution)")
|
||||||
|
let url = await mlbServerAPI.streamURL(for: config)
|
||||||
|
let elapsedMs = Int(Date().timeIntervalSince(startedAt) * 1000)
|
||||||
|
logGamesViewModel("buildStreamURL success mediaId=\(config.mediaId) elapsedMs=\(elapsedMs) url=\(gamesViewModelDebugURLDescription(url))")
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
|
||||||
|
func buildEventStreamURL(event: String, resolution: String = "best") async -> URL {
|
||||||
|
let startedAt = Date()
|
||||||
|
logGamesViewModel("buildEventStreamURL start event=\(event) resolution=\(resolution)")
|
||||||
|
let url = await mlbServerAPI.eventStreamURL(event: event, resolution: resolution)
|
||||||
|
let elapsedMs = Int(Date().timeIntervalSince(startedAt) * 1000)
|
||||||
|
logGamesViewModel("buildEventStreamURL success event=\(event) elapsedMs=\(elapsedMs) url=\(gamesViewModelDebugURLDescription(url))")
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveStreamURL(for stream: ActiveStream) async -> URL? {
|
||||||
|
await resolveStreamURLImpl(
|
||||||
|
for: stream,
|
||||||
|
resolutionOverride: nil,
|
||||||
|
preserveServerResolutionWhenBest: true
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func resolveStreamURL(
|
||||||
|
for stream: ActiveStream,
|
||||||
|
resolutionOverride: String,
|
||||||
|
preserveServerResolutionWhenBest: Bool = false
|
||||||
|
) async -> URL? {
|
||||||
|
await resolveStreamURLImpl(
|
||||||
|
for: stream,
|
||||||
|
resolutionOverride: resolutionOverride,
|
||||||
|
preserveServerResolutionWhenBest: preserveServerResolutionWhenBest
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func resolveStreamURLImpl(
|
||||||
|
for stream: ActiveStream,
|
||||||
|
resolutionOverride: String?,
|
||||||
|
preserveServerResolutionWhenBest: Bool
|
||||||
|
) async -> URL? {
|
||||||
|
let requestedResolution = resolutionOverride ?? defaultResolution
|
||||||
|
logGamesViewModel(
|
||||||
|
"resolveStreamURL start id=\(stream.id) label=\(stream.label) requestedResolution=\(requestedResolution) hasDirectURL=\(stream.streamURLString != nil) hasMediaId=\(stream.mediaId != nil) hasConfig=\(stream.config != nil) hasOverride=\(stream.overrideURL != nil)"
|
||||||
|
)
|
||||||
|
|
||||||
|
if let urlStr = stream.streamURLString {
|
||||||
|
logGamesViewModel("resolveStreamURL using direct stream URL id=\(stream.id) raw=\(urlStr)")
|
||||||
|
let rewritten = urlStr.replacingOccurrences(of: "http://127.0.0.1:9999", with: serverBaseURL)
|
||||||
|
if let url = URL(string: rewritten), var components = URLComponents(url: url, resolvingAgainstBaseURL: false) {
|
||||||
|
var items = components.queryItems ?? []
|
||||||
|
let hasResolution = items.contains { $0.name == "resolution" }
|
||||||
|
|
||||||
|
if requestedResolution == "best", hasResolution, preserveServerResolutionWhenBest {
|
||||||
|
logGamesViewModel("resolveStreamURL preserving server resolution for direct URL id=\(stream.id) because requestedResolution=best")
|
||||||
|
} else if let idx = items.firstIndex(where: { $0.name == "resolution" }) {
|
||||||
|
items[idx] = URLQueryItem(name: "resolution", value: requestedResolution)
|
||||||
|
} else if resolutionOverride != nil || requestedResolution != "best" {
|
||||||
|
items.append(URLQueryItem(name: "resolution", value: requestedResolution))
|
||||||
|
}
|
||||||
|
components.queryItems = items
|
||||||
|
|
||||||
|
if let resolvedURL = components.url {
|
||||||
|
logGamesViewModel(
|
||||||
|
"resolveStreamURL direct success id=\(stream.id) url=\(gamesViewModelDebugURLDescription(resolvedURL))"
|
||||||
|
)
|
||||||
|
return resolvedURL
|
||||||
|
}
|
||||||
|
|
||||||
|
logGamesViewModel("resolveStreamURL direct failed id=\(stream.id) could not rebuild URL components")
|
||||||
|
} else {
|
||||||
|
logGamesViewModel("resolveStreamURL direct failed id=\(stream.id) invalid rewritten URL")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let mediaId = stream.mediaId {
|
||||||
|
let startedAt = Date()
|
||||||
|
logGamesViewModel("resolveStreamURL fetching mediaId id=\(stream.id) mediaId=\(mediaId) resolution=\(requestedResolution)")
|
||||||
|
let url = await mlbServerAPI.streamURL(for: StreamConfig(mediaId: mediaId, resolution: requestedResolution))
|
||||||
|
let elapsedMs = Int(Date().timeIntervalSince(startedAt) * 1000)
|
||||||
|
logGamesViewModel(
|
||||||
|
"resolveStreamURL mediaId success id=\(stream.id) mediaId=\(mediaId) elapsedMs=\(elapsedMs) url=\(gamesViewModelDebugURLDescription(url))"
|
||||||
|
)
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
|
||||||
|
if let config = stream.config {
|
||||||
|
let startedAt = Date()
|
||||||
|
let configResolution = resolutionOverride ?? config.resolution
|
||||||
|
logGamesViewModel("resolveStreamURL fetching config id=\(stream.id) mediaId=\(config.mediaId) resolution=\(configResolution)")
|
||||||
|
let requestConfig: StreamConfig
|
||||||
|
if let mediaId = config.mediaId {
|
||||||
|
requestConfig = StreamConfig(
|
||||||
|
mediaId: mediaId,
|
||||||
|
resolution: configResolution,
|
||||||
|
audioTrack: config.audioTrack
|
||||||
|
)
|
||||||
|
} else if let team = config.team {
|
||||||
|
requestConfig = StreamConfig(
|
||||||
|
team: team,
|
||||||
|
resolution: configResolution,
|
||||||
|
date: config.date,
|
||||||
|
level: config.level,
|
||||||
|
skip: config.skip,
|
||||||
|
audioTrack: config.audioTrack,
|
||||||
|
mediaType: config.mediaType,
|
||||||
|
game: config.game
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
logGamesViewModel("resolveStreamURL config failed id=\(stream.id) missing mediaId and team")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
let url = await mlbServerAPI.streamURL(for: requestConfig)
|
||||||
|
let elapsedMs = Int(Date().timeIntervalSince(startedAt) * 1000)
|
||||||
|
logGamesViewModel(
|
||||||
|
"resolveStreamURL config success id=\(stream.id) mediaId=\(config.mediaId) elapsedMs=\(elapsedMs) url=\(gamesViewModelDebugURLDescription(url))"
|
||||||
|
)
|
||||||
|
return url
|
||||||
|
}
|
||||||
|
|
||||||
|
logGamesViewModel("resolveStreamURL failed id=\(stream.id) no usable stream source")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
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",
|
||||||
|
awayTeam: TeamInfo(code: "MLBN", name: "MLB Network", score: nil),
|
||||||
|
homeTeam: TeamInfo(code: "MLBN", name: "MLB Network", score: nil),
|
||||||
|
status: .live(nil), gameType: nil, startTime: nil, venue: nil,
|
||||||
|
pitchers: nil, gamePk: nil, gameDate: "",
|
||||||
|
broadcasts: [], isBlackedOut: false
|
||||||
|
)
|
||||||
|
let url = await mlbServerAPI.eventStreamURL(event: "MLBN", resolution: defaultResolution)
|
||||||
|
let stream = ActiveStream(
|
||||||
|
id: "MLBN", game: dummyGame, label: "MLB Network", overrideURL: url
|
||||||
|
)
|
||||||
|
activeStreams.append(stream)
|
||||||
|
if shouldCaptureAudio {
|
||||||
|
audioFocusStreamID = stream.id
|
||||||
|
}
|
||||||
|
syncAudioFocus()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum MultiViewLayoutMode: String, CaseIterable, Identifiable, Sendable {
|
||||||
|
case balanced
|
||||||
|
case spotlight
|
||||||
|
|
||||||
|
var id: String { rawValue }
|
||||||
|
|
||||||
|
var title: String {
|
||||||
|
switch self {
|
||||||
|
case .balanced: "Balanced"
|
||||||
|
case .spotlight: "Spotlight"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var systemImage: String {
|
||||||
|
switch self {
|
||||||
|
case .balanced: "square.grid.2x2"
|
||||||
|
case .spotlight: "rectangle.leadinghalf.filled"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct ActiveStream: Identifiable, @unchecked Sendable {
|
||||||
|
let id: String
|
||||||
|
let game: Game
|
||||||
|
let label: String
|
||||||
|
var mediaId: String?
|
||||||
|
var streamURLString: String?
|
||||||
|
var config: StreamConfig?
|
||||||
|
var overrideURL: URL?
|
||||||
|
var player: AVPlayer?
|
||||||
|
var isPlaying = false
|
||||||
|
var isMuted = false
|
||||||
|
}
|
||||||
132
mlbTVOS/ViewModels/LeagueCenterViewModel.swift
Normal file
132
mlbTVOS/ViewModels/LeagueCenterViewModel.swift
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
import Foundation
|
||||||
|
import Observation
|
||||||
|
|
||||||
|
@Observable
|
||||||
|
@MainActor
|
||||||
|
final class LeagueCenterViewModel {
|
||||||
|
var scheduleGames: [StatsGame] = []
|
||||||
|
var standings: [StandingsDivisionRecord] = []
|
||||||
|
var teams: [LeagueTeamSummary] = []
|
||||||
|
|
||||||
|
var selectedTeam: TeamProfile?
|
||||||
|
var roster: [RosterPlayerSummary] = []
|
||||||
|
var selectedPlayer: PlayerProfile?
|
||||||
|
|
||||||
|
var isLoadingOverview = false
|
||||||
|
var isLoadingTeam = false
|
||||||
|
var isLoadingPlayer = false
|
||||||
|
|
||||||
|
var overviewErrorMessage: String?
|
||||||
|
var teamErrorMessage: String?
|
||||||
|
var playerErrorMessage: String?
|
||||||
|
|
||||||
|
private let statsAPI = MLBStatsAPI()
|
||||||
|
private(set) var scheduleDate = Date()
|
||||||
|
|
||||||
|
private var seasonString: String {
|
||||||
|
String(Calendar.current.component(.year, from: scheduleDate))
|
||||||
|
}
|
||||||
|
|
||||||
|
private var scheduleDateString: String {
|
||||||
|
Self.scheduleFormatter.string(from: scheduleDate)
|
||||||
|
}
|
||||||
|
|
||||||
|
var displayDateString: String {
|
||||||
|
Self.displayFormatter.string(from: scheduleDate)
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadInitial() async {
|
||||||
|
isLoadingOverview = true
|
||||||
|
overviewErrorMessage = nil
|
||||||
|
|
||||||
|
async let scheduleTask = statsAPI.fetchSchedule(date: scheduleDateString)
|
||||||
|
async let standingsTask = statsAPI.fetchStandingsRecords(season: seasonString)
|
||||||
|
async let teamsTask = statsAPI.fetchLeagueTeams(season: seasonString)
|
||||||
|
|
||||||
|
do {
|
||||||
|
scheduleGames = try await scheduleTask
|
||||||
|
standings = try await standingsTask
|
||||||
|
teams = try await teamsTask
|
||||||
|
|
||||||
|
if selectedTeam == nil, let firstTeam = teams.first {
|
||||||
|
await selectTeam(firstTeam.id)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
overviewErrorMessage = "Failed to load league information."
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoadingOverview = false
|
||||||
|
}
|
||||||
|
|
||||||
|
func goToPreviousDay() async {
|
||||||
|
scheduleDate = Calendar.current.date(byAdding: .day, value: -1, to: scheduleDate) ?? scheduleDate
|
||||||
|
await loadSchedule()
|
||||||
|
}
|
||||||
|
|
||||||
|
func goToNextDay() async {
|
||||||
|
scheduleDate = Calendar.current.date(byAdding: .day, value: 1, to: scheduleDate) ?? scheduleDate
|
||||||
|
await loadSchedule()
|
||||||
|
}
|
||||||
|
|
||||||
|
func goToToday() async {
|
||||||
|
scheduleDate = Date()
|
||||||
|
await loadSchedule()
|
||||||
|
}
|
||||||
|
|
||||||
|
func loadSchedule() async {
|
||||||
|
do {
|
||||||
|
scheduleGames = try await statsAPI.fetchSchedule(date: scheduleDateString)
|
||||||
|
} catch {
|
||||||
|
overviewErrorMessage = "Failed to load schedule."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func selectTeam(_ teamID: Int) async {
|
||||||
|
isLoadingTeam = true
|
||||||
|
teamErrorMessage = nil
|
||||||
|
selectedPlayer = nil
|
||||||
|
playerErrorMessage = nil
|
||||||
|
|
||||||
|
do {
|
||||||
|
async let profileTask = statsAPI.fetchTeamProfile(teamID: teamID, season: seasonString)
|
||||||
|
async let rosterTask = statsAPI.fetchTeamRoster(teamID: teamID, season: seasonString)
|
||||||
|
|
||||||
|
selectedTeam = try await profileTask
|
||||||
|
roster = try await rosterTask
|
||||||
|
|
||||||
|
if let firstPlayer = roster.first {
|
||||||
|
await selectPlayer(firstPlayer.id)
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
teamErrorMessage = "Failed to load team details."
|
||||||
|
roster = []
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoadingTeam = false
|
||||||
|
}
|
||||||
|
|
||||||
|
func selectPlayer(_ playerID: Int) async {
|
||||||
|
isLoadingPlayer = true
|
||||||
|
playerErrorMessage = nil
|
||||||
|
|
||||||
|
do {
|
||||||
|
selectedPlayer = try await statsAPI.fetchPlayerProfile(personID: playerID, season: seasonString)
|
||||||
|
} catch {
|
||||||
|
playerErrorMessage = "Failed to load player details."
|
||||||
|
}
|
||||||
|
|
||||||
|
isLoadingPlayer = false
|
||||||
|
}
|
||||||
|
|
||||||
|
private static let scheduleFormatter: DateFormatter = {
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.dateFormat = "yyyy-MM-dd"
|
||||||
|
return formatter
|
||||||
|
}()
|
||||||
|
|
||||||
|
private static let displayFormatter: DateFormatter = {
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.dateFormat = "EEEE, MMM d"
|
||||||
|
return formatter
|
||||||
|
}()
|
||||||
|
}
|
||||||
108
mlbTVOS/Views/Components/LinescoreView.swift
Normal file
108
mlbTVOS/Views/Components/LinescoreView.swift
Normal file
@@ -0,0 +1,108 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct LinescoreView: View {
|
||||||
|
let linescore: StatsLinescore
|
||||||
|
let awayCode: String
|
||||||
|
let homeCode: String
|
||||||
|
|
||||||
|
private var totalInnings: Int {
|
||||||
|
linescore.scheduledInnings ?? 9
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
// Header
|
||||||
|
HStack(spacing: 0) {
|
||||||
|
Text("")
|
||||||
|
.frame(width: 70, alignment: .leading)
|
||||||
|
|
||||||
|
ForEach(1...totalInnings, id: \.self) { inning in
|
||||||
|
let isCurrent = inning == linescore.currentInning
|
||||||
|
HStack(spacing: 0) {
|
||||||
|
if inning > 1 && inning % 3 == 1 {
|
||||||
|
Divider()
|
||||||
|
.frame(width: 1, height: 18)
|
||||||
|
.background(.secondary.opacity(0.3))
|
||||||
|
.padding(.trailing, 4)
|
||||||
|
}
|
||||||
|
Text("\(inning)")
|
||||||
|
.font(.callout.weight(.semibold).monospacedDigit())
|
||||||
|
.foregroundStyle(isCurrent ? .primary : .secondary)
|
||||||
|
.frame(width: 44)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Divider().frame(width: 1, height: 20).padding(.horizontal, 6)
|
||||||
|
|
||||||
|
ForEach(["R", "H", "E"], id: \.self) { label in
|
||||||
|
Text(label)
|
||||||
|
.font(.callout.weight(.bold).monospacedDigit())
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.frame(width: 48)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
.background(.ultraThinMaterial)
|
||||||
|
|
||||||
|
Divider()
|
||||||
|
|
||||||
|
teamRow(code: awayCode, innings: linescore.innings ?? [], side: .away, totals: linescore.teams?.away)
|
||||||
|
Divider()
|
||||||
|
teamRow(code: homeCode, innings: linescore.innings ?? [], side: .home, totals: linescore.teams?.home)
|
||||||
|
}
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 10))
|
||||||
|
.background(.regularMaterial)
|
||||||
|
}
|
||||||
|
|
||||||
|
private enum Side { case away, home }
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func teamRow(code: String, innings: [StatsInningScore], side: Side, totals: StatsLinescoreTotals?) -> some View {
|
||||||
|
HStack(spacing: 0) {
|
||||||
|
Text(code)
|
||||||
|
.font(.callout.weight(.bold))
|
||||||
|
.foregroundStyle(TeamAssets.color(for: code))
|
||||||
|
.frame(width: 70, alignment: .leading)
|
||||||
|
|
||||||
|
ForEach(1...totalInnings, id: \.self) { inning in
|
||||||
|
let runs = inningRuns(innings: innings, inning: inning, side: side)
|
||||||
|
let isCurrent = inning == linescore.currentInning
|
||||||
|
HStack(spacing: 0) {
|
||||||
|
if inning > 1 && inning % 3 == 1 {
|
||||||
|
Divider()
|
||||||
|
.frame(width: 1, height: 22)
|
||||||
|
.background(.secondary.opacity(0.2))
|
||||||
|
.padding(.trailing, 4)
|
||||||
|
}
|
||||||
|
Text(runs.map { "\($0)" } ?? "-")
|
||||||
|
.font(.callout.weight(runs != nil ? .semibold : .regular).monospacedDigit())
|
||||||
|
.foregroundStyle(runs == nil ? .tertiary : isCurrent ? .primary : .secondary)
|
||||||
|
.frame(width: 44)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Divider().frame(width: 1, height: 24).padding(.horizontal, 6)
|
||||||
|
|
||||||
|
Text(totals?.runs.map { "\($0)" } ?? "-")
|
||||||
|
.font(.callout.weight(.bold).monospacedDigit())
|
||||||
|
.frame(width: 48)
|
||||||
|
Text(totals?.hits.map { "\($0)" } ?? "-")
|
||||||
|
.font(.callout.weight(.medium).monospacedDigit())
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.frame(width: 48)
|
||||||
|
Text(totals?.errors.map { "\($0)" } ?? "-")
|
||||||
|
.font(.callout.weight(.medium).monospacedDigit())
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.frame(width: 48)
|
||||||
|
}
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func inningRuns(innings: [StatsInningScore], inning: Int, side: Side) -> Int? {
|
||||||
|
guard let data = innings.first(where: { $0.num == inning }) else { return nil }
|
||||||
|
switch side {
|
||||||
|
case .away: return data.away?.runs
|
||||||
|
case .home: return data.home?.runs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
66
mlbTVOS/Views/Components/LiveIndicator.swift
Normal file
66
mlbTVOS/Views/Components/LiveIndicator.swift
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct LiveIndicator: View {
|
||||||
|
@State private var isPulsing = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Circle()
|
||||||
|
.fill(.red)
|
||||||
|
.frame(width: 12, height: 12)
|
||||||
|
.scaleEffect(isPulsing ? 1.4 : 1.0)
|
||||||
|
.opacity(isPulsing ? 0.6 : 1.0)
|
||||||
|
.animation(
|
||||||
|
.easeInOut(duration: 0.8).repeatForever(autoreverses: true),
|
||||||
|
value: isPulsing
|
||||||
|
)
|
||||||
|
Text("LIVE")
|
||||||
|
.font(.subheadline.weight(.black))
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
}
|
||||||
|
.onAppear { isPulsing = true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct StatusBadge: View {
|
||||||
|
let status: GameStatus
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
switch status {
|
||||||
|
case .live(let inning):
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
LiveIndicator()
|
||||||
|
if let inning {
|
||||||
|
Text(inning)
|
||||||
|
.font(.subheadline.weight(.semibold))
|
||||||
|
.foregroundStyle(.primary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
.background(.red.opacity(0.15))
|
||||||
|
.clipShape(Capsule())
|
||||||
|
|
||||||
|
case .scheduled(let time):
|
||||||
|
Text(time)
|
||||||
|
.font(.body.weight(.bold))
|
||||||
|
.foregroundStyle(.green)
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
.background(.green.opacity(0.12))
|
||||||
|
.clipShape(Capsule())
|
||||||
|
|
||||||
|
case .final_:
|
||||||
|
Text("FINAL")
|
||||||
|
.font(.subheadline.weight(.bold))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
.background(.secondary.opacity(0.12))
|
||||||
|
.clipShape(Capsule())
|
||||||
|
|
||||||
|
case .unknown:
|
||||||
|
EmptyView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
53
mlbTVOS/Views/Components/PitcherHeadshotView.swift
Normal file
53
mlbTVOS/Views/Components/PitcherHeadshotView.swift
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct PitcherHeadshotView: View {
|
||||||
|
let url: URL?
|
||||||
|
var teamCode: String?
|
||||||
|
var name: String?
|
||||||
|
var size: CGFloat = 56
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(spacing: 6) {
|
||||||
|
AsyncImage(url: url) { phase in
|
||||||
|
switch phase {
|
||||||
|
case .success(let image):
|
||||||
|
image
|
||||||
|
.resizable()
|
||||||
|
.aspectRatio(contentMode: .fill)
|
||||||
|
case .failure:
|
||||||
|
fallbackImage
|
||||||
|
default:
|
||||||
|
fallbackImage
|
||||||
|
.redacted(reason: .placeholder)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(width: size, height: size)
|
||||||
|
.clipShape(Circle())
|
||||||
|
.overlay(
|
||||||
|
Circle()
|
||||||
|
.strokeBorder(
|
||||||
|
teamCode.map { TeamAssets.color(for: $0) } ?? .gray,
|
||||||
|
lineWidth: 2
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
if let name {
|
||||||
|
Text(name)
|
||||||
|
.font(.system(size: 13, weight: .medium))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var fallbackImage: some View {
|
||||||
|
ZStack {
|
||||||
|
Circle()
|
||||||
|
.fill(.gray.opacity(0.3))
|
||||||
|
Image(systemName: "person.fill")
|
||||||
|
.font(.system(size: size * 0.4))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
67
mlbTVOS/Views/Components/ScoreOverlayView.swift
Normal file
67
mlbTVOS/Views/Components/ScoreOverlayView.swift
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct ScoreOverlayView: View {
|
||||||
|
let game: Game
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
// Don't show for non-game streams (e.g., MLB Network)
|
||||||
|
if game.id == "MLBN" {
|
||||||
|
EmptyView()
|
||||||
|
} else {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
// Away
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Circle()
|
||||||
|
.fill(TeamAssets.color(for: game.awayTeam.code))
|
||||||
|
.frame(width: 8, height: 8)
|
||||||
|
Text(game.awayTeam.code)
|
||||||
|
.font(.system(size: 15, weight: .bold, design: .rounded))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
|
||||||
|
if !game.status.isScheduled, let score = game.awayTeam.score {
|
||||||
|
Text("\(score)")
|
||||||
|
.font(.system(size: 17, weight: .bold, design: .rounded).monospacedDigit())
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text("-")
|
||||||
|
.font(.system(size: 13, weight: .medium))
|
||||||
|
.foregroundStyle(.white.opacity(0.4))
|
||||||
|
|
||||||
|
// Home
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
if !game.status.isScheduled, let score = game.homeTeam.score {
|
||||||
|
Text("\(score)")
|
||||||
|
.font(.system(size: 17, weight: .bold, design: .rounded).monospacedDigit())
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(game.homeTeam.code)
|
||||||
|
.font(.system(size: 15, weight: .bold, design: .rounded))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
Circle()
|
||||||
|
.fill(TeamAssets.color(for: game.homeTeam.code))
|
||||||
|
.frame(width: 8, height: 8)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inning/status
|
||||||
|
if game.isLive, let inning = game.currentInningDisplay {
|
||||||
|
Text(inning)
|
||||||
|
.font(.system(size: 12, weight: .semibold))
|
||||||
|
.foregroundStyle(.white.opacity(0.6))
|
||||||
|
.padding(.leading, 4)
|
||||||
|
} else if game.isFinal {
|
||||||
|
Text("F")
|
||||||
|
.font(.system(size: 12, weight: .bold))
|
||||||
|
.foregroundStyle(.white.opacity(0.5))
|
||||||
|
.padding(.leading, 4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
.background(.black.opacity(0.7))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 8))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
167
mlbTVOS/Views/Components/ScoresTickerView.swift
Normal file
167
mlbTVOS/Views/Components/ScoresTickerView.swift
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import UIKit
|
||||||
|
|
||||||
|
struct ScoresTickerView: View {
|
||||||
|
var gamesOverride: [Game]? = nil
|
||||||
|
|
||||||
|
@Environment(GamesViewModel.self) private var viewModel
|
||||||
|
|
||||||
|
private var entries: [Game] {
|
||||||
|
gamesOverride ?? viewModel.games
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
if !entries.isEmpty {
|
||||||
|
TickerMarqueeView(text: tickerText)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.frame(height: 22)
|
||||||
|
.padding(.horizontal, 18)
|
||||||
|
.padding(.vertical, 14)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 18, style: .continuous)
|
||||||
|
.fill(.black.opacity(0.72))
|
||||||
|
.overlay {
|
||||||
|
RoundedRectangle(cornerRadius: 18, style: .continuous)
|
||||||
|
.strokeBorder(.white.opacity(0.08), lineWidth: 1)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.accessibilityHidden(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var tickerText: String {
|
||||||
|
entries.map(tickerSummary(for:)).joined(separator: " | ")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func tickerSummary(for game: Game) -> String {
|
||||||
|
let matchup = "\(game.awayTeam.code) \(game.awayTeam.score.map(String.init) ?? "-") - \(game.homeTeam.code) \(game.homeTeam.score.map(String.init) ?? "-")"
|
||||||
|
|
||||||
|
if game.isLive, let linescore = game.linescore {
|
||||||
|
let inning = linescore.currentInningOrdinal ?? game.currentInningDisplay ?? "LIVE"
|
||||||
|
let state = (linescore.inningState ?? linescore.inningHalf ?? "Live").uppercased()
|
||||||
|
let outs = linescore.outs ?? 0
|
||||||
|
return "\(matchup) \(state) \(inning.uppercased()) • \(outs) OUT\(outs == 1 ? "" : "S")"
|
||||||
|
}
|
||||||
|
|
||||||
|
if game.isFinal {
|
||||||
|
return "\(matchup) FINAL"
|
||||||
|
}
|
||||||
|
|
||||||
|
if let startTime = game.startTime {
|
||||||
|
return "\(matchup) \(startTime.uppercased())"
|
||||||
|
}
|
||||||
|
|
||||||
|
return "\(matchup) \(game.status.label.uppercased())"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct TickerMarqueeView: UIViewRepresentable {
|
||||||
|
let text: String
|
||||||
|
|
||||||
|
func makeUIView(context: Context) -> TickerMarqueeContainerView {
|
||||||
|
let view = TickerMarqueeContainerView()
|
||||||
|
view.setText(text)
|
||||||
|
return view
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateUIView(_ uiView: TickerMarqueeContainerView, context: Context) {
|
||||||
|
uiView.setText(text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private final class TickerMarqueeContainerView: UIView {
|
||||||
|
private let trackView = UIView()
|
||||||
|
private let primaryLabel = UILabel()
|
||||||
|
private let secondaryLabel = UILabel()
|
||||||
|
private let spacing: CGFloat = 48
|
||||||
|
private let pointsPerSecond: CGFloat = 64
|
||||||
|
|
||||||
|
private var currentText = ""
|
||||||
|
private var previousBoundsWidth: CGFloat = 0
|
||||||
|
private var previousContentWidth: CGFloat = 0
|
||||||
|
|
||||||
|
override init(frame: CGRect) {
|
||||||
|
super.init(frame: frame)
|
||||||
|
|
||||||
|
clipsToBounds = true
|
||||||
|
isUserInteractionEnabled = false
|
||||||
|
|
||||||
|
let baseFont = UIFont.systemFont(ofSize: 15, weight: .bold)
|
||||||
|
let roundedFont = UIFont(descriptor: baseFont.fontDescriptor.withDesign(.rounded) ?? baseFont.fontDescriptor, size: 15)
|
||||||
|
|
||||||
|
[primaryLabel, secondaryLabel].forEach { label in
|
||||||
|
label.font = roundedFont
|
||||||
|
label.textColor = UIColor.white.withAlphaComponent(0.92)
|
||||||
|
label.numberOfLines = 1
|
||||||
|
label.lineBreakMode = .byClipping
|
||||||
|
trackView.addSubview(label)
|
||||||
|
}
|
||||||
|
|
||||||
|
addSubview(trackView)
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
func setText(_ text: String) {
|
||||||
|
guard currentText != text else { return }
|
||||||
|
currentText = text
|
||||||
|
primaryLabel.text = text
|
||||||
|
secondaryLabel.text = text
|
||||||
|
previousContentWidth = 0
|
||||||
|
setNeedsLayout()
|
||||||
|
}
|
||||||
|
|
||||||
|
override func layoutSubviews() {
|
||||||
|
super.layoutSubviews()
|
||||||
|
|
||||||
|
guard bounds.width > 0, bounds.height > 0 else { return }
|
||||||
|
|
||||||
|
let contentWidth = ceil(primaryLabel.sizeThatFits(CGSize(width: .greatestFiniteMagnitude, height: bounds.height)).width)
|
||||||
|
let contentHeight = bounds.height
|
||||||
|
|
||||||
|
primaryLabel.frame = CGRect(x: 0, y: 0, width: contentWidth, height: contentHeight)
|
||||||
|
secondaryLabel.frame = CGRect(x: contentWidth + spacing, y: 0, width: contentWidth, height: contentHeight)
|
||||||
|
|
||||||
|
let cycleWidth = contentWidth + spacing
|
||||||
|
|
||||||
|
if contentWidth <= bounds.width {
|
||||||
|
trackView.layer.removeAllAnimations()
|
||||||
|
secondaryLabel.isHidden = true
|
||||||
|
trackView.frame = CGRect(x: 0, y: 0, width: bounds.width, height: contentHeight)
|
||||||
|
primaryLabel.frame = CGRect(x: 0, y: 0, width: bounds.width, height: contentHeight)
|
||||||
|
primaryLabel.textAlignment = .left
|
||||||
|
previousBoundsWidth = bounds.width
|
||||||
|
previousContentWidth = contentWidth
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
primaryLabel.textAlignment = .left
|
||||||
|
secondaryLabel.isHidden = false
|
||||||
|
trackView.frame = CGRect(x: 0, y: 0, width: contentWidth * 2 + spacing, height: contentHeight)
|
||||||
|
|
||||||
|
let shouldRestart = abs(previousBoundsWidth - bounds.width) > 0.5
|
||||||
|
|| abs(previousContentWidth - contentWidth) > 0.5
|
||||||
|
|| trackView.layer.animation(forKey: "tickerMarquee") == nil
|
||||||
|
|
||||||
|
previousBoundsWidth = bounds.width
|
||||||
|
previousContentWidth = contentWidth
|
||||||
|
|
||||||
|
guard shouldRestart else { return }
|
||||||
|
|
||||||
|
trackView.layer.removeAllAnimations()
|
||||||
|
trackView.transform = .identity
|
||||||
|
|
||||||
|
let animation = CABasicAnimation(keyPath: "transform.translation.x")
|
||||||
|
animation.fromValue = 0
|
||||||
|
animation.toValue = -cycleWidth
|
||||||
|
animation.duration = Double(cycleWidth / pointsPerSecond)
|
||||||
|
animation.repeatCount = .infinity
|
||||||
|
animation.timingFunction = CAMediaTimingFunction(name: .linear)
|
||||||
|
animation.isRemovedOnCompletion = false
|
||||||
|
|
||||||
|
trackView.layer.add(animation, forKey: "tickerMarquee")
|
||||||
|
}
|
||||||
|
}
|
||||||
38
mlbTVOS/Views/Components/TeamLogoView.swift
Normal file
38
mlbTVOS/Views/Components/TeamLogoView.swift
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct TeamLogoView: View {
|
||||||
|
let team: TeamInfo
|
||||||
|
var size: CGFloat = 64
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
AsyncImage(url: team.logoURL) { phase in
|
||||||
|
switch phase {
|
||||||
|
case .success(let image):
|
||||||
|
image
|
||||||
|
.resizable()
|
||||||
|
.aspectRatio(contentMode: .fit)
|
||||||
|
case .failure:
|
||||||
|
fallbackLogo
|
||||||
|
default:
|
||||||
|
fallbackLogo
|
||||||
|
.redacted(reason: .placeholder)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(width: size, height: size)
|
||||||
|
.overlay(
|
||||||
|
Circle()
|
||||||
|
.strokeBorder(.white.opacity(0.1), lineWidth: 1)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var fallbackLogo: some View {
|
||||||
|
ZStack {
|
||||||
|
Circle()
|
||||||
|
.fill(TeamAssets.color(for: team.code).gradient)
|
||||||
|
Text(team.code.prefix(3))
|
||||||
|
.font(.system(size: size * 0.3, weight: .heavy, design: .rounded))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
33
mlbTVOS/Views/ContentView.swift
Normal file
33
mlbTVOS/Views/ContentView.swift
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct ContentView: View {
|
||||||
|
@Environment(GamesViewModel.self) private var viewModel
|
||||||
|
|
||||||
|
private var multiViewLabel: String {
|
||||||
|
let count = viewModel.activeStreams.count
|
||||||
|
if count > 0 {
|
||||||
|
return "Multi-View (\(count))"
|
||||||
|
}
|
||||||
|
return "Multi-View"
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
TabView {
|
||||||
|
Tab("Games", systemImage: "sportscourt.fill") {
|
||||||
|
DashboardView()
|
||||||
|
}
|
||||||
|
Tab("League", systemImage: "list.bullet.rectangle.portrait.fill") {
|
||||||
|
LeagueCenterView()
|
||||||
|
}
|
||||||
|
Tab(multiViewLabel, systemImage: "rectangle.split.2x2.fill") {
|
||||||
|
MultiStreamView()
|
||||||
|
}
|
||||||
|
Tab("Settings", systemImage: "gearshape.fill") {
|
||||||
|
SettingsView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.task {
|
||||||
|
await viewModel.loadGames()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
340
mlbTVOS/Views/DashboardView.swift
Normal file
340
mlbTVOS/Views/DashboardView.swift
Normal file
@@ -0,0 +1,340 @@
|
|||||||
|
import SwiftUI
|
||||||
|
import OSLog
|
||||||
|
|
||||||
|
private let dashboardLogger = Logger(subsystem: "com.treyt.mlbTVOS", category: "Dashboard")
|
||||||
|
|
||||||
|
private func logDashboard(_ message: String) {
|
||||||
|
dashboardLogger.debug("\(message, privacy: .public)")
|
||||||
|
print("[Dashboard] \(message)")
|
||||||
|
}
|
||||||
|
|
||||||
|
struct DashboardView: View {
|
||||||
|
@Environment(GamesViewModel.self) private var viewModel
|
||||||
|
@State private var selectedGame: Game?
|
||||||
|
@State private var fullScreenBroadcast: BroadcastSelection?
|
||||||
|
@State private var pendingFullScreenBroadcast: BroadcastSelection?
|
||||||
|
@State private var showMLBNetworkSheet = false
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ScrollView {
|
||||||
|
VStack(alignment: .leading, spacing: 50) {
|
||||||
|
headerSection
|
||||||
|
|
||||||
|
if viewModel.isLoading {
|
||||||
|
HStack {
|
||||||
|
Spacer()
|
||||||
|
ProgressView("Loading games...")
|
||||||
|
.font(.title3)
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(.top, 80)
|
||||||
|
} else if let error = viewModel.errorMessage, viewModel.games.isEmpty {
|
||||||
|
HStack {
|
||||||
|
Spacer()
|
||||||
|
VStack(spacing: 20) {
|
||||||
|
Image(systemName: "exclamationmark.triangle")
|
||||||
|
.font(.system(size: 50))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Text(error)
|
||||||
|
.font(.title3)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Button("Retry") {
|
||||||
|
Task { await viewModel.loadGames() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(.top, 80)
|
||||||
|
} else {
|
||||||
|
// Hero featured game
|
||||||
|
if let featured = viewModel.featuredGame {
|
||||||
|
FeaturedGameCard(game: featured) {
|
||||||
|
selectedGame = featured
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !viewModel.liveGames.isEmpty {
|
||||||
|
gameShelf(title: "Live", icon: "antenna.radiowaves.left.and.right", games: viewModel.liveGames, excludeId: viewModel.featuredGame?.id)
|
||||||
|
}
|
||||||
|
if !viewModel.scheduledGames.isEmpty {
|
||||||
|
gameShelf(title: "Upcoming", icon: "calendar", games: viewModel.scheduledGames, excludeId: viewModel.featuredGame?.id)
|
||||||
|
}
|
||||||
|
if !viewModel.finalGames.isEmpty {
|
||||||
|
gameShelf(title: "Final", icon: "checkmark.circle", games: viewModel.finalGames, excludeId: viewModel.featuredGame?.id)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
mlbNetworkCard
|
||||||
|
|
||||||
|
if !viewModel.activeStreams.isEmpty {
|
||||||
|
multiViewStatus
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 60)
|
||||||
|
.padding(.vertical, 40)
|
||||||
|
}
|
||||||
|
.onAppear {
|
||||||
|
logDashboard("DashboardView appeared")
|
||||||
|
viewModel.startAutoRefresh()
|
||||||
|
}
|
||||||
|
.onDisappear {
|
||||||
|
logDashboard("DashboardView disappeared")
|
||||||
|
viewModel.stopAutoRefresh()
|
||||||
|
}
|
||||||
|
.sheet(item: $selectedGame, onDismiss: presentPendingFullScreenBroadcast) { game in
|
||||||
|
StreamOptionsSheet(game: game) { broadcast in
|
||||||
|
logDashboard("Queued fullscreen broadcast from game sheet broadcastId=\(broadcast.broadcast.id) gameId=\(broadcast.game.id)")
|
||||||
|
pendingFullScreenBroadcast = broadcast
|
||||||
|
selectedGame = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.fullScreenCover(item: $fullScreenBroadcast) { selection in
|
||||||
|
SingleStreamPlaybackScreen(
|
||||||
|
resolveURL: {
|
||||||
|
logDashboard("resolveURL closure invoked broadcastId=\(selection.broadcast.id) gameId=\(selection.game.id)")
|
||||||
|
if selection.broadcast.id == "MLBN" {
|
||||||
|
return await viewModel.buildEventStreamURL(event: "MLBN")
|
||||||
|
}
|
||||||
|
let s = ActiveStream(
|
||||||
|
id: selection.broadcast.id,
|
||||||
|
game: selection.game,
|
||||||
|
label: selection.broadcast.displayLabel,
|
||||||
|
mediaId: selection.broadcast.mediaId,
|
||||||
|
streamURLString: selection.broadcast.streamURL
|
||||||
|
)
|
||||||
|
return await viewModel.resolveStreamURL(for: s)
|
||||||
|
},
|
||||||
|
tickerGames: viewModel.games
|
||||||
|
)
|
||||||
|
.ignoresSafeArea()
|
||||||
|
.onAppear {
|
||||||
|
logDashboard("fullScreenCover content mounted broadcastId=\(selection.broadcast.id) gameId=\(selection.game.id)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.onChange(of: fullScreenBroadcast?.id) { _, newValue in
|
||||||
|
logDashboard("fullScreenBroadcast changed newValue=\(newValue ?? "nil")")
|
||||||
|
}
|
||||||
|
.sheet(isPresented: $showMLBNetworkSheet, onDismiss: presentPendingFullScreenBroadcast) {
|
||||||
|
MLBNetworkSheet(
|
||||||
|
onWatchFullScreen: {
|
||||||
|
logDashboard("Queued fullscreen broadcast from MLB Network sheet")
|
||||||
|
showMLBNetworkSheet = false
|
||||||
|
pendingFullScreenBroadcast = mlbNetworkBroadcastSelection
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func presentPendingFullScreenBroadcast() {
|
||||||
|
guard selectedGame == nil, !showMLBNetworkSheet else {
|
||||||
|
logDashboard("Skipped pending fullscreen presentation because another sheet is still active")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let pendingFullScreenBroadcast else { return }
|
||||||
|
logDashboard(
|
||||||
|
"Presenting pending fullscreen broadcast broadcastId=\(pendingFullScreenBroadcast.broadcast.id) gameId=\(pendingFullScreenBroadcast.game.id)"
|
||||||
|
)
|
||||||
|
self.pendingFullScreenBroadcast = nil
|
||||||
|
DispatchQueue.main.async {
|
||||||
|
fullScreenBroadcast = pendingFullScreenBroadcast
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var mlbNetworkBroadcastSelection: BroadcastSelection {
|
||||||
|
let bc = Broadcast(
|
||||||
|
id: "MLBN",
|
||||||
|
teamCode: "MLBN",
|
||||||
|
name: "MLB Network",
|
||||||
|
mediaId: "",
|
||||||
|
streamURL: ""
|
||||||
|
)
|
||||||
|
let game = Game(
|
||||||
|
id: "MLBN",
|
||||||
|
awayTeam: TeamInfo(code: "MLBN", name: "MLB Network", score: nil),
|
||||||
|
homeTeam: TeamInfo(code: "MLBN", name: "MLB Network", score: nil),
|
||||||
|
status: .live(nil), gameType: nil, startTime: nil, venue: nil,
|
||||||
|
pitchers: nil, gamePk: nil, gameDate: "",
|
||||||
|
broadcasts: [], isBlackedOut: false
|
||||||
|
)
|
||||||
|
return BroadcastSelection(broadcast: bc, game: game)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Game Shelf (Horizontal)
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func gameShelf(title: String, icon: String, games: [Game], excludeId: String?) -> some View {
|
||||||
|
let filtered = games.filter { $0.id != excludeId }
|
||||||
|
if !filtered.isEmpty {
|
||||||
|
VStack(alignment: .leading, spacing: 20) {
|
||||||
|
Label(title, systemImage: icon)
|
||||||
|
.font(.title3.weight(.bold))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
|
ScrollView(.horizontal) {
|
||||||
|
LazyHStack(spacing: 30) {
|
||||||
|
ForEach(filtered) { game in
|
||||||
|
GameCardView(game: game) {
|
||||||
|
selectedGame = game
|
||||||
|
}
|
||||||
|
.frame(width: 400)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, 20)
|
||||||
|
}
|
||||||
|
.scrollClipDisabled()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Header
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var headerSection: some View {
|
||||||
|
VStack(spacing: 24) {
|
||||||
|
HStack(alignment: .bottom) {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
Text("MLB")
|
||||||
|
.font(.headline.weight(.black))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
.kerning(4)
|
||||||
|
Text(viewModel.displayDateString)
|
||||||
|
.font(.system(size: 40, weight: .bold))
|
||||||
|
.contentTransition(.numericText())
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
HStack(spacing: 16) {
|
||||||
|
statPill("\(viewModel.games.count)", label: "Games")
|
||||||
|
if !viewModel.liveGames.isEmpty {
|
||||||
|
statPill("\(viewModel.liveGames.count)", label: "Live", color: .red)
|
||||||
|
}
|
||||||
|
if !viewModel.activeStreams.isEmpty {
|
||||||
|
statPill("\(viewModel.activeStreams.count)/4", label: "Streams", color: .green)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack(spacing: 16) {
|
||||||
|
Button {
|
||||||
|
Task { await viewModel.goToPreviousDay() }
|
||||||
|
} label: {
|
||||||
|
Label("Previous Day", systemImage: "chevron.left")
|
||||||
|
}
|
||||||
|
|
||||||
|
if !viewModel.isToday {
|
||||||
|
Button {
|
||||||
|
Task { await viewModel.goToToday() }
|
||||||
|
} label: {
|
||||||
|
Label("Today", systemImage: "calendar")
|
||||||
|
}
|
||||||
|
.tint(.blue)
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
Task { await viewModel.goToNextDay() }
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: 6) {
|
||||||
|
Text("Next Day")
|
||||||
|
Image(systemName: "chevron.right")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func statPill(_ value: String, label: String, color: Color = .blue) -> some View {
|
||||||
|
VStack(spacing: 2) {
|
||||||
|
Text(value)
|
||||||
|
.font(.title3.weight(.bold).monospacedDigit())
|
||||||
|
.foregroundStyle(color)
|
||||||
|
Text(label)
|
||||||
|
.font(.subheadline.weight(.medium))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
.background(.regularMaterial)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - MLB Network
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var mlbNetworkCard: some View {
|
||||||
|
let added = viewModel.activeStreams.contains(where: { $0.id == "MLBN" })
|
||||||
|
Button {
|
||||||
|
showMLBNetworkSheet = true
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: 16) {
|
||||||
|
Image(systemName: "tv.fill")
|
||||||
|
.font(.title2)
|
||||||
|
.foregroundStyle(.blue)
|
||||||
|
.frame(width: 56, height: 56)
|
||||||
|
.background(.blue.opacity(0.2))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text("MLB Network")
|
||||||
|
.font(.title3.weight(.bold))
|
||||||
|
Text("Live coverage, analysis & highlights")
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
if added {
|
||||||
|
Label("In Multi-View", systemImage: "checkmark.circle.fill")
|
||||||
|
.font(.subheadline.weight(.bold))
|
||||||
|
.foregroundStyle(.green)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(24)
|
||||||
|
.background(.regularMaterial)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 20))
|
||||||
|
}
|
||||||
|
.buttonStyle(.card)
|
||||||
|
}
|
||||||
|
|
||||||
|
// MARK: - Multi-View Status
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var multiViewStatus: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 14) {
|
||||||
|
Label("Multi-View", systemImage: "rectangle.split.2x2")
|
||||||
|
.font(.title3.weight(.bold))
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
ForEach(viewModel.activeStreams) { stream in
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Circle().fill(.green).frame(width: 8, height: 8)
|
||||||
|
Text(stream.label)
|
||||||
|
.font(.subheadline.weight(.semibold))
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 14)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
.background(.regularMaterial)
|
||||||
|
.clipShape(Capsule())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct BroadcastSelection: Identifiable {
|
||||||
|
let id: String
|
||||||
|
let broadcast: Broadcast
|
||||||
|
let game: Game
|
||||||
|
|
||||||
|
init(broadcast: Broadcast, game: Game) {
|
||||||
|
self.id = broadcast.id
|
||||||
|
self.broadcast = broadcast
|
||||||
|
self.game = game
|
||||||
|
}
|
||||||
|
}
|
||||||
584
mlbTVOS/Views/FeaturedGameCard.swift
Normal file
584
mlbTVOS/Views/FeaturedGameCard.swift
Normal file
@@ -0,0 +1,584 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct FeaturedGameCard: View {
|
||||||
|
let game: Game
|
||||||
|
let onSelect: () -> Void
|
||||||
|
|
||||||
|
private var awayColor: Color { TeamAssets.color(for: game.awayTeam.code) }
|
||||||
|
private var homeColor: Color { TeamAssets.color(for: game.homeTeam.code) }
|
||||||
|
|
||||||
|
private var hasLinescore: Bool {
|
||||||
|
!game.status.isScheduled && (game.linescore?.hasData ?? false)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var awayPitcherName: String? {
|
||||||
|
guard let pitchers = game.pitchers else { return nil }
|
||||||
|
let parts = pitchers.components(separatedBy: " vs ")
|
||||||
|
return parts.first
|
||||||
|
}
|
||||||
|
|
||||||
|
private var homePitcherName: String? {
|
||||||
|
guard let pitchers = game.pitchers else { return nil }
|
||||||
|
let parts = pitchers.components(separatedBy: " vs ")
|
||||||
|
return parts.count > 1 ? parts.last : nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button(action: onSelect) {
|
||||||
|
VStack(spacing: 0) {
|
||||||
|
HStack(alignment: .top, spacing: 28) {
|
||||||
|
matchupColumn
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
|
||||||
|
sidePanel
|
||||||
|
.frame(width: 760, alignment: .leading)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 34)
|
||||||
|
.padding(.top, 30)
|
||||||
|
.padding(.bottom, 24)
|
||||||
|
|
||||||
|
footerBar
|
||||||
|
.padding(.horizontal, 34)
|
||||||
|
.padding(.vertical, 16)
|
||||||
|
.background(.white.opacity(0.04))
|
||||||
|
}
|
||||||
|
.background(cardBackground)
|
||||||
|
.overlay(alignment: .top) {
|
||||||
|
HStack(spacing: 0) {
|
||||||
|
Rectangle()
|
||||||
|
.fill(awayColor.opacity(0.92))
|
||||||
|
Rectangle()
|
||||||
|
.fill(homeColor.opacity(0.92))
|
||||||
|
}
|
||||||
|
.frame(height: 5)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 28, style: .continuous))
|
||||||
|
}
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 28, style: .continuous))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 28, style: .continuous)
|
||||||
|
.strokeBorder(borderColor, lineWidth: borderWidth)
|
||||||
|
)
|
||||||
|
.shadow(color: shadowColor, radius: 24, y: 10)
|
||||||
|
}
|
||||||
|
.buttonStyle(.card)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var matchupColumn: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 18) {
|
||||||
|
headerBar
|
||||||
|
|
||||||
|
featuredTeamRow(
|
||||||
|
team: game.awayTeam,
|
||||||
|
color: awayColor,
|
||||||
|
pitcherURL: game.awayPitcherHeadshotURL,
|
||||||
|
pitcherName: awayPitcherName
|
||||||
|
)
|
||||||
|
|
||||||
|
scorePanel
|
||||||
|
|
||||||
|
featuredTeamRow(
|
||||||
|
team: game.homeTeam,
|
||||||
|
color: homeColor,
|
||||||
|
pitcherURL: game.homePitcherHeadshotURL,
|
||||||
|
pitcherName: homePitcherName
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var headerBar: some View {
|
||||||
|
HStack(alignment: .top, spacing: 16) {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text((game.gameType ?? "Featured Game").uppercased())
|
||||||
|
.font(.system(size: 13, weight: .bold, design: .rounded))
|
||||||
|
.foregroundStyle(.white.opacity(0.6))
|
||||||
|
.kerning(1.8)
|
||||||
|
|
||||||
|
if let venue = game.venue {
|
||||||
|
Label(venue, systemImage: "mappin.and.ellipse")
|
||||||
|
.font(.system(size: 14, weight: .medium))
|
||||||
|
.foregroundStyle(.white.opacity(0.8))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
if game.hasStreams {
|
||||||
|
headerChip(title: "WATCH", color: .blue)
|
||||||
|
} else if game.isBlackedOut {
|
||||||
|
headerChip(title: "BLACKOUT", color: .red)
|
||||||
|
}
|
||||||
|
|
||||||
|
statusChip
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func featuredTeamRow(team: TeamInfo, color: Color, pitcherURL: URL?, pitcherName: String?) -> some View {
|
||||||
|
HStack(spacing: 18) {
|
||||||
|
TeamLogoView(team: team, size: 82)
|
||||||
|
.frame(width: 88, height: 88)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Text(team.code)
|
||||||
|
.font(.system(size: 34, weight: .black, design: .rounded))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
|
||||||
|
if let record = team.record {
|
||||||
|
Text(record)
|
||||||
|
.font(.system(size: 13, weight: .bold, design: .monospaced))
|
||||||
|
.foregroundStyle(.white.opacity(0.72))
|
||||||
|
.padding(.horizontal, 11)
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
.background(.white.opacity(0.08))
|
||||||
|
.clipShape(Capsule())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(team.displayName)
|
||||||
|
.font(.system(size: 26, weight: .bold))
|
||||||
|
.foregroundStyle(.white.opacity(0.96))
|
||||||
|
.lineLimit(1)
|
||||||
|
.minimumScaleFactor(0.82)
|
||||||
|
|
||||||
|
if let standing = team.standingSummary {
|
||||||
|
Text(standing)
|
||||||
|
.font(.system(size: 15, weight: .medium))
|
||||||
|
.foregroundStyle(.white.opacity(0.58))
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(minLength: 14)
|
||||||
|
|
||||||
|
if pitcherURL != nil || pitcherName != nil {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
if pitcherURL != nil {
|
||||||
|
PitcherHeadshotView(
|
||||||
|
url: pitcherURL,
|
||||||
|
teamCode: team.code,
|
||||||
|
name: nil,
|
||||||
|
size: 46
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(alignment: .trailing, spacing: 4) {
|
||||||
|
Text("Probable")
|
||||||
|
.font(.system(size: 12, weight: .bold, design: .rounded))
|
||||||
|
.foregroundStyle(.white.opacity(0.42))
|
||||||
|
.textCase(.uppercase)
|
||||||
|
|
||||||
|
Text(pitcherName ?? "TBD")
|
||||||
|
.font(.system(size: 16, weight: .semibold))
|
||||||
|
.foregroundStyle(.white.opacity(0.86))
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 22)
|
||||||
|
.padding(.vertical, 20)
|
||||||
|
.background {
|
||||||
|
RoundedRectangle(cornerRadius: 24, style: .continuous)
|
||||||
|
.fill(teamPanelBackground(color: color))
|
||||||
|
}
|
||||||
|
.overlay {
|
||||||
|
RoundedRectangle(cornerRadius: 24, style: .continuous)
|
||||||
|
.strokeBorder(.white.opacity(0.08), lineWidth: 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var scorePanel: some View {
|
||||||
|
VStack(spacing: 10) {
|
||||||
|
if let summary = scoreSummaryText {
|
||||||
|
Text(summary)
|
||||||
|
.font(.system(size: 74, weight: .black, design: .rounded))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.monospacedDigit()
|
||||||
|
.contentTransition(.numericText())
|
||||||
|
} else {
|
||||||
|
Text(game.status.label)
|
||||||
|
.font(.system(size: 54, weight: .black, design: .rounded))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(stateHeadline)
|
||||||
|
.font(.system(size: 22, weight: .semibold, design: .rounded))
|
||||||
|
.foregroundStyle(stateHeadlineColor)
|
||||||
|
|
||||||
|
if let detail = stateDetailText {
|
||||||
|
Text(detail)
|
||||||
|
.font(.system(size: 15, weight: .medium))
|
||||||
|
.foregroundStyle(.white.opacity(0.6))
|
||||||
|
.lineLimit(2)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.horizontal, 24)
|
||||||
|
.padding(.vertical, 26)
|
||||||
|
.background {
|
||||||
|
RoundedRectangle(cornerRadius: 26, style: .continuous)
|
||||||
|
.fill(.white.opacity(0.06))
|
||||||
|
}
|
||||||
|
.overlay {
|
||||||
|
RoundedRectangle(cornerRadius: 26, style: .continuous)
|
||||||
|
.strokeBorder(.white.opacity(0.08), lineWidth: 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var sidePanel: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 18) {
|
||||||
|
HStack(spacing: 14) {
|
||||||
|
detailTile(
|
||||||
|
title: "Venue",
|
||||||
|
value: game.venue ?? "TBD",
|
||||||
|
icon: "mappin.and.ellipse",
|
||||||
|
accent: nil
|
||||||
|
)
|
||||||
|
|
||||||
|
detailTile(
|
||||||
|
title: "Pitching",
|
||||||
|
value: game.pitchers ?? "Pitchers TBD",
|
||||||
|
icon: "baseball",
|
||||||
|
accent: nil
|
||||||
|
)
|
||||||
|
|
||||||
|
detailTile(
|
||||||
|
title: "Coverage",
|
||||||
|
value: coverageSummary,
|
||||||
|
icon: coverageIconName,
|
||||||
|
accent: coverageAccent
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if hasLinescore,
|
||||||
|
let linescore = game.linescore {
|
||||||
|
VStack(alignment: .leading, spacing: 14) {
|
||||||
|
HStack {
|
||||||
|
Text("Linescore")
|
||||||
|
.font(.system(size: 18, weight: .bold))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Text(linescoreLabel)
|
||||||
|
.font(.system(size: 13, weight: .bold, design: .rounded))
|
||||||
|
.foregroundStyle(.white.opacity(0.56))
|
||||||
|
}
|
||||||
|
|
||||||
|
LinescoreView(
|
||||||
|
linescore: linescore,
|
||||||
|
awayCode: game.awayTeam.code,
|
||||||
|
homeCode: game.homeTeam.code
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.padding(20)
|
||||||
|
.background(panelBackground)
|
||||||
|
} else {
|
||||||
|
fallbackInfoPanel
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func detailTile(title: String, value: String, icon: String, accent: Color?) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
Label(title, systemImage: icon)
|
||||||
|
.font(.system(size: 12, weight: .bold, design: .rounded))
|
||||||
|
.foregroundStyle(.white.opacity(0.52))
|
||||||
|
|
||||||
|
Text(value)
|
||||||
|
.font(.system(size: 17, weight: .semibold))
|
||||||
|
.foregroundStyle(accent ?? .white)
|
||||||
|
.lineLimit(2)
|
||||||
|
.minimumScaleFactor(0.82)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, minHeight: 102, alignment: .topLeading)
|
||||||
|
.padding(18)
|
||||||
|
.background(panelBackground)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var fallbackInfoPanel: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
Text(hasLinescore ? "Game Flow" : "Matchup Notes")
|
||||||
|
.font(.system(size: 18, weight: .bold))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
|
||||||
|
Text(fallbackSummary)
|
||||||
|
.font(.system(size: 18, weight: .medium))
|
||||||
|
.foregroundStyle(.white.opacity(0.78))
|
||||||
|
.lineLimit(3)
|
||||||
|
|
||||||
|
if let venue = game.venue {
|
||||||
|
Text(venue)
|
||||||
|
.font(.system(size: 14, weight: .semibold))
|
||||||
|
.foregroundStyle(.white.opacity(0.52))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.padding(22)
|
||||||
|
.background(panelBackground)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var footerBar: some View {
|
||||||
|
HStack(spacing: 14) {
|
||||||
|
Label(footerSummary, systemImage: footerIconName)
|
||||||
|
.font(.system(size: 15, weight: .semibold))
|
||||||
|
.foregroundStyle(.white.opacity(0.72))
|
||||||
|
.lineLimit(1)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Image(systemName: game.hasStreams ? "play.fill" : "rectangle.and.text.magnifyingglass")
|
||||||
|
.font(.system(size: 13, weight: .bold))
|
||||||
|
Text(game.hasStreams ? "Watch Game" : "Open Matchup")
|
||||||
|
.font(.system(size: 15, weight: .bold))
|
||||||
|
}
|
||||||
|
.foregroundStyle(game.hasStreams ? .blue : .white.opacity(0.82))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var statusChip: some View {
|
||||||
|
switch game.status {
|
||||||
|
case .live(let inning):
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Circle()
|
||||||
|
.fill(.red)
|
||||||
|
.frame(width: 8, height: 8)
|
||||||
|
Text(inning ?? "LIVE")
|
||||||
|
.font(.system(size: 15, weight: .black, design: .rounded))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 15)
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
.background(.red.opacity(0.18))
|
||||||
|
.clipShape(Capsule())
|
||||||
|
|
||||||
|
case .scheduled(let time):
|
||||||
|
Text(time)
|
||||||
|
.font(.system(size: 15, weight: .black, design: .rounded))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.padding(.horizontal, 15)
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
.background(.white.opacity(0.1))
|
||||||
|
.clipShape(Capsule())
|
||||||
|
|
||||||
|
case .final_:
|
||||||
|
Text("FINAL")
|
||||||
|
.font(.system(size: 15, weight: .black, design: .rounded))
|
||||||
|
.foregroundStyle(.white.opacity(0.96))
|
||||||
|
.padding(.horizontal, 15)
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
.background(.white.opacity(0.12))
|
||||||
|
.clipShape(Capsule())
|
||||||
|
|
||||||
|
case .unknown:
|
||||||
|
EmptyView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func headerChip(title: String, color: Color) -> some View {
|
||||||
|
Text(title)
|
||||||
|
.font(.system(size: 13, weight: .bold, design: .rounded))
|
||||||
|
.foregroundStyle(color)
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 9)
|
||||||
|
.background(color.opacity(0.12))
|
||||||
|
.clipShape(Capsule())
|
||||||
|
}
|
||||||
|
|
||||||
|
private var scoreSummaryText: String? {
|
||||||
|
guard let away = game.awayTeam.score, let home = game.homeTeam.score else { return nil }
|
||||||
|
return "\(away) - \(home)"
|
||||||
|
}
|
||||||
|
|
||||||
|
private var stateHeadline: String {
|
||||||
|
switch game.status {
|
||||||
|
case .scheduled:
|
||||||
|
return "First Pitch"
|
||||||
|
case .live(let inning):
|
||||||
|
return inning ?? "Live"
|
||||||
|
case .final_:
|
||||||
|
return "Final"
|
||||||
|
case .unknown:
|
||||||
|
return "Game Day"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var stateHeadlineColor: Color {
|
||||||
|
switch game.status {
|
||||||
|
case .live:
|
||||||
|
return .red.opacity(0.92)
|
||||||
|
case .scheduled:
|
||||||
|
return .blue.opacity(0.92)
|
||||||
|
case .final_:
|
||||||
|
return .white.opacity(0.82)
|
||||||
|
case .unknown:
|
||||||
|
return .white.opacity(0.72)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var stateDetailText: String? {
|
||||||
|
if game.status.isScheduled {
|
||||||
|
return game.pitchers ?? game.venue
|
||||||
|
}
|
||||||
|
return game.venue
|
||||||
|
}
|
||||||
|
|
||||||
|
private var coverageSummary: String {
|
||||||
|
if game.isBlackedOut {
|
||||||
|
return "Unavailable in your area"
|
||||||
|
}
|
||||||
|
if !game.broadcasts.isEmpty {
|
||||||
|
let names = game.broadcasts.map(\.displayLabel)
|
||||||
|
return names.joined(separator: " / ")
|
||||||
|
}
|
||||||
|
if game.status.isScheduled {
|
||||||
|
return "Feeds closer to game time"
|
||||||
|
}
|
||||||
|
return "No feeds listed"
|
||||||
|
}
|
||||||
|
|
||||||
|
private var coverageIconName: String {
|
||||||
|
if game.isBlackedOut { return "eye.slash.fill" }
|
||||||
|
if !game.broadcasts.isEmpty { return "tv.fill" }
|
||||||
|
return "dot.radiowaves.left.and.right"
|
||||||
|
}
|
||||||
|
|
||||||
|
private var coverageAccent: Color {
|
||||||
|
if game.isBlackedOut { return .red }
|
||||||
|
if !game.broadcasts.isEmpty { return .blue }
|
||||||
|
return .white
|
||||||
|
}
|
||||||
|
|
||||||
|
private var fallbackSummary: String {
|
||||||
|
if let pitchers = game.pitchers, !pitchers.isEmpty {
|
||||||
|
return pitchers
|
||||||
|
}
|
||||||
|
if game.isBlackedOut {
|
||||||
|
return "This game is blacked out in your area."
|
||||||
|
}
|
||||||
|
if !game.broadcasts.isEmpty {
|
||||||
|
return game.broadcasts.map(\.displayLabel).joined(separator: " / ")
|
||||||
|
}
|
||||||
|
return "Select the matchup to view streams and full game details."
|
||||||
|
}
|
||||||
|
|
||||||
|
private var footerSummary: String {
|
||||||
|
if game.isBlackedOut {
|
||||||
|
return "Blackout restrictions apply"
|
||||||
|
}
|
||||||
|
if !game.broadcasts.isEmpty {
|
||||||
|
let feeds = game.broadcasts.map(\.teamCode).joined(separator: " / ")
|
||||||
|
return "Coverage: \(feeds)"
|
||||||
|
}
|
||||||
|
return game.venue ?? "Game details"
|
||||||
|
}
|
||||||
|
|
||||||
|
private var footerIconName: String {
|
||||||
|
if game.isBlackedOut { return "eye.slash.fill" }
|
||||||
|
if !game.broadcasts.isEmpty { return "tv.fill" }
|
||||||
|
return "sportscourt.fill"
|
||||||
|
}
|
||||||
|
|
||||||
|
private var linescoreLabel: String {
|
||||||
|
if let inning = game.currentInningDisplay, !inning.isEmpty {
|
||||||
|
return inning.uppercased()
|
||||||
|
}
|
||||||
|
if game.isFinal {
|
||||||
|
return "FINAL"
|
||||||
|
}
|
||||||
|
return "GAME"
|
||||||
|
}
|
||||||
|
|
||||||
|
private func teamPanelBackground(color: Color) -> some ShapeStyle {
|
||||||
|
LinearGradient(
|
||||||
|
colors: [
|
||||||
|
color.opacity(0.22),
|
||||||
|
.white.opacity(0.05)
|
||||||
|
],
|
||||||
|
startPoint: .leading,
|
||||||
|
endPoint: .trailing
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var panelBackground: some View {
|
||||||
|
RoundedRectangle(cornerRadius: 24, style: .continuous)
|
||||||
|
.fill(.black.opacity(0.22))
|
||||||
|
.overlay {
|
||||||
|
RoundedRectangle(cornerRadius: 24, style: .continuous)
|
||||||
|
.strokeBorder(.white.opacity(0.08), lineWidth: 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var cardBackground: some View {
|
||||||
|
ZStack {
|
||||||
|
RoundedRectangle(cornerRadius: 28, style: .continuous)
|
||||||
|
.fill(Color(red: 0.05, green: 0.07, blue: 0.11))
|
||||||
|
|
||||||
|
LinearGradient(
|
||||||
|
colors: [
|
||||||
|
awayColor.opacity(0.2),
|
||||||
|
Color(red: 0.05, green: 0.07, blue: 0.11),
|
||||||
|
homeColor.opacity(0.22)
|
||||||
|
],
|
||||||
|
startPoint: .topLeading,
|
||||||
|
endPoint: .bottomTrailing
|
||||||
|
)
|
||||||
|
|
||||||
|
Circle()
|
||||||
|
.fill(awayColor.opacity(0.18))
|
||||||
|
.frame(width: 320, height: 320)
|
||||||
|
.blur(radius: 64)
|
||||||
|
.offset(x: -280, y: -70)
|
||||||
|
|
||||||
|
Circle()
|
||||||
|
.fill(homeColor.opacity(0.18))
|
||||||
|
.frame(width: 360, height: 360)
|
||||||
|
.blur(radius: 72)
|
||||||
|
.offset(x: 320, y: 40)
|
||||||
|
|
||||||
|
Rectangle()
|
||||||
|
.fill(.white.opacity(0.03))
|
||||||
|
.frame(width: 1)
|
||||||
|
.padding(.vertical, 28)
|
||||||
|
.offset(x: 86)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var borderColor: Color {
|
||||||
|
if game.isLive {
|
||||||
|
return .red.opacity(0.32)
|
||||||
|
}
|
||||||
|
if game.hasStreams {
|
||||||
|
return .blue.opacity(0.24)
|
||||||
|
}
|
||||||
|
return .white.opacity(0.08)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var borderWidth: CGFloat {
|
||||||
|
game.isLive || game.hasStreams ? 2 : 1
|
||||||
|
}
|
||||||
|
|
||||||
|
private var shadowColor: Color {
|
||||||
|
if game.isLive {
|
||||||
|
return .red.opacity(0.18)
|
||||||
|
}
|
||||||
|
return .black.opacity(0.26)
|
||||||
|
}
|
||||||
|
}
|
||||||
302
mlbTVOS/Views/GameCardView.swift
Normal file
302
mlbTVOS/Views/GameCardView.swift
Normal file
@@ -0,0 +1,302 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct GameCardView: View {
|
||||||
|
let game: Game
|
||||||
|
let onSelect: () -> Void
|
||||||
|
@Environment(GamesViewModel.self) private var viewModel
|
||||||
|
|
||||||
|
private var inMultiView: Bool {
|
||||||
|
game.broadcasts.contains(where: { bc in
|
||||||
|
viewModel.activeStreams.contains(where: { $0.id == bc.id })
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
private var awayColor: Color { TeamAssets.color(for: game.awayTeam.code) }
|
||||||
|
private var homeColor: Color { TeamAssets.color(for: game.homeTeam.code) }
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
Button(action: onSelect) {
|
||||||
|
VStack(alignment: .leading, spacing: 0) {
|
||||||
|
header
|
||||||
|
.padding(.horizontal, 22)
|
||||||
|
.padding(.top, 18)
|
||||||
|
.padding(.bottom, 16)
|
||||||
|
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
teamRow(team: game.awayTeam, isWinning: isWinning(away: true))
|
||||||
|
teamRow(team: game.homeTeam, isWinning: isWinning(away: false))
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 22)
|
||||||
|
|
||||||
|
Spacer(minLength: 14)
|
||||||
|
|
||||||
|
footer
|
||||||
|
.padding(.horizontal, 22)
|
||||||
|
.padding(.vertical, 16)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, minHeight: 320, maxHeight: 320, alignment: .topLeading)
|
||||||
|
.background(cardBackground)
|
||||||
|
.overlay(alignment: .top) {
|
||||||
|
HStack(spacing: 0) {
|
||||||
|
Rectangle()
|
||||||
|
.fill(awayColor.opacity(0.95))
|
||||||
|
Rectangle()
|
||||||
|
.fill(homeColor.opacity(0.95))
|
||||||
|
}
|
||||||
|
.frame(height: 4)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 24, style: .continuous))
|
||||||
|
}
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 24, style: .continuous))
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 24, style: .continuous)
|
||||||
|
.strokeBorder(
|
||||||
|
inMultiView ? .green.opacity(0.4) :
|
||||||
|
game.isLive ? .red.opacity(0.4) :
|
||||||
|
.white.opacity(0.08),
|
||||||
|
lineWidth: inMultiView || game.isLive ? 2 : 1
|
||||||
|
)
|
||||||
|
)
|
||||||
|
.shadow(
|
||||||
|
color: shadowColor,
|
||||||
|
radius: 18,
|
||||||
|
y: 8
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.buttonStyle(.card)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var header: some View {
|
||||||
|
HStack(alignment: .top, spacing: 12) {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
Text((game.gameType ?? "Matchup").uppercased())
|
||||||
|
.font(.system(size: 12, weight: .bold, design: .rounded))
|
||||||
|
.foregroundStyle(.white.opacity(0.58))
|
||||||
|
.kerning(1.2)
|
||||||
|
.lineLimit(1)
|
||||||
|
|
||||||
|
Text(subtitleText)
|
||||||
|
.font(.system(size: 14, weight: .medium))
|
||||||
|
.foregroundStyle(.white.opacity(0.82))
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(minLength: 12)
|
||||||
|
|
||||||
|
compactStatus
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func teamRow(team: TeamInfo, isWinning: Bool) -> some View {
|
||||||
|
HStack(spacing: 14) {
|
||||||
|
TeamLogoView(team: team, size: 46)
|
||||||
|
.frame(width: 50, height: 50)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 5) {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
Text(team.code)
|
||||||
|
.font(.system(size: 28, weight: .black, design: .rounded))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
|
||||||
|
if let record = team.record {
|
||||||
|
Text(record)
|
||||||
|
.font(.system(size: 12, weight: .bold, design: .monospaced))
|
||||||
|
.foregroundStyle(.white.opacity(0.72))
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
.padding(.vertical, 5)
|
||||||
|
.background(.white.opacity(isWinning ? 0.12 : 0.07))
|
||||||
|
.clipShape(Capsule())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(team.displayName)
|
||||||
|
.font(.system(size: 15, weight: .semibold))
|
||||||
|
.foregroundStyle(.white.opacity(isWinning ? 0.88 : 0.68))
|
||||||
|
.lineLimit(1)
|
||||||
|
.minimumScaleFactor(0.75)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(minLength: 12)
|
||||||
|
|
||||||
|
if !game.status.isScheduled, let score = team.score {
|
||||||
|
Text("\(score)")
|
||||||
|
.font(.system(size: 42, weight: .black, design: .rounded))
|
||||||
|
.foregroundStyle(isWinning ? .white : .white.opacity(0.72))
|
||||||
|
.contentTransition(.numericText())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 14)
|
||||||
|
.padding(.vertical, 13)
|
||||||
|
.background {
|
||||||
|
RoundedRectangle(cornerRadius: 18, style: .continuous)
|
||||||
|
.fill(rowBackground(for: team, isWinning: isWinning))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func isWinning(away: Bool) -> Bool {
|
||||||
|
guard let a = game.awayTeam.score, let h = game.homeTeam.score else { return false }
|
||||||
|
return away ? a > h : h > a
|
||||||
|
}
|
||||||
|
|
||||||
|
private var subtitleText: String {
|
||||||
|
if game.status.isScheduled {
|
||||||
|
return game.pitchers ?? game.venue ?? "Upcoming"
|
||||||
|
}
|
||||||
|
if game.isBlackedOut {
|
||||||
|
return "Regional blackout"
|
||||||
|
}
|
||||||
|
if !game.broadcasts.isEmpty {
|
||||||
|
let count = game.broadcasts.count
|
||||||
|
return "\(count) feed\(count == 1 ? "" : "s") available"
|
||||||
|
}
|
||||||
|
return game.venue ?? "No feeds listed yet"
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var compactStatus: some View {
|
||||||
|
switch game.status {
|
||||||
|
case .live(let inning):
|
||||||
|
HStack(spacing: 7) {
|
||||||
|
Circle()
|
||||||
|
.fill(.red)
|
||||||
|
.frame(width: 8, height: 8)
|
||||||
|
Text(inning ?? "LIVE")
|
||||||
|
.font(.system(size: 13, weight: .bold, design: .rounded))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
.background(.red.opacity(0.18))
|
||||||
|
.clipShape(Capsule())
|
||||||
|
|
||||||
|
case .scheduled(let time):
|
||||||
|
Text(time)
|
||||||
|
.font(.system(size: 13, weight: .bold, design: .rounded))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
.background(.white.opacity(0.12))
|
||||||
|
.clipShape(Capsule())
|
||||||
|
|
||||||
|
case .final_:
|
||||||
|
Text("FINAL")
|
||||||
|
.font(.system(size: 13, weight: .black, design: .rounded))
|
||||||
|
.foregroundStyle(.white.opacity(0.92))
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
.background(.white.opacity(0.12))
|
||||||
|
.clipShape(Capsule())
|
||||||
|
|
||||||
|
case .unknown:
|
||||||
|
EmptyView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var footer: some View {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Label(footerText, systemImage: footerIconName)
|
||||||
|
.font(.system(size: 13, weight: .semibold))
|
||||||
|
.foregroundStyle(.white.opacity(0.66))
|
||||||
|
.lineLimit(1)
|
||||||
|
|
||||||
|
Spacer(minLength: 12)
|
||||||
|
|
||||||
|
if inMultiView {
|
||||||
|
footerBadge(title: "In Multi-View", color: .green)
|
||||||
|
} else if game.isBlackedOut {
|
||||||
|
footerBadge(title: "Blacked Out", color: .red)
|
||||||
|
} else if game.hasStreams {
|
||||||
|
footerBadge(title: "Watch", color: .blue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.overlay(alignment: .top) {
|
||||||
|
Rectangle()
|
||||||
|
.fill(.white.opacity(0.08))
|
||||||
|
.frame(height: 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var footerText: String {
|
||||||
|
if game.status.isScheduled {
|
||||||
|
return game.venue ?? (game.pitchers ?? "First pitch later today")
|
||||||
|
}
|
||||||
|
if game.isBlackedOut {
|
||||||
|
return "This game is unavailable in your area"
|
||||||
|
}
|
||||||
|
if let pitchers = game.pitchers, !pitchers.isEmpty {
|
||||||
|
return pitchers
|
||||||
|
}
|
||||||
|
if !game.broadcasts.isEmpty {
|
||||||
|
return game.broadcasts.map(\.teamCode).joined(separator: " • ")
|
||||||
|
}
|
||||||
|
return game.venue ?? "Tap for details"
|
||||||
|
}
|
||||||
|
|
||||||
|
private var footerIconName: String {
|
||||||
|
if game.isBlackedOut { return "eye.slash.fill" }
|
||||||
|
if game.hasStreams { return "tv.fill" }
|
||||||
|
if game.status.isScheduled { return "mappin.and.ellipse" }
|
||||||
|
return "sportscourt.fill"
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func footerBadge(title: String, color: Color) -> some View {
|
||||||
|
Text(title)
|
||||||
|
.font(.system(size: 12, weight: .bold, design: .rounded))
|
||||||
|
.foregroundStyle(color)
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
.background(color.opacity(0.12))
|
||||||
|
.clipShape(Capsule())
|
||||||
|
}
|
||||||
|
|
||||||
|
private func rowBackground(for team: TeamInfo, isWinning: Bool) -> some ShapeStyle {
|
||||||
|
let color = TeamAssets.color(for: team.code)
|
||||||
|
return LinearGradient(
|
||||||
|
colors: [
|
||||||
|
color.opacity(isWinning ? 0.22 : 0.12),
|
||||||
|
.white.opacity(isWinning ? 0.07 : 0.03)
|
||||||
|
],
|
||||||
|
startPoint: .leading,
|
||||||
|
endPoint: .trailing
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var cardBackground: some View {
|
||||||
|
ZStack {
|
||||||
|
RoundedRectangle(cornerRadius: 24, style: .continuous)
|
||||||
|
.fill(Color(red: 0.08, green: 0.09, blue: 0.12))
|
||||||
|
|
||||||
|
LinearGradient(
|
||||||
|
colors: [
|
||||||
|
awayColor.opacity(0.18),
|
||||||
|
Color.clear,
|
||||||
|
homeColor.opacity(0.18)
|
||||||
|
],
|
||||||
|
startPoint: .topLeading,
|
||||||
|
endPoint: .bottomTrailing
|
||||||
|
)
|
||||||
|
|
||||||
|
Circle()
|
||||||
|
.fill(awayColor.opacity(0.18))
|
||||||
|
.frame(width: 180)
|
||||||
|
.blur(radius: 40)
|
||||||
|
.offset(x: -110, y: -90)
|
||||||
|
|
||||||
|
Circle()
|
||||||
|
.fill(homeColor.opacity(0.16))
|
||||||
|
.frame(width: 200)
|
||||||
|
.blur(radius: 44)
|
||||||
|
.offset(x: 140, y: 120)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var shadowColor: Color {
|
||||||
|
if inMultiView { return .green.opacity(0.18) }
|
||||||
|
if game.isLive { return .red.opacity(0.22) }
|
||||||
|
return .black.opacity(0.22)
|
||||||
|
}
|
||||||
|
}
|
||||||
375
mlbTVOS/Views/GameCenterView.swift
Normal file
375
mlbTVOS/Views/GameCenterView.swift
Normal file
@@ -0,0 +1,375 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct GameCenterView: View {
|
||||||
|
let game: Game
|
||||||
|
|
||||||
|
@State private var viewModel = GameCenterViewModel()
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 18) {
|
||||||
|
header
|
||||||
|
|
||||||
|
if viewModel.isLoading && viewModel.feed == nil {
|
||||||
|
loadingState
|
||||||
|
} else if let feed = viewModel.feed {
|
||||||
|
situationStrip(feed: feed)
|
||||||
|
matchupPanel(feed: feed)
|
||||||
|
|
||||||
|
if !feed.decisionsSummary.isEmpty || !feed.officialSummary.isEmpty || feed.weatherSummary != nil {
|
||||||
|
contextPanel(feed: feed)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !feed.awayLineup.isEmpty || !feed.homeLineup.isEmpty {
|
||||||
|
lineupPanel(feed: feed)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !feed.scoringPlays.isEmpty {
|
||||||
|
timelineSection(
|
||||||
|
title: "Scoring Plays",
|
||||||
|
subtitle: "Every run-scoring swing, plate appearance, and sequence.",
|
||||||
|
plays: Array(feed.scoringPlays.suffix(6).reversed())
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
timelineSection(
|
||||||
|
title: "Recent Plays",
|
||||||
|
subtitle: "The latest plate appearances and game-state changes.",
|
||||||
|
plays: Array(feed.recentPlays.prefix(8))
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
errorState
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.task(id: game.id) {
|
||||||
|
await viewModel.watch(game: game)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var header: some View {
|
||||||
|
HStack(alignment: .top, spacing: 16) {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
Text("Game Center")
|
||||||
|
.font(.system(size: 28, weight: .bold, design: .rounded))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
|
||||||
|
Text("Live matchup context, lineup state, and inning-by-inning events from MLB's live feed.")
|
||||||
|
.font(.system(size: 15, weight: .medium))
|
||||||
|
.foregroundStyle(.white.opacity(0.58))
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Button {
|
||||||
|
if let gamePk = game.gamePk {
|
||||||
|
Task { await viewModel.refresh(gamePk: gamePk) }
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Image(systemName: "arrow.clockwise")
|
||||||
|
.font(.system(size: 12, weight: .bold))
|
||||||
|
Text(refreshLabel)
|
||||||
|
.font(.system(size: 14, weight: .semibold))
|
||||||
|
}
|
||||||
|
.foregroundStyle(.white.opacity(0.82))
|
||||||
|
.padding(.horizontal, 14)
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
.background(.white.opacity(0.08))
|
||||||
|
.clipShape(Capsule())
|
||||||
|
}
|
||||||
|
.buttonStyle(.card)
|
||||||
|
.disabled(game.gamePk == nil)
|
||||||
|
}
|
||||||
|
.padding(22)
|
||||||
|
.background(panelBackground)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func situationStrip(feed: LiveGameFeed) -> some View {
|
||||||
|
HStack(spacing: 14) {
|
||||||
|
situationTile(
|
||||||
|
title: "Situation",
|
||||||
|
value: feed.liveData.linescore?.inningDisplay ?? game.currentInningDisplay ?? game.status.label,
|
||||||
|
accent: .red
|
||||||
|
)
|
||||||
|
|
||||||
|
situationTile(
|
||||||
|
title: "Count",
|
||||||
|
value: feed.currentCountText ?? "No active count",
|
||||||
|
accent: .blue
|
||||||
|
)
|
||||||
|
|
||||||
|
situationTile(
|
||||||
|
title: "Bases",
|
||||||
|
value: feed.occupiedBases.isEmpty ? "Bases Empty" : "On \(feed.occupiedBases.joined(separator: ", "))",
|
||||||
|
accent: .green
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func situationTile(title: String, value: String, accent: Color) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
Text(title.uppercased())
|
||||||
|
.font(.system(size: 12, weight: .bold, design: .rounded))
|
||||||
|
.foregroundStyle(accent.opacity(0.9))
|
||||||
|
.kerning(1.2)
|
||||||
|
|
||||||
|
Text(value)
|
||||||
|
.font(.system(size: 19, weight: .bold, design: .rounded))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.lineLimit(2)
|
||||||
|
.minimumScaleFactor(0.82)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.padding(18)
|
||||||
|
.background(panelBackground)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func matchupPanel(feed: LiveGameFeed) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
|
HStack(alignment: .top, spacing: 18) {
|
||||||
|
VStack(alignment: .leading, spacing: 5) {
|
||||||
|
Text("At Bat")
|
||||||
|
.font(.system(size: 13, weight: .bold, design: .rounded))
|
||||||
|
.foregroundStyle(.white.opacity(0.5))
|
||||||
|
|
||||||
|
Text(feed.currentBatter?.displayName ?? "Awaiting matchup")
|
||||||
|
.font(.system(size: 24, weight: .bold, design: .rounded))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
|
||||||
|
if let onDeck = feed.onDeckBatter?.displayName {
|
||||||
|
Text("On deck: \(onDeck)")
|
||||||
|
.font(.system(size: 15, weight: .medium))
|
||||||
|
.foregroundStyle(.white.opacity(0.55))
|
||||||
|
}
|
||||||
|
|
||||||
|
if let inHole = feed.inHoleBatter?.displayName {
|
||||||
|
Text("In hole: \(inHole)")
|
||||||
|
.font(.system(size: 15, weight: .medium))
|
||||||
|
.foregroundStyle(.white.opacity(0.46))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
VStack(alignment: .trailing, spacing: 5) {
|
||||||
|
Text("Pitching")
|
||||||
|
.font(.system(size: 13, weight: .bold, design: .rounded))
|
||||||
|
.foregroundStyle(.white.opacity(0.5))
|
||||||
|
|
||||||
|
Text(feed.currentPitcher?.displayName ?? "Awaiting pitcher")
|
||||||
|
.font(.system(size: 24, weight: .bold, design: .rounded))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.multilineTextAlignment(.trailing)
|
||||||
|
|
||||||
|
if let play = feed.currentPlay {
|
||||||
|
Text(play.summaryText)
|
||||||
|
.font(.system(size: 15, weight: .medium))
|
||||||
|
.foregroundStyle(.white.opacity(0.58))
|
||||||
|
.frame(maxWidth: 420, alignment: .trailing)
|
||||||
|
.multilineTextAlignment(.trailing)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(22)
|
||||||
|
.background(panelBackground)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func contextPanel(feed: LiveGameFeed) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
|
if let weatherSummary = feed.weatherSummary {
|
||||||
|
contextRow(title: "Weather", values: [weatherSummary], accent: .blue)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !feed.decisionsSummary.isEmpty {
|
||||||
|
contextRow(title: "Decisions", values: feed.decisionsSummary, accent: .green)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !feed.officialSummary.isEmpty {
|
||||||
|
contextRow(title: "Officials", values: Array(feed.officialSummary.prefix(4)), accent: .orange)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(22)
|
||||||
|
.background(panelBackground)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func contextRow(title: String, values: [String], accent: Color) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
Text(title.uppercased())
|
||||||
|
.font(.system(size: 12, weight: .bold, design: .rounded))
|
||||||
|
.foregroundStyle(accent.opacity(0.9))
|
||||||
|
.kerning(1.2)
|
||||||
|
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
ForEach(values, id: \.self) { value in
|
||||||
|
Text(value)
|
||||||
|
.font(.system(size: 14, weight: .semibold))
|
||||||
|
.foregroundStyle(.white.opacity(0.82))
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 9)
|
||||||
|
.background(.white.opacity(0.08))
|
||||||
|
.clipShape(Capsule())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func lineupPanel(feed: LiveGameFeed) -> some View {
|
||||||
|
HStack(alignment: .top, spacing: 16) {
|
||||||
|
lineupColumn(
|
||||||
|
title: game.awayTeam.code,
|
||||||
|
teamName: game.awayTeam.displayName,
|
||||||
|
color: TeamAssets.color(for: game.awayTeam.code),
|
||||||
|
players: Array(feed.awayLineup.prefix(9))
|
||||||
|
)
|
||||||
|
|
||||||
|
lineupColumn(
|
||||||
|
title: game.homeTeam.code,
|
||||||
|
teamName: game.homeTeam.displayName,
|
||||||
|
color: TeamAssets.color(for: game.homeTeam.code),
|
||||||
|
players: Array(feed.homeLineup.prefix(9))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func lineupColumn(title: String, teamName: String, color: Color, players: [LiveFeedBoxscorePlayer]) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 14) {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
Circle()
|
||||||
|
.fill(color)
|
||||||
|
.frame(width: 10, height: 10)
|
||||||
|
|
||||||
|
Text(title)
|
||||||
|
.font(.system(size: 18, weight: .black, design: .rounded))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
|
||||||
|
Text(teamName)
|
||||||
|
.font(.system(size: 14, weight: .medium))
|
||||||
|
.foregroundStyle(.white.opacity(0.55))
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
ForEach(Array(players.enumerated()), id: \.element.id) { index, player in
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
Text("\(index + 1)")
|
||||||
|
.font(.system(size: 12, weight: .bold, design: .rounded))
|
||||||
|
.foregroundStyle(.white.opacity(0.6))
|
||||||
|
.frame(width: 18, alignment: .leading)
|
||||||
|
|
||||||
|
Text(player.person.displayName)
|
||||||
|
.font(.system(size: 15, weight: .semibold))
|
||||||
|
.foregroundStyle(.white.opacity(0.88))
|
||||||
|
.lineLimit(1)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Text(player.position?.abbreviation ?? "")
|
||||||
|
.font(.system(size: 13, weight: .bold, design: .rounded))
|
||||||
|
.foregroundStyle(.white.opacity(0.45))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.padding(20)
|
||||||
|
.background(panelBackground)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func timelineSection(title: String, subtitle: String, plays: [LiveFeedPlay]) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 14) {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text(title)
|
||||||
|
.font(.system(size: 24, weight: .bold, design: .rounded))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
|
||||||
|
Text(subtitle)
|
||||||
|
.font(.system(size: 15, weight: .medium))
|
||||||
|
.foregroundStyle(.white.opacity(0.55))
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
ForEach(plays) { play in
|
||||||
|
HStack(alignment: .top, spacing: 12) {
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text(play.about?.halfInning?.uppercased() ?? "PLAY")
|
||||||
|
.font(.system(size: 11, weight: .bold, design: .rounded))
|
||||||
|
.foregroundStyle(.white.opacity(0.4))
|
||||||
|
|
||||||
|
if let inning = play.about?.inning {
|
||||||
|
Text("\(inning)")
|
||||||
|
.font(.system(size: 16, weight: .black, design: .rounded))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(width: 54, alignment: .leading)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
Text(play.summaryText)
|
||||||
|
.font(.system(size: 16, weight: .semibold))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
|
||||||
|
if let event = play.result?.event {
|
||||||
|
Text(event)
|
||||||
|
.font(.system(size: 13, weight: .medium))
|
||||||
|
.foregroundStyle(.white.opacity(0.5))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 18, style: .continuous)
|
||||||
|
.fill(.white.opacity(0.05))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(22)
|
||||||
|
.background(panelBackground)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var loadingState: some View {
|
||||||
|
HStack {
|
||||||
|
Spacer()
|
||||||
|
ProgressView("Loading game center...")
|
||||||
|
.font(.system(size: 16, weight: .semibold))
|
||||||
|
.foregroundStyle(.white.opacity(0.7))
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(.vertical, 28)
|
||||||
|
.background(panelBackground)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var errorState: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
Text("Game center is unavailable.")
|
||||||
|
.font(.system(size: 20, weight: .bold, design: .rounded))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
|
||||||
|
Text(viewModel.errorMessage ?? "No live feed data was returned for this game.")
|
||||||
|
.font(.system(size: 15, weight: .medium))
|
||||||
|
.foregroundStyle(.white.opacity(0.6))
|
||||||
|
}
|
||||||
|
.padding(22)
|
||||||
|
.background(panelBackground)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var refreshLabel: String {
|
||||||
|
if let lastUpdated = viewModel.lastUpdated {
|
||||||
|
let formatter = DateFormatter()
|
||||||
|
formatter.dateFormat = "h:mm:ss a"
|
||||||
|
return formatter.string(from: lastUpdated)
|
||||||
|
}
|
||||||
|
return "Refresh"
|
||||||
|
}
|
||||||
|
|
||||||
|
private var panelBackground: some View {
|
||||||
|
RoundedRectangle(cornerRadius: 24, style: .continuous)
|
||||||
|
.fill(.black.opacity(0.22))
|
||||||
|
.overlay {
|
||||||
|
RoundedRectangle(cornerRadius: 24, style: .continuous)
|
||||||
|
.strokeBorder(.white.opacity(0.08), lineWidth: 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
635
mlbTVOS/Views/GameListView.swift
Normal file
635
mlbTVOS/Views/GameListView.swift
Normal file
@@ -0,0 +1,635 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct StreamOptionsSheet: View {
|
||||||
|
let game: Game
|
||||||
|
var onWatch: ((BroadcastSelection) -> Void)?
|
||||||
|
@Environment(GamesViewModel.self) private var viewModel
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
private var awayColor: Color { TeamAssets.color(for: game.awayTeam.code) }
|
||||||
|
private var homeColor: Color { TeamAssets.color(for: game.homeTeam.code) }
|
||||||
|
|
||||||
|
private var hasLinescore: Bool {
|
||||||
|
!game.status.isScheduled && (game.linescore?.hasData ?? false)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var canAddMoreStreams: Bool {
|
||||||
|
viewModel.activeStreams.count < 4
|
||||||
|
}
|
||||||
|
|
||||||
|
private var awayPitcherName: String? {
|
||||||
|
guard let pitchers = game.pitchers else { return nil }
|
||||||
|
let parts = pitchers.components(separatedBy: " vs ")
|
||||||
|
return parts.first
|
||||||
|
}
|
||||||
|
|
||||||
|
private var homePitcherName: String? {
|
||||||
|
guard let pitchers = game.pitchers else { return nil }
|
||||||
|
let parts = pitchers.components(separatedBy: " vs ")
|
||||||
|
return parts.count > 1 ? parts.last : nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ScrollView {
|
||||||
|
VStack(spacing: 28) {
|
||||||
|
HStack(alignment: .top, spacing: 28) {
|
||||||
|
matchupColumn
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
|
||||||
|
actionRail
|
||||||
|
.frame(width: 520, alignment: .leading)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !canAddMoreStreams {
|
||||||
|
Label(
|
||||||
|
"Multi-view is full. Remove a stream in Multi-View or Settings before adding another.",
|
||||||
|
systemImage: "rectangle.split.2x2.fill"
|
||||||
|
)
|
||||||
|
.font(.system(size: 16, weight: .semibold))
|
||||||
|
.foregroundStyle(.orange)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.padding(.horizontal, 22)
|
||||||
|
.padding(.vertical, 18)
|
||||||
|
.background(panelBackground)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 56)
|
||||||
|
.padding(.vertical, 42)
|
||||||
|
}
|
||||||
|
.background(sheetBackground.ignoresSafeArea())
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var matchupColumn: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 20) {
|
||||||
|
headerBar
|
||||||
|
|
||||||
|
featuredTeamRow(
|
||||||
|
team: game.awayTeam,
|
||||||
|
color: awayColor,
|
||||||
|
pitcherURL: game.awayPitcherHeadshotURL,
|
||||||
|
pitcherName: awayPitcherName
|
||||||
|
)
|
||||||
|
|
||||||
|
scorePanel
|
||||||
|
|
||||||
|
featuredTeamRow(
|
||||||
|
team: game.homeTeam,
|
||||||
|
color: homeColor,
|
||||||
|
pitcherURL: game.homePitcherHeadshotURL,
|
||||||
|
pitcherName: homePitcherName
|
||||||
|
)
|
||||||
|
|
||||||
|
if hasLinescore,
|
||||||
|
let linescore = game.linescore {
|
||||||
|
VStack(alignment: .leading, spacing: 14) {
|
||||||
|
HStack {
|
||||||
|
Text("Linescore")
|
||||||
|
.font(.system(size: 18, weight: .bold))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Text(linescoreStatusText)
|
||||||
|
.font(.system(size: 13, weight: .bold, design: .rounded))
|
||||||
|
.foregroundStyle(.white.opacity(0.58))
|
||||||
|
}
|
||||||
|
|
||||||
|
LinescoreView(
|
||||||
|
linescore: linescore,
|
||||||
|
awayCode: game.awayTeam.code,
|
||||||
|
homeCode: game.homeTeam.code
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.padding(22)
|
||||||
|
.background(panelBackground)
|
||||||
|
}
|
||||||
|
|
||||||
|
GameCenterView(game: game)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var headerBar: some View {
|
||||||
|
HStack(alignment: .top, spacing: 16) {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text((game.gameType ?? "Game Center").uppercased())
|
||||||
|
.font(.system(size: 13, weight: .bold, design: .rounded))
|
||||||
|
.foregroundStyle(.white.opacity(0.58))
|
||||||
|
.kerning(1.8)
|
||||||
|
|
||||||
|
if let venue = game.venue {
|
||||||
|
Label(venue, systemImage: "mappin.and.ellipse")
|
||||||
|
.font(.system(size: 15, weight: .medium))
|
||||||
|
.foregroundStyle(.white.opacity(0.82))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
if game.isBlackedOut {
|
||||||
|
chip(title: "BLACKOUT", color: .red)
|
||||||
|
} else if !game.broadcasts.isEmpty {
|
||||||
|
chip(title: "\(game.broadcasts.count) FEED\(game.broadcasts.count == 1 ? "" : "S")", color: .blue)
|
||||||
|
}
|
||||||
|
|
||||||
|
statusChip
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func featuredTeamRow(team: TeamInfo, color: Color, pitcherURL: URL?, pitcherName: String?) -> some View {
|
||||||
|
HStack(spacing: 18) {
|
||||||
|
TeamLogoView(team: team, size: 72)
|
||||||
|
.frame(width: 78, height: 78)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 7) {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
Text(team.code)
|
||||||
|
.font(.system(size: 30, weight: .black, design: .rounded))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
|
||||||
|
if let record = team.record {
|
||||||
|
Text(record)
|
||||||
|
.font(.system(size: 13, weight: .bold, design: .monospaced))
|
||||||
|
.foregroundStyle(.white.opacity(0.72))
|
||||||
|
.padding(.horizontal, 11)
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
.background(.white.opacity(0.08))
|
||||||
|
.clipShape(Capsule())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(team.displayName)
|
||||||
|
.font(.system(size: 28, weight: .bold))
|
||||||
|
.foregroundStyle(.white.opacity(0.96))
|
||||||
|
.lineLimit(1)
|
||||||
|
.minimumScaleFactor(0.82)
|
||||||
|
|
||||||
|
if let standing = team.standingSummary {
|
||||||
|
Text(standing)
|
||||||
|
.font(.system(size: 15, weight: .medium))
|
||||||
|
.foregroundStyle(.white.opacity(0.56))
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(minLength: 14)
|
||||||
|
|
||||||
|
if pitcherURL != nil || pitcherName != nil {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
if pitcherURL != nil {
|
||||||
|
PitcherHeadshotView(
|
||||||
|
url: pitcherURL,
|
||||||
|
teamCode: team.code,
|
||||||
|
name: nil,
|
||||||
|
size: 46
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(alignment: .trailing, spacing: 4) {
|
||||||
|
Text("Probable")
|
||||||
|
.font(.system(size: 12, weight: .bold, design: .rounded))
|
||||||
|
.foregroundStyle(.white.opacity(0.42))
|
||||||
|
|
||||||
|
Text(pitcherName ?? "TBD")
|
||||||
|
.font(.system(size: 16, weight: .semibold))
|
||||||
|
.foregroundStyle(.white.opacity(0.84))
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 22)
|
||||||
|
.padding(.vertical, 18)
|
||||||
|
.background {
|
||||||
|
RoundedRectangle(cornerRadius: 24, style: .continuous)
|
||||||
|
.fill(teamPanelBackground(color: color))
|
||||||
|
}
|
||||||
|
.overlay {
|
||||||
|
RoundedRectangle(cornerRadius: 24, style: .continuous)
|
||||||
|
.strokeBorder(.white.opacity(0.08), lineWidth: 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var scorePanel: some View {
|
||||||
|
VStack(spacing: 12) {
|
||||||
|
if let summary = scoreSummaryText {
|
||||||
|
Text(summary)
|
||||||
|
.font(.system(size: 72, weight: .black, design: .rounded))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.monospacedDigit()
|
||||||
|
.contentTransition(.numericText())
|
||||||
|
} else {
|
||||||
|
Text(game.status.label)
|
||||||
|
.font(.system(size: 52, weight: .black, design: .rounded))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(statusHeadline)
|
||||||
|
.font(.system(size: 22, weight: .semibold, design: .rounded))
|
||||||
|
.foregroundStyle(statusHeadlineColor)
|
||||||
|
|
||||||
|
if let detail = centerDetailText {
|
||||||
|
Text(detail)
|
||||||
|
.font(.system(size: 16, weight: .medium))
|
||||||
|
.foregroundStyle(.white.opacity(0.62))
|
||||||
|
.lineLimit(2)
|
||||||
|
.multilineTextAlignment(.center)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
.padding(.horizontal, 24)
|
||||||
|
.padding(.vertical, 26)
|
||||||
|
.background {
|
||||||
|
RoundedRectangle(cornerRadius: 26, style: .continuous)
|
||||||
|
.fill(.white.opacity(0.06))
|
||||||
|
}
|
||||||
|
.overlay {
|
||||||
|
RoundedRectangle(cornerRadius: 26, style: .continuous)
|
||||||
|
.strokeBorder(.white.opacity(0.08), lineWidth: 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var actionRail: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 18) {
|
||||||
|
actionRailHeader
|
||||||
|
|
||||||
|
if !game.broadcasts.isEmpty {
|
||||||
|
ForEach(game.broadcasts) { broadcast in
|
||||||
|
broadcastCard(broadcast)
|
||||||
|
}
|
||||||
|
} else if game.isBlackedOut {
|
||||||
|
blackoutCard
|
||||||
|
} else if game.status.isScheduled {
|
||||||
|
scheduledStateCard
|
||||||
|
} else {
|
||||||
|
fallbackActionsCard
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var actionRailHeader: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
Text("Watch Options")
|
||||||
|
.font(.system(size: 28, weight: .bold, design: .rounded))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
|
||||||
|
Text(actionRailSubtitle)
|
||||||
|
.font(.system(size: 16, weight: .medium))
|
||||||
|
.foregroundStyle(.white.opacity(0.68))
|
||||||
|
.lineLimit(2)
|
||||||
|
}
|
||||||
|
.padding(22)
|
||||||
|
.background(panelBackground)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func broadcastCard(_ broadcast: Broadcast) -> some View {
|
||||||
|
let alreadyAdded = viewModel.activeStreams.contains(where: { $0.id == broadcast.id })
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
Circle()
|
||||||
|
.fill(TeamAssets.color(for: broadcast.teamCode))
|
||||||
|
.frame(width: 10, height: 10)
|
||||||
|
|
||||||
|
Text(broadcast.teamCode)
|
||||||
|
.font(.system(size: 13, weight: .black, design: .rounded))
|
||||||
|
.foregroundStyle(.white.opacity(0.78))
|
||||||
|
.padding(.horizontal, 10)
|
||||||
|
.padding(.vertical, 6)
|
||||||
|
.background(.white.opacity(0.08))
|
||||||
|
.clipShape(Capsule())
|
||||||
|
|
||||||
|
Text(broadcast.name)
|
||||||
|
.font(.system(size: 18, weight: .semibold))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.lineLimit(1)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
if alreadyAdded {
|
||||||
|
chip(title: "ADDED", color: .green)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(spacing: 14) {
|
||||||
|
railActionButton(
|
||||||
|
title: "Watch Full Screen",
|
||||||
|
subtitle: "Open \(broadcast.name) in the main player",
|
||||||
|
systemImage: "play.fill",
|
||||||
|
fill: .blue.opacity(0.18)
|
||||||
|
) {
|
||||||
|
let selection = BroadcastSelection(broadcast: broadcast, game: game)
|
||||||
|
if let onWatch { onWatch(selection) } else { dismiss() }
|
||||||
|
}
|
||||||
|
|
||||||
|
railActionButton(
|
||||||
|
title: alreadyAdded ? "Already Added to Multi-View" : "Add to Multi-View",
|
||||||
|
subtitle: alreadyAdded ? "This feed is already active in the grid" : "Send \(broadcast.name) into an open tile",
|
||||||
|
systemImage: alreadyAdded ? "checkmark.circle.fill" : "plus.circle.fill",
|
||||||
|
fill: alreadyAdded ? .green.opacity(0.14) : .white.opacity(0.08),
|
||||||
|
foreground: alreadyAdded ? .green : .white,
|
||||||
|
disabled: !canAddMoreStreams || alreadyAdded
|
||||||
|
) {
|
||||||
|
viewModel.addStream(broadcast: broadcast, game: game)
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(22)
|
||||||
|
.background(panelBackground)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var blackoutCard: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 14) {
|
||||||
|
Image(systemName: "eye.slash.fill")
|
||||||
|
.font(.system(size: 36, weight: .bold))
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
|
||||||
|
Text("Blacked Out")
|
||||||
|
.font(.system(size: 28, weight: .bold, design: .rounded))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
|
||||||
|
Text("This game is unavailable in your area. The matchup data is still live, but watch controls are disabled.")
|
||||||
|
.font(.system(size: 17, weight: .medium))
|
||||||
|
.foregroundStyle(.white.opacity(0.72))
|
||||||
|
.lineLimit(3)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.padding(24)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 24, style: .continuous)
|
||||||
|
.fill(.red.opacity(0.12))
|
||||||
|
)
|
||||||
|
.overlay {
|
||||||
|
RoundedRectangle(cornerRadius: 24, style: .continuous)
|
||||||
|
.strokeBorder(.red.opacity(0.24), lineWidth: 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var scheduledStateCard: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 14) {
|
||||||
|
Label("Feeds Not Posted Yet", systemImage: "calendar")
|
||||||
|
.font(.system(size: 16, weight: .bold))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
|
||||||
|
Text("Streams usually appear closer to first pitch. Check back around game time.")
|
||||||
|
.font(.system(size: 17, weight: .medium))
|
||||||
|
.foregroundStyle(.white.opacity(0.72))
|
||||||
|
.lineLimit(3)
|
||||||
|
|
||||||
|
if let venue = game.venue {
|
||||||
|
Text(venue)
|
||||||
|
.font(.system(size: 14, weight: .semibold))
|
||||||
|
.foregroundStyle(.white.opacity(0.5))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.padding(24)
|
||||||
|
.background(panelBackground)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var fallbackActionsCard: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 16) {
|
||||||
|
Text("No Feeds Listed")
|
||||||
|
.font(.system(size: 24, weight: .bold, design: .rounded))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
|
||||||
|
Text("You can still try building a stream from one of the team feeds.")
|
||||||
|
.font(.system(size: 16, weight: .medium))
|
||||||
|
.foregroundStyle(.white.opacity(0.68))
|
||||||
|
|
||||||
|
VStack(spacing: 14) {
|
||||||
|
fallbackButton(teamCode: game.awayTeam.code)
|
||||||
|
fallbackButton(teamCode: game.homeTeam.code)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(22)
|
||||||
|
.background(panelBackground)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func fallbackButton(teamCode: String) -> some View {
|
||||||
|
railActionButton(
|
||||||
|
title: "Try \(teamCode) Feed",
|
||||||
|
subtitle: "Build a fallback stream for \(teamCode)",
|
||||||
|
systemImage: "play.circle.fill",
|
||||||
|
fill: .white.opacity(0.08),
|
||||||
|
disabled: !canAddMoreStreams
|
||||||
|
) {
|
||||||
|
viewModel.addStreamByTeam(teamCode: teamCode, game: game)
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func railActionButton(
|
||||||
|
title: String,
|
||||||
|
subtitle: String,
|
||||||
|
systemImage: String,
|
||||||
|
fill: Color,
|
||||||
|
foreground: Color = .white,
|
||||||
|
disabled: Bool = false,
|
||||||
|
action: @escaping () -> Void
|
||||||
|
) -> some View {
|
||||||
|
Button(action: action) {
|
||||||
|
HStack(alignment: .center, spacing: 14) {
|
||||||
|
Image(systemName: systemImage)
|
||||||
|
.font(.system(size: 20, weight: .bold))
|
||||||
|
.frame(width: 28)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text(title)
|
||||||
|
.font(.system(size: 18, weight: .bold, design: .rounded))
|
||||||
|
.foregroundStyle(foreground)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
|
||||||
|
Text(subtitle)
|
||||||
|
.font(.system(size: 13, weight: .medium))
|
||||||
|
.foregroundStyle(foreground.opacity(0.64))
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, minHeight: 84, alignment: .leading)
|
||||||
|
.padding(.horizontal, 20)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 20, style: .continuous)
|
||||||
|
.fill(fill)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.buttonStyle(.card)
|
||||||
|
.disabled(disabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var statusChip: some View {
|
||||||
|
switch game.status {
|
||||||
|
case .live(let inning):
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Circle()
|
||||||
|
.fill(.red)
|
||||||
|
.frame(width: 8, height: 8)
|
||||||
|
Text(inning ?? "LIVE")
|
||||||
|
.font(.system(size: 15, weight: .black, design: .rounded))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 15)
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
.background(.red.opacity(0.16))
|
||||||
|
.clipShape(Capsule())
|
||||||
|
|
||||||
|
case .scheduled(let time):
|
||||||
|
Text(time)
|
||||||
|
.font(.system(size: 15, weight: .black, design: .rounded))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.padding(.horizontal, 15)
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
.background(.white.opacity(0.1))
|
||||||
|
.clipShape(Capsule())
|
||||||
|
|
||||||
|
case .final_:
|
||||||
|
Text("FINAL")
|
||||||
|
.font(.system(size: 15, weight: .black, design: .rounded))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.padding(.horizontal, 15)
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
.background(.white.opacity(0.12))
|
||||||
|
.clipShape(Capsule())
|
||||||
|
|
||||||
|
case .unknown:
|
||||||
|
EmptyView()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func chip(title: String, color: Color) -> some View {
|
||||||
|
Text(title)
|
||||||
|
.font(.system(size: 13, weight: .bold, design: .rounded))
|
||||||
|
.foregroundStyle(color)
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 9)
|
||||||
|
.background(color.opacity(0.12))
|
||||||
|
.clipShape(Capsule())
|
||||||
|
}
|
||||||
|
|
||||||
|
private var scoreSummaryText: String? {
|
||||||
|
guard let away = game.awayTeam.score, let home = game.homeTeam.score else { return nil }
|
||||||
|
return "\(away) - \(home)"
|
||||||
|
}
|
||||||
|
|
||||||
|
private var statusHeadline: String {
|
||||||
|
switch game.status {
|
||||||
|
case .scheduled:
|
||||||
|
return "First Pitch"
|
||||||
|
case .live(let inning):
|
||||||
|
return inning ?? "Live"
|
||||||
|
case .final_:
|
||||||
|
return "Final"
|
||||||
|
case .unknown:
|
||||||
|
return "Game Day"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var statusHeadlineColor: Color {
|
||||||
|
switch game.status {
|
||||||
|
case .live:
|
||||||
|
return .red.opacity(0.92)
|
||||||
|
case .scheduled:
|
||||||
|
return .blue.opacity(0.92)
|
||||||
|
case .final_:
|
||||||
|
return .white.opacity(0.82)
|
||||||
|
case .unknown:
|
||||||
|
return .white.opacity(0.72)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var centerDetailText: String? {
|
||||||
|
if game.status.isScheduled {
|
||||||
|
return game.pitchers ?? game.venue
|
||||||
|
}
|
||||||
|
return game.pitchers ?? game.venue
|
||||||
|
}
|
||||||
|
|
||||||
|
private var actionRailSubtitle: String {
|
||||||
|
if game.isBlackedOut {
|
||||||
|
return "Watch controls are unavailable for this matchup."
|
||||||
|
}
|
||||||
|
if !game.broadcasts.isEmpty {
|
||||||
|
return "Pick a feed to watch full screen or send it into Multi-View."
|
||||||
|
}
|
||||||
|
if game.status.isScheduled {
|
||||||
|
return "Streams normally appear closer to game time."
|
||||||
|
}
|
||||||
|
return "Try a team feed fallback for this matchup."
|
||||||
|
}
|
||||||
|
|
||||||
|
private var linescoreStatusText: String {
|
||||||
|
if let inning = game.currentInningDisplay, !inning.isEmpty {
|
||||||
|
return inning.uppercased()
|
||||||
|
}
|
||||||
|
if game.isFinal {
|
||||||
|
return "FINAL"
|
||||||
|
}
|
||||||
|
return "GAME"
|
||||||
|
}
|
||||||
|
|
||||||
|
private func teamPanelBackground(color: Color) -> some ShapeStyle {
|
||||||
|
LinearGradient(
|
||||||
|
colors: [
|
||||||
|
color.opacity(0.22),
|
||||||
|
.white.opacity(0.05)
|
||||||
|
],
|
||||||
|
startPoint: .leading,
|
||||||
|
endPoint: .trailing
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var panelBackground: some View {
|
||||||
|
RoundedRectangle(cornerRadius: 24, style: .continuous)
|
||||||
|
.fill(.black.opacity(0.22))
|
||||||
|
.overlay {
|
||||||
|
RoundedRectangle(cornerRadius: 24, style: .continuous)
|
||||||
|
.strokeBorder(.white.opacity(0.08), lineWidth: 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var sheetBackground: some View {
|
||||||
|
ZStack {
|
||||||
|
LinearGradient(
|
||||||
|
colors: [
|
||||||
|
Color(red: 0.05, green: 0.06, blue: 0.1),
|
||||||
|
Color(red: 0.08, green: 0.06, blue: 0.09)
|
||||||
|
],
|
||||||
|
startPoint: .topLeading,
|
||||||
|
endPoint: .bottomTrailing
|
||||||
|
)
|
||||||
|
|
||||||
|
Circle()
|
||||||
|
.fill(awayColor.opacity(0.16))
|
||||||
|
.frame(width: 520, height: 520)
|
||||||
|
.blur(radius: 90)
|
||||||
|
.offset(x: -360, y: -220)
|
||||||
|
|
||||||
|
Circle()
|
||||||
|
.fill(homeColor.opacity(0.18))
|
||||||
|
.frame(width: 520, height: 520)
|
||||||
|
.blur(radius: 92)
|
||||||
|
.offset(x: 420, y: 120)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
665
mlbTVOS/Views/LeagueCenterView.swift
Normal file
665
mlbTVOS/Views/LeagueCenterView.swift
Normal file
@@ -0,0 +1,665 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct LeagueCenterView: View {
|
||||||
|
@Environment(GamesViewModel.self) private var gamesViewModel
|
||||||
|
@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),
|
||||||
|
]
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ScrollView {
|
||||||
|
VStack(alignment: .leading, spacing: 34) {
|
||||||
|
header
|
||||||
|
|
||||||
|
if let overviewErrorMessage = viewModel.overviewErrorMessage {
|
||||||
|
messagePanel(overviewErrorMessage, tint: .orange)
|
||||||
|
}
|
||||||
|
|
||||||
|
scheduleSection
|
||||||
|
standingsSection
|
||||||
|
teamsSection
|
||||||
|
|
||||||
|
if let selectedTeam = viewModel.selectedTeam {
|
||||||
|
teamProfileSection(team: selectedTeam)
|
||||||
|
} else if let teamErrorMessage = viewModel.teamErrorMessage {
|
||||||
|
messagePanel(teamErrorMessage, tint: .orange)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !viewModel.roster.isEmpty {
|
||||||
|
rosterSection
|
||||||
|
}
|
||||||
|
|
||||||
|
if let player = viewModel.selectedPlayer {
|
||||||
|
playerSection(player: player)
|
||||||
|
} else if let playerErrorMessage = viewModel.playerErrorMessage {
|
||||||
|
messagePanel(playerErrorMessage, tint: .orange)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 56)
|
||||||
|
.padding(.vertical, 40)
|
||||||
|
}
|
||||||
|
.background(screenBackground.ignoresSafeArea())
|
||||||
|
.task {
|
||||||
|
await viewModel.loadInitial()
|
||||||
|
}
|
||||||
|
.sheet(item: $selectedGame) { game in
|
||||||
|
StreamOptionsSheet(game: game)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var header: some View {
|
||||||
|
HStack(alignment: .top, spacing: 24) {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text("Around MLB")
|
||||||
|
.font(.system(size: 42, weight: .bold, design: .rounded))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
|
||||||
|
Text("Schedules, standings, team context, roster access, and player snapshots in one control room.")
|
||||||
|
.font(.system(size: 16, weight: .medium))
|
||||||
|
.foregroundStyle(.white.opacity(0.58))
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
infoPill(title: "\(viewModel.scheduleGames.count)", label: "Games", color: .blue)
|
||||||
|
infoPill(title: "\(viewModel.teams.count)", label: "Teams", color: .green)
|
||||||
|
infoPill(title: "\(viewModel.standings.count)", label: "Divisions", color: .orange)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var scheduleSection: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 18) {
|
||||||
|
HStack {
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
Text("Schedule")
|
||||||
|
.font(.system(size: 30, weight: .bold, design: .rounded))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
|
||||||
|
Text(viewModel.displayDateString)
|
||||||
|
.font(.system(size: 16, weight: .medium))
|
||||||
|
.foregroundStyle(.white.opacity(0.55))
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
dateButton(title: "Previous", systemImage: "chevron.left") {
|
||||||
|
Task { await viewModel.goToPreviousDay() }
|
||||||
|
}
|
||||||
|
|
||||||
|
dateButton(title: "Today", systemImage: "calendar") {
|
||||||
|
Task { await viewModel.goToToday() }
|
||||||
|
}
|
||||||
|
|
||||||
|
dateButton(title: "Next", systemImage: "chevron.right") {
|
||||||
|
Task { await viewModel.goToNextDay() }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if viewModel.scheduleGames.isEmpty && viewModel.isLoadingOverview {
|
||||||
|
loadingPanel(title: "Loading schedule...")
|
||||||
|
} else {
|
||||||
|
VStack(spacing: 14) {
|
||||||
|
ForEach(viewModel.scheduleGames.prefix(10)) { game in
|
||||||
|
scheduleRow(game)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func scheduleRow(_ game: StatsGame) -> some View {
|
||||||
|
let linkedGame = gamesViewModel.games.first { $0.gamePk == String(game.gamePk) }
|
||||||
|
|
||||||
|
return Button {
|
||||||
|
if let linkedGame {
|
||||||
|
selectedGame = linkedGame
|
||||||
|
}
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: 18) {
|
||||||
|
teamMiniColumn(team: game.teams.away)
|
||||||
|
VStack(spacing: 6) {
|
||||||
|
Text(scoreText(for: game))
|
||||||
|
.font(.system(size: 28, weight: .black, design: .rounded))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.monospacedDigit()
|
||||||
|
|
||||||
|
Text(statusText(for: game))
|
||||||
|
.font(.system(size: 14, weight: .semibold))
|
||||||
|
.foregroundStyle(statusColor(for: game))
|
||||||
|
}
|
||||||
|
.frame(width: 160)
|
||||||
|
|
||||||
|
teamMiniColumn(team: game.teams.home, alignTrailing: true)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
VStack(alignment: .trailing, spacing: 6) {
|
||||||
|
if let venue = game.venue?.name {
|
||||||
|
Text(venue)
|
||||||
|
.font(.system(size: 14, weight: .medium))
|
||||||
|
.foregroundStyle(.white.opacity(0.56))
|
||||||
|
.lineLimit(1)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(linkedGame != nil ? "Open game sheet" : "Info only")
|
||||||
|
.font(.system(size: 13, weight: .bold, design: .rounded))
|
||||||
|
.foregroundStyle(linkedGame != nil ? .blue.opacity(0.95) : .white.opacity(0.34))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(22)
|
||||||
|
.background(sectionPanel)
|
||||||
|
}
|
||||||
|
.buttonStyle(.card)
|
||||||
|
.disabled(linkedGame == nil)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func teamMiniColumn(team: StatsTeamGameInfo, alignTrailing: Bool = false) -> some View {
|
||||||
|
let info = TeamInfo(
|
||||||
|
code: team.team.abbreviation ?? "MLB",
|
||||||
|
name: team.team.name ?? "MLB",
|
||||||
|
score: team.score,
|
||||||
|
teamId: team.team.id,
|
||||||
|
record: team.leagueRecord.map { "\($0.wins)-\($0.losses)" }
|
||||||
|
)
|
||||||
|
|
||||||
|
return HStack(spacing: 12) {
|
||||||
|
if !alignTrailing {
|
||||||
|
TeamLogoView(team: info, size: 56)
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(alignment: alignTrailing ? .trailing : .leading, spacing: 6) {
|
||||||
|
Text(info.code)
|
||||||
|
.font(.system(size: 22, weight: .black, design: .rounded))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
|
||||||
|
Text(info.displayName)
|
||||||
|
.font(.system(size: 16, weight: .semibold))
|
||||||
|
.foregroundStyle(.white.opacity(0.7))
|
||||||
|
.lineLimit(1)
|
||||||
|
|
||||||
|
if let record = info.record {
|
||||||
|
Text(record)
|
||||||
|
.font(.system(size: 13, weight: .bold, design: .monospaced))
|
||||||
|
.foregroundStyle(.white.opacity(0.46))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if alignTrailing {
|
||||||
|
TeamLogoView(team: info, size: 56)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: alignTrailing ? .trailing : .leading)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var standingsSection: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 18) {
|
||||||
|
Text("Standings")
|
||||||
|
.font(.system(size: 30, weight: .bold, design: .rounded))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
|
||||||
|
if viewModel.standings.isEmpty && viewModel.isLoadingOverview {
|
||||||
|
loadingPanel(title: "Loading standings...")
|
||||||
|
} else {
|
||||||
|
ScrollView(.horizontal) {
|
||||||
|
HStack(spacing: 18) {
|
||||||
|
ForEach(viewModel.standings, id: \.division?.id) { record in
|
||||||
|
standingsCard(record)
|
||||||
|
.frame(width: 360)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
}
|
||||||
|
.focusSection()
|
||||||
|
.scrollClipDisabled()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func standingsCard(_ record: StandingsDivisionRecord) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 14) {
|
||||||
|
Text(record.division?.name ?? "Division")
|
||||||
|
.font(.system(size: 22, weight: .bold, design: .rounded))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
|
||||||
|
VStack(spacing: 10) {
|
||||||
|
ForEach(record.teamRecords.sorted(by: standingsSort), id: \.team.id) { team in
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
Text(team.divisionRank ?? "-")
|
||||||
|
.font(.system(size: 12, weight: .black, design: .rounded))
|
||||||
|
.foregroundStyle(.white.opacity(0.52))
|
||||||
|
.frame(width: 22)
|
||||||
|
|
||||||
|
Text(team.team.abbreviation ?? team.team.name ?? "MLB")
|
||||||
|
.font(.system(size: 16, weight: .bold, design: .rounded))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
if let wins = team.wins, let losses = team.losses {
|
||||||
|
Text("\(wins)-\(losses)")
|
||||||
|
.font(.system(size: 15, weight: .bold, design: .monospaced))
|
||||||
|
.foregroundStyle(.white.opacity(0.86))
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(team.gamesBack ?? "-")
|
||||||
|
.font(.system(size: 14, weight: .semibold))
|
||||||
|
.foregroundStyle(.white.opacity(0.45))
|
||||||
|
.frame(width: 44, alignment: .trailing)
|
||||||
|
}
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(22)
|
||||||
|
.background(sectionPanel)
|
||||||
|
}
|
||||||
|
|
||||||
|
private var teamsSection: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 18) {
|
||||||
|
Text("Teams")
|
||||||
|
.font(.system(size: 30, weight: .bold, design: .rounded))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
|
||||||
|
ScrollView(.horizontal) {
|
||||||
|
HStack(spacing: 16) {
|
||||||
|
ForEach(viewModel.teams) { team in
|
||||||
|
Button {
|
||||||
|
Task { await viewModel.selectTeam(team.id) }
|
||||||
|
} label: {
|
||||||
|
VStack(alignment: .leading, spacing: 14) {
|
||||||
|
TeamLogoView(team: team.teamInfo, size: 72)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
Text(team.abbreviation)
|
||||||
|
.font(.system(size: 20, weight: .black, design: .rounded))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
|
||||||
|
Text(team.name)
|
||||||
|
.font(.system(size: 16, weight: .semibold))
|
||||||
|
.foregroundStyle(.white.opacity(0.76))
|
||||||
|
.lineLimit(2)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Text(team.recordText ?? "Season")
|
||||||
|
.font(.system(size: 13, weight: .bold, design: .monospaced))
|
||||||
|
.foregroundStyle(.white.opacity(0.5))
|
||||||
|
}
|
||||||
|
.frame(width: 210, height: 220, alignment: .leading)
|
||||||
|
.padding(18)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 24, style: .continuous)
|
||||||
|
.fill(TeamAssets.color(for: team.abbreviation).opacity(0.16))
|
||||||
|
)
|
||||||
|
.overlay(
|
||||||
|
RoundedRectangle(cornerRadius: 24, style: .continuous)
|
||||||
|
.stroke(.white.opacity(0.08), lineWidth: 1)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.buttonStyle(.card)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(.vertical, 4)
|
||||||
|
}
|
||||||
|
.focusSection()
|
||||||
|
.scrollClipDisabled()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func teamProfileSection(team: TeamProfile) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 18) {
|
||||||
|
Text("Team Profile")
|
||||||
|
.font(.system(size: 30, weight: .bold, design: .rounded))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
|
||||||
|
if viewModel.isLoadingTeam {
|
||||||
|
loadingPanel(title: "Loading team profile...")
|
||||||
|
} else {
|
||||||
|
HStack(alignment: .top, spacing: 24) {
|
||||||
|
TeamLogoView(team: team.teamInfo, size: 96)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
Text(team.name)
|
||||||
|
.font(.system(size: 34, weight: .bold, design: .rounded))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
detailChip(team.recordText ?? "Season", color: .blue)
|
||||||
|
if let gamesBack = team.gamesBackText {
|
||||||
|
detailChip(gamesBack, color: .orange)
|
||||||
|
}
|
||||||
|
if let division = team.divisionName {
|
||||||
|
detailChip(division, color: .green)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
profileLine(label: "Venue", value: team.venueName)
|
||||||
|
profileLine(label: "League", value: team.leagueName)
|
||||||
|
profileLine(label: "Franchise", value: team.franchiseName)
|
||||||
|
profileLine(label: "First Year", value: team.firstYearOfPlay)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(24)
|
||||||
|
.background(sectionPanel)
|
||||||
|
.contentShape(RoundedRectangle(cornerRadius: 24, style: .continuous))
|
||||||
|
.focusable(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var rosterSection: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 18) {
|
||||||
|
Text("Roster")
|
||||||
|
.font(.system(size: 30, weight: .bold, design: .rounded))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
|
||||||
|
LazyVGrid(columns: rosterColumns, spacing: 14) {
|
||||||
|
ForEach(viewModel.roster) { player in
|
||||||
|
Button {
|
||||||
|
Task { await viewModel.selectPlayer(player.id) }
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: 14) {
|
||||||
|
playerHeadshot(url: player.headshotURL, size: 58)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 6) {
|
||||||
|
Text(player.fullName)
|
||||||
|
.font(.system(size: 16, weight: .bold))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
.lineLimit(2)
|
||||||
|
|
||||||
|
Text("\(player.positionAbbreviation ?? "P") · #\(player.jerseyNumber ?? "--")")
|
||||||
|
.font(.system(size: 13, weight: .semibold))
|
||||||
|
.foregroundStyle(.white.opacity(0.5))
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(16)
|
||||||
|
.background(sectionPanel)
|
||||||
|
}
|
||||||
|
.buttonStyle(.card)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func playerSection(player: PlayerProfile) -> some View {
|
||||||
|
VStack(alignment: .leading, spacing: 18) {
|
||||||
|
Text("Player Profile")
|
||||||
|
.font(.system(size: 30, weight: .bold, design: .rounded))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
|
||||||
|
if viewModel.isLoadingPlayer {
|
||||||
|
loadingPanel(title: "Loading player profile...")
|
||||||
|
} else {
|
||||||
|
VStack(alignment: .leading, spacing: 20) {
|
||||||
|
HStack(alignment: .top, spacing: 24) {
|
||||||
|
playerHeadshot(url: player.headshotURL, size: 112)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 12) {
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
if let primaryNumber = player.primaryNumber {
|
||||||
|
Text("#\(primaryNumber)")
|
||||||
|
.font(.system(size: 15, weight: .black, design: .rounded))
|
||||||
|
.foregroundStyle(.white.opacity(0.7))
|
||||||
|
}
|
||||||
|
|
||||||
|
if let position = player.primaryPosition {
|
||||||
|
detailChip(position, color: .blue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(player.fullName)
|
||||||
|
.font(.system(size: 34, weight: .bold, design: .rounded))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
profileLine(label: "Age", value: player.currentAge.map(String.init))
|
||||||
|
profileLine(label: "Bats / Throws", value: [player.bats, player.throwsHand].compactMap { $0 }.joined(separator: " / "))
|
||||||
|
profileLine(label: "Height / Weight", value: [player.height, player.weight.map { "\($0) lbs" }].compactMap { $0 }.joined(separator: " / "))
|
||||||
|
profileLine(label: "Born", value: player.birthPlace)
|
||||||
|
profileLine(label: "Debut", value: player.debutDate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
|
||||||
|
if !player.statGroups.isEmpty {
|
||||||
|
HStack(alignment: .top, spacing: 16) {
|
||||||
|
ForEach(player.statGroups) { group in
|
||||||
|
VStack(alignment: .leading, spacing: 14) {
|
||||||
|
Text("\(group.title) \(player.seasonLabel)")
|
||||||
|
.font(.system(size: 18, weight: .bold, design: .rounded))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
ForEach(group.items, id: \.label) { item in
|
||||||
|
HStack {
|
||||||
|
Text(item.label)
|
||||||
|
.font(.system(size: 13, weight: .bold, design: .rounded))
|
||||||
|
.foregroundStyle(.white.opacity(0.5))
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
Text(item.value)
|
||||||
|
.font(.system(size: 18, weight: .black, design: .rounded))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.padding(18)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 20, style: .continuous)
|
||||||
|
.fill(.white.opacity(0.05))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Text("No regular-season MLB stats available for \(player.seasonLabel).")
|
||||||
|
.font(.system(size: 18, weight: .semibold, design: .rounded))
|
||||||
|
.foregroundStyle(.white.opacity(0.7))
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.padding(.top, 4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(24)
|
||||||
|
.background(sectionPanel)
|
||||||
|
.contentShape(RoundedRectangle(cornerRadius: 24, style: .continuous))
|
||||||
|
.focusable(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func playerHeadshot(url: URL, size: CGFloat) -> some View {
|
||||||
|
AsyncImage(url: url) { phase in
|
||||||
|
switch phase {
|
||||||
|
case .success(let image):
|
||||||
|
image
|
||||||
|
.resizable()
|
||||||
|
.scaledToFill()
|
||||||
|
default:
|
||||||
|
ZStack {
|
||||||
|
Circle()
|
||||||
|
.fill(.white.opacity(0.08))
|
||||||
|
Image(systemName: "person.fill")
|
||||||
|
.font(.system(size: size * 0.34, weight: .bold))
|
||||||
|
.foregroundStyle(.white.opacity(0.32))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(width: size, height: size)
|
||||||
|
.clipShape(Circle())
|
||||||
|
.overlay(
|
||||||
|
Circle()
|
||||||
|
.stroke(.white.opacity(0.08), lineWidth: 1)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func profileLine(label: String, value: String?) -> some View {
|
||||||
|
let displayValue: String
|
||||||
|
if let value, !value.isEmpty {
|
||||||
|
displayValue = value
|
||||||
|
} else {
|
||||||
|
displayValue = "Unavailable"
|
||||||
|
}
|
||||||
|
|
||||||
|
return HStack(spacing: 10) {
|
||||||
|
Text(label)
|
||||||
|
.font(.system(size: 13, weight: .bold, design: .rounded))
|
||||||
|
.foregroundStyle(.white.opacity(0.45))
|
||||||
|
.frame(width: 92, alignment: .leading)
|
||||||
|
|
||||||
|
Text(displayValue)
|
||||||
|
.font(.system(size: 15, weight: .semibold))
|
||||||
|
.foregroundStyle(.white.opacity(0.82))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func messagePanel(_ text: String, tint: Color) -> some View {
|
||||||
|
Text(text)
|
||||||
|
.font(.system(size: 15, weight: .semibold))
|
||||||
|
.foregroundStyle(tint.opacity(0.95))
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.padding(18)
|
||||||
|
.background(sectionPanel)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func detailChip(_ title: String, color: Color) -> some View {
|
||||||
|
Text(title)
|
||||||
|
.font(.system(size: 13, weight: .bold, design: .rounded))
|
||||||
|
.foregroundStyle(color.opacity(0.95))
|
||||||
|
.padding(.horizontal, 12)
|
||||||
|
.padding(.vertical, 8)
|
||||||
|
.background(color.opacity(0.12))
|
||||||
|
.clipShape(Capsule())
|
||||||
|
}
|
||||||
|
|
||||||
|
private func infoPill(title: String, label: String, color: Color) -> some View {
|
||||||
|
VStack(spacing: 4) {
|
||||||
|
Text(title)
|
||||||
|
.font(.system(size: 22, weight: .black, design: .rounded))
|
||||||
|
.foregroundStyle(color)
|
||||||
|
.monospacedDigit()
|
||||||
|
Text(label)
|
||||||
|
.font(.system(size: 12, weight: .semibold))
|
||||||
|
.foregroundStyle(.white.opacity(0.5))
|
||||||
|
}
|
||||||
|
.padding(.horizontal, 18)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
.background(sectionPanel)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func dateButton(title: String, systemImage: String, action: @escaping () -> Void) -> some View {
|
||||||
|
Button(action: action) {
|
||||||
|
HStack(spacing: 8) {
|
||||||
|
Image(systemName: systemImage)
|
||||||
|
.font(.system(size: 12, weight: .bold))
|
||||||
|
Text(title)
|
||||||
|
.font(.system(size: 14, weight: .semibold))
|
||||||
|
}
|
||||||
|
.foregroundStyle(.white.opacity(0.84))
|
||||||
|
.padding(.horizontal, 14)
|
||||||
|
.padding(.vertical, 10)
|
||||||
|
.background(.white.opacity(0.08))
|
||||||
|
.clipShape(Capsule())
|
||||||
|
}
|
||||||
|
.buttonStyle(.card)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func loadingPanel(title: String) -> some View {
|
||||||
|
HStack {
|
||||||
|
Spacer()
|
||||||
|
ProgressView(title)
|
||||||
|
.font(.system(size: 16, weight: .semibold))
|
||||||
|
.foregroundStyle(.white.opacity(0.75))
|
||||||
|
Spacer()
|
||||||
|
}
|
||||||
|
.padding(.vertical, 34)
|
||||||
|
.background(sectionPanel)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func scoreText(for game: StatsGame) -> String {
|
||||||
|
if game.isScheduled {
|
||||||
|
return game.startTime ?? "TBD"
|
||||||
|
}
|
||||||
|
let away = game.teams.away.score ?? 0
|
||||||
|
let home = game.teams.home.score ?? 0
|
||||||
|
return "\(away) - \(home)"
|
||||||
|
}
|
||||||
|
|
||||||
|
private func statusText(for game: StatsGame) -> String {
|
||||||
|
if game.isLive {
|
||||||
|
return game.linescore?.currentInningDisplay ?? "Live"
|
||||||
|
}
|
||||||
|
if game.isFinal {
|
||||||
|
return "Final"
|
||||||
|
}
|
||||||
|
return game.status.detailedState ?? "Scheduled"
|
||||||
|
}
|
||||||
|
|
||||||
|
private func statusColor(for game: StatsGame) -> Color {
|
||||||
|
if game.isLive {
|
||||||
|
return .red.opacity(0.9)
|
||||||
|
}
|
||||||
|
if game.isFinal {
|
||||||
|
return .white.opacity(0.72)
|
||||||
|
}
|
||||||
|
return .blue.opacity(0.9)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func standingsSort(lhs: StandingsTeamRecord, rhs: StandingsTeamRecord) -> Bool {
|
||||||
|
Int(lhs.divisionRank ?? "99") ?? 99 < Int(rhs.divisionRank ?? "99") ?? 99
|
||||||
|
}
|
||||||
|
|
||||||
|
private var sectionPanel: some View {
|
||||||
|
RoundedRectangle(cornerRadius: 24, style: .continuous)
|
||||||
|
.fill(.black.opacity(0.22))
|
||||||
|
.overlay {
|
||||||
|
RoundedRectangle(cornerRadius: 24, style: .continuous)
|
||||||
|
.strokeBorder(.white.opacity(0.08), lineWidth: 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var screenBackground: some View {
|
||||||
|
ZStack {
|
||||||
|
LinearGradient(
|
||||||
|
colors: [
|
||||||
|
Color(red: 0.04, green: 0.05, blue: 0.09),
|
||||||
|
Color(red: 0.03, green: 0.06, blue: 0.1),
|
||||||
|
Color(red: 0.02, green: 0.03, blue: 0.06),
|
||||||
|
],
|
||||||
|
startPoint: .topLeading,
|
||||||
|
endPoint: .bottomTrailing
|
||||||
|
)
|
||||||
|
|
||||||
|
Circle()
|
||||||
|
.fill(.blue.opacity(0.18))
|
||||||
|
.frame(width: 520, height: 520)
|
||||||
|
.blur(radius: 110)
|
||||||
|
.offset(x: -360, y: -260)
|
||||||
|
|
||||||
|
Circle()
|
||||||
|
.fill(.orange.opacity(0.16))
|
||||||
|
.frame(width: 560, height: 560)
|
||||||
|
.blur(radius: 120)
|
||||||
|
.offset(x: 420, y: -80)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
294
mlbTVOS/Views/MLBNetworkSheet.swift
Normal file
294
mlbTVOS/Views/MLBNetworkSheet.swift
Normal file
@@ -0,0 +1,294 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct MLBNetworkSheet: View {
|
||||||
|
var onWatchFullScreen: () -> Void
|
||||||
|
@Environment(GamesViewModel.self) private var viewModel
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
|
||||||
|
private var added: Bool {
|
||||||
|
viewModel.activeStreams.contains(where: { $0.id == "MLBN" })
|
||||||
|
}
|
||||||
|
|
||||||
|
private var canToggleIntoMultiview: Bool {
|
||||||
|
added || viewModel.activeStreams.count < 4
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
sheetBackground
|
||||||
|
.ignoresSafeArea()
|
||||||
|
|
||||||
|
HStack(alignment: .top, spacing: 32) {
|
||||||
|
networkOverview
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
|
||||||
|
actionColumn
|
||||||
|
.frame(width: 360, alignment: .leading)
|
||||||
|
}
|
||||||
|
.padding(38)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 34, style: .continuous)
|
||||||
|
.fill(.black.opacity(0.46))
|
||||||
|
.overlay {
|
||||||
|
RoundedRectangle(cornerRadius: 34, style: .continuous)
|
||||||
|
.strokeBorder(.white.opacity(0.08), lineWidth: 1)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.padding(.horizontal, 94)
|
||||||
|
.padding(.vertical, 70)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var networkOverview: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 28) {
|
||||||
|
VStack(alignment: .leading, spacing: 18) {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
statusPill(title: "LIVE CHANNEL", color: .blue)
|
||||||
|
|
||||||
|
if added {
|
||||||
|
statusPill(title: "IN MULTI-VIEW", color: .green)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack(alignment: .center, spacing: 22) {
|
||||||
|
ZStack {
|
||||||
|
RoundedRectangle(cornerRadius: 26, style: .continuous)
|
||||||
|
.fill(.blue.opacity(0.16))
|
||||||
|
.frame(width: 110, height: 110)
|
||||||
|
|
||||||
|
Image(systemName: "tv.fill")
|
||||||
|
.font(.system(size: 46, weight: .bold))
|
||||||
|
.foregroundStyle(
|
||||||
|
LinearGradient(
|
||||||
|
colors: [.white, .blue.opacity(0.88)],
|
||||||
|
startPoint: .top,
|
||||||
|
endPoint: .bottom
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
Text("MLB Network")
|
||||||
|
.font(.system(size: 46, weight: .black, design: .rounded))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
|
||||||
|
Text("Live coverage, studio hits, whip-around looks, analysis, and highlights all day.")
|
||||||
|
.font(.system(size: 20, weight: .medium))
|
||||||
|
.foregroundStyle(.white.opacity(0.72))
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack(spacing: 14) {
|
||||||
|
featurePill(title: "Live Look-Ins", systemImage: "dot.radiowaves.left.and.right")
|
||||||
|
featurePill(title: "Studio Analysis", systemImage: "waveform.path.ecg.rectangle")
|
||||||
|
featurePill(title: "Highlights", systemImage: "sparkles.tv")
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 14) {
|
||||||
|
Text("Best For")
|
||||||
|
.font(.system(size: 16, weight: .bold, design: .rounded))
|
||||||
|
.foregroundStyle(.white.opacity(0.48))
|
||||||
|
.kerning(1.2)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 14) {
|
||||||
|
overviewLine(
|
||||||
|
icon: "rectangle.stack.badge.play.fill",
|
||||||
|
title: "Background baseball TV",
|
||||||
|
detail: "Keep a league-wide live channel running while you browse or build Multi-View."
|
||||||
|
)
|
||||||
|
overviewLine(
|
||||||
|
icon: "sportscourt.fill",
|
||||||
|
title: "Between-game coverage",
|
||||||
|
detail: "Use it when you want updates, reactions, and cut-ins instead of one locked game feed."
|
||||||
|
)
|
||||||
|
overviewLine(
|
||||||
|
icon: "square.grid.2x2.fill",
|
||||||
|
title: "Multi-View filler stream",
|
||||||
|
detail: "Drop it into an open tile to keep the board full when only one or two games matter."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(24)
|
||||||
|
.background(panelBackground)
|
||||||
|
|
||||||
|
if !canToggleIntoMultiview {
|
||||||
|
Label(
|
||||||
|
"Multi-View is full. Remove a stream before adding MLB Network.",
|
||||||
|
systemImage: "rectangle.split.2x2.fill"
|
||||||
|
)
|
||||||
|
.font(.system(size: 16, weight: .semibold))
|
||||||
|
.foregroundStyle(.orange)
|
||||||
|
.padding(.horizontal, 18)
|
||||||
|
.padding(.vertical, 16)
|
||||||
|
.background(panelBackground)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var actionColumn: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 18) {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text("Actions")
|
||||||
|
.font(.system(size: 28, weight: .bold, design: .rounded))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
|
||||||
|
Text("Launch MLB Network full screen or place it in your Multi-View lineup.")
|
||||||
|
.font(.system(size: 16, weight: .medium))
|
||||||
|
.foregroundStyle(.white.opacity(0.68))
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
|
.padding(24)
|
||||||
|
.background(panelBackground)
|
||||||
|
|
||||||
|
actionButton(
|
||||||
|
title: "Watch Full Screen",
|
||||||
|
subtitle: "Open the channel in the main player",
|
||||||
|
systemImage: "play.fill",
|
||||||
|
fill: .blue.opacity(0.18)
|
||||||
|
) {
|
||||||
|
onWatchFullScreen()
|
||||||
|
}
|
||||||
|
|
||||||
|
actionButton(
|
||||||
|
title: added ? "Remove From Multi-View" : "Add to Multi-View",
|
||||||
|
subtitle: added ? "Take MLB Network out of your active grid" : "Send MLB Network into an open tile",
|
||||||
|
systemImage: added ? "minus.circle.fill" : "plus.circle.fill",
|
||||||
|
fill: added ? .red.opacity(0.16) : .white.opacity(0.08),
|
||||||
|
foreground: added ? .red : .white,
|
||||||
|
disabled: !canToggleIntoMultiview
|
||||||
|
) {
|
||||||
|
if added {
|
||||||
|
viewModel.removeStream(id: "MLBN")
|
||||||
|
} else {
|
||||||
|
Task { await viewModel.addMLBNetwork() }
|
||||||
|
}
|
||||||
|
dismiss()
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func actionButton(
|
||||||
|
title: String,
|
||||||
|
subtitle: String,
|
||||||
|
systemImage: String,
|
||||||
|
fill: Color,
|
||||||
|
foreground: Color = .white,
|
||||||
|
disabled: Bool = false,
|
||||||
|
action: @escaping () -> Void
|
||||||
|
) -> some View {
|
||||||
|
Button(action: action) {
|
||||||
|
HStack(alignment: .center, spacing: 16) {
|
||||||
|
Image(systemName: systemImage)
|
||||||
|
.font(.system(size: 22, weight: .bold))
|
||||||
|
.frame(width: 32)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 5) {
|
||||||
|
Text(title)
|
||||||
|
.font(.system(size: 21, weight: .bold, design: .rounded))
|
||||||
|
|
||||||
|
Text(subtitle)
|
||||||
|
.font(.system(size: 14, weight: .medium))
|
||||||
|
.foregroundStyle(foreground.opacity(0.68))
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
}
|
||||||
|
.foregroundStyle(foreground)
|
||||||
|
.frame(maxWidth: .infinity, minHeight: 88, alignment: .leading)
|
||||||
|
.padding(.horizontal, 22)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 24, style: .continuous)
|
||||||
|
.fill(fill)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.buttonStyle(.card)
|
||||||
|
.disabled(disabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func overviewLine(icon: String, title: String, detail: String) -> some View {
|
||||||
|
HStack(alignment: .top, spacing: 14) {
|
||||||
|
Image(systemName: icon)
|
||||||
|
.font(.system(size: 18, weight: .bold))
|
||||||
|
.foregroundStyle(.blue.opacity(0.9))
|
||||||
|
.frame(width: 26)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text(title)
|
||||||
|
.font(.system(size: 18, weight: .bold))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
|
||||||
|
Text(detail)
|
||||||
|
.font(.system(size: 15, weight: .medium))
|
||||||
|
.foregroundStyle(.white.opacity(0.66))
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func statusPill(title: String, color: Color) -> some View {
|
||||||
|
Text(title)
|
||||||
|
.font(.system(size: 13, weight: .black, design: .rounded))
|
||||||
|
.foregroundStyle(color)
|
||||||
|
.padding(.horizontal, 13)
|
||||||
|
.padding(.vertical, 9)
|
||||||
|
.background(color.opacity(0.14))
|
||||||
|
.clipShape(Capsule())
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func featurePill(title: String, systemImage: String) -> some View {
|
||||||
|
Label(title, systemImage: systemImage)
|
||||||
|
.font(.system(size: 15, weight: .semibold))
|
||||||
|
.foregroundStyle(.white.opacity(0.84))
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
.background(.white.opacity(0.06))
|
||||||
|
.clipShape(Capsule())
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var panelBackground: some View {
|
||||||
|
RoundedRectangle(cornerRadius: 28, style: .continuous)
|
||||||
|
.fill(.black.opacity(0.24))
|
||||||
|
.overlay {
|
||||||
|
RoundedRectangle(cornerRadius: 28, style: .continuous)
|
||||||
|
.strokeBorder(.white.opacity(0.08), lineWidth: 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var sheetBackground: some View {
|
||||||
|
ZStack {
|
||||||
|
LinearGradient(
|
||||||
|
colors: [
|
||||||
|
Color(red: 0.04, green: 0.05, blue: 0.08),
|
||||||
|
Color(red: 0.05, green: 0.07, blue: 0.11)
|
||||||
|
],
|
||||||
|
startPoint: .topLeading,
|
||||||
|
endPoint: .bottomTrailing
|
||||||
|
)
|
||||||
|
|
||||||
|
Circle()
|
||||||
|
.fill(.blue.opacity(0.22))
|
||||||
|
.frame(width: 540, height: 540)
|
||||||
|
.blur(radius: 100)
|
||||||
|
.offset(x: -280, y: -210)
|
||||||
|
|
||||||
|
Circle()
|
||||||
|
.fill(.cyan.opacity(0.14))
|
||||||
|
.frame(width: 460, height: 460)
|
||||||
|
.blur(radius: 100)
|
||||||
|
.offset(x: 350, y: 180)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
1257
mlbTVOS/Views/MultiStreamView.swift
Normal file
1257
mlbTVOS/Views/MultiStreamView.swift
Normal file
File diff suppressed because it is too large
Load Diff
73
mlbTVOS/Views/SettingsView.swift
Normal file
73
mlbTVOS/Views/SettingsView.swift
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
struct SettingsView: View {
|
||||||
|
@Environment(GamesViewModel.self) private var viewModel
|
||||||
|
|
||||||
|
private let resolutions = [
|
||||||
|
("best", "Best Quality"),
|
||||||
|
("1080p60", "1080p 60fps"),
|
||||||
|
("720p60", "720p 60fps"),
|
||||||
|
("540p", "540p"),
|
||||||
|
("adaptive", "Adaptive"),
|
||||||
|
]
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
@Bindable var vm = viewModel
|
||||||
|
|
||||||
|
NavigationStack {
|
||||||
|
Form {
|
||||||
|
Section("Server") {
|
||||||
|
LabeledContent("URL", value: viewModel.serverBaseURL)
|
||||||
|
}
|
||||||
|
|
||||||
|
Section("Default Quality") {
|
||||||
|
ForEach(resolutions, id: \.0) { res in
|
||||||
|
Button {
|
||||||
|
vm.defaultResolution = res.0
|
||||||
|
} label: {
|
||||||
|
HStack {
|
||||||
|
Text(res.1)
|
||||||
|
Spacer()
|
||||||
|
if viewModel.defaultResolution == res.0 {
|
||||||
|
Image(systemName: "checkmark")
|
||||||
|
.foregroundStyle(.blue)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Section("Active Streams (\(viewModel.activeStreams.count)/4)") {
|
||||||
|
if viewModel.activeStreams.isEmpty {
|
||||||
|
Text("No active streams")
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
} else {
|
||||||
|
ForEach(viewModel.activeStreams) { stream in
|
||||||
|
HStack {
|
||||||
|
Text(stream.label)
|
||||||
|
.fontWeight(.bold)
|
||||||
|
Text(stream.game.displayTitle)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
Spacer()
|
||||||
|
Button(role: .destructive) {
|
||||||
|
viewModel.removeStream(id: stream.id)
|
||||||
|
} label: {
|
||||||
|
Image(systemName: "trash")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Button("Clear All Streams", role: .destructive) {
|
||||||
|
viewModel.clearAllStreams()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Section("About") {
|
||||||
|
LabeledContent("Version", value: "1.0")
|
||||||
|
LabeledContent("Server", value: "mlbserver")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.navigationTitle("Settings")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
414
mlbTVOS/Views/SingleStreamPlayerView.swift
Normal file
414
mlbTVOS/Views/SingleStreamPlayerView.swift
Normal file
@@ -0,0 +1,414 @@
|
|||||||
|
import AVFoundation
|
||||||
|
import AVKit
|
||||||
|
import OSLog
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
private let singleStreamLogger = Logger(subsystem: "com.treyt.mlbTVOS", category: "SingleStreamPlayer")
|
||||||
|
|
||||||
|
private func logSingleStream(_ message: String) {
|
||||||
|
singleStreamLogger.debug("\(message, privacy: .public)")
|
||||||
|
print("[SingleStream] \(message)")
|
||||||
|
}
|
||||||
|
|
||||||
|
private func singleStreamDebugURLDescription(_ url: URL) -> String {
|
||||||
|
var host = url.host ?? "unknown-host"
|
||||||
|
if let port = url.port {
|
||||||
|
host += ":\(port)"
|
||||||
|
}
|
||||||
|
|
||||||
|
let queryKeys = URLComponents(url: url, resolvingAgainstBaseURL: false)?
|
||||||
|
.queryItems?
|
||||||
|
.map(\.name) ?? []
|
||||||
|
let querySuffix = queryKeys.isEmpty ? "" : "?\(queryKeys.joined(separator: "&"))"
|
||||||
|
|
||||||
|
return "\(url.scheme ?? "unknown")://\(host)\(url.path)\(querySuffix)"
|
||||||
|
}
|
||||||
|
|
||||||
|
private func singleStreamStatusDescription(_ status: AVPlayer.Status) -> String {
|
||||||
|
switch status {
|
||||||
|
case .unknown: "unknown"
|
||||||
|
case .readyToPlay: "readyToPlay"
|
||||||
|
case .failed: "failed"
|
||||||
|
@unknown default: "unknown-future"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func singleStreamItemStatusDescription(_ status: AVPlayerItem.Status) -> String {
|
||||||
|
switch status {
|
||||||
|
case .unknown: "unknown"
|
||||||
|
case .readyToPlay: "readyToPlay"
|
||||||
|
case .failed: "failed"
|
||||||
|
@unknown default: "unknown-future"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func singleStreamTimeControlDescription(_ status: AVPlayer.TimeControlStatus) -> String {
|
||||||
|
switch status {
|
||||||
|
case .paused: "paused"
|
||||||
|
case .waitingToPlayAtSpecifiedRate: "waitingToPlayAtSpecifiedRate"
|
||||||
|
case .playing: "playing"
|
||||||
|
@unknown default: "unknown-future"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct SingleStreamPlaybackScreen: View {
|
||||||
|
let resolveURL: @Sendable () async -> URL?
|
||||||
|
let tickerGames: [Game]
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack(alignment: .bottom) {
|
||||||
|
SingleStreamPlayerView(resolveURL: resolveURL)
|
||||||
|
.ignoresSafeArea()
|
||||||
|
|
||||||
|
SingleStreamScoreStripView(games: tickerGames)
|
||||||
|
.allowsHitTesting(false)
|
||||||
|
.padding(.horizontal, 18)
|
||||||
|
.padding(.bottom, 14)
|
||||||
|
}
|
||||||
|
.ignoresSafeArea()
|
||||||
|
.onAppear {
|
||||||
|
logSingleStream("SingleStreamPlaybackScreen appeared tickerGames=\(tickerGames.count) tickerMode=marqueeOverlay")
|
||||||
|
}
|
||||||
|
.onDisappear {
|
||||||
|
logSingleStream("SingleStreamPlaybackScreen disappeared")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct SingleStreamScoreStripView: View {
|
||||||
|
let games: [Game]
|
||||||
|
|
||||||
|
private var summaries: [String] {
|
||||||
|
games.map { game in
|
||||||
|
let matchup = "\(game.awayTeam.code) \(game.awayTeam.score.map(String.init) ?? "-") - \(game.homeTeam.code) \(game.homeTeam.score.map(String.init) ?? "-")"
|
||||||
|
|
||||||
|
if game.isLive, let linescore = game.linescore {
|
||||||
|
let inning = linescore.currentInningOrdinal ?? game.currentInningDisplay ?? "LIVE"
|
||||||
|
let state = (linescore.inningState ?? linescore.inningHalf ?? "Live").uppercased()
|
||||||
|
let outs = linescore.outs ?? 0
|
||||||
|
return "\(matchup) \(state) \(inning.uppercased()) • \(outs) OUT\(outs == 1 ? "" : "S")"
|
||||||
|
}
|
||||||
|
|
||||||
|
if game.isFinal {
|
||||||
|
return "\(matchup) FINAL"
|
||||||
|
}
|
||||||
|
|
||||||
|
if let startTime = game.startTime {
|
||||||
|
return "\(matchup) \(startTime.uppercased())"
|
||||||
|
}
|
||||||
|
|
||||||
|
return "\(matchup) \(game.status.label.uppercased())"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var stripText: String {
|
||||||
|
summaries.joined(separator: " | ")
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
if !games.isEmpty {
|
||||||
|
SingleStreamMarqueeView(text: stripText)
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.frame(height: 22)
|
||||||
|
.padding(.horizontal, 18)
|
||||||
|
.padding(.vertical, 14)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 18, style: .continuous)
|
||||||
|
.fill(.black.opacity(0.72))
|
||||||
|
.overlay {
|
||||||
|
RoundedRectangle(cornerRadius: 18, style: .continuous)
|
||||||
|
.strokeBorder(.white.opacity(0.08), lineWidth: 1)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.accessibilityHidden(true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private struct SingleStreamMarqueeView: UIViewRepresentable {
|
||||||
|
let text: String
|
||||||
|
|
||||||
|
func makeUIView(context: Context) -> SingleStreamMarqueeContainerView {
|
||||||
|
let view = SingleStreamMarqueeContainerView()
|
||||||
|
view.setText(text)
|
||||||
|
return view
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateUIView(_ uiView: SingleStreamMarqueeContainerView, context: Context) {
|
||||||
|
uiView.setText(text)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private final class SingleStreamMarqueeContainerView: UIView {
|
||||||
|
private let trackView = UIView()
|
||||||
|
private let primaryLabel = UILabel()
|
||||||
|
private let secondaryLabel = UILabel()
|
||||||
|
private let spacing: CGFloat = 48
|
||||||
|
private let pointsPerSecond: CGFloat = 64
|
||||||
|
|
||||||
|
private var currentText = ""
|
||||||
|
private var previousBoundsWidth: CGFloat = 0
|
||||||
|
private var previousContentWidth: CGFloat = 0
|
||||||
|
|
||||||
|
override init(frame: CGRect) {
|
||||||
|
super.init(frame: frame)
|
||||||
|
|
||||||
|
clipsToBounds = true
|
||||||
|
isUserInteractionEnabled = false
|
||||||
|
|
||||||
|
let baseFont = UIFont.systemFont(ofSize: 15, weight: .bold)
|
||||||
|
let roundedFont = UIFont(descriptor: baseFont.fontDescriptor.withDesign(.rounded) ?? baseFont.fontDescriptor, size: 15)
|
||||||
|
|
||||||
|
[primaryLabel, secondaryLabel].forEach { label in
|
||||||
|
label.font = roundedFont
|
||||||
|
label.textColor = UIColor.white.withAlphaComponent(0.92)
|
||||||
|
label.numberOfLines = 1
|
||||||
|
label.lineBreakMode = .byClipping
|
||||||
|
trackView.addSubview(label)
|
||||||
|
}
|
||||||
|
|
||||||
|
addSubview(trackView)
|
||||||
|
}
|
||||||
|
|
||||||
|
@available(*, unavailable)
|
||||||
|
required init?(coder: NSCoder) {
|
||||||
|
fatalError("init(coder:) has not been implemented")
|
||||||
|
}
|
||||||
|
|
||||||
|
func setText(_ text: String) {
|
||||||
|
guard currentText != text else { return }
|
||||||
|
currentText = text
|
||||||
|
primaryLabel.text = text
|
||||||
|
secondaryLabel.text = text
|
||||||
|
previousContentWidth = 0
|
||||||
|
setNeedsLayout()
|
||||||
|
}
|
||||||
|
|
||||||
|
override func layoutSubviews() {
|
||||||
|
super.layoutSubviews()
|
||||||
|
|
||||||
|
guard bounds.width > 0, bounds.height > 0 else { return }
|
||||||
|
|
||||||
|
let contentWidth = ceil(primaryLabel.sizeThatFits(CGSize(width: .greatestFiniteMagnitude, height: bounds.height)).width)
|
||||||
|
let contentHeight = bounds.height
|
||||||
|
|
||||||
|
primaryLabel.frame = CGRect(x: 0, y: 0, width: contentWidth, height: contentHeight)
|
||||||
|
secondaryLabel.frame = CGRect(x: contentWidth + spacing, y: 0, width: contentWidth, height: contentHeight)
|
||||||
|
|
||||||
|
let cycleWidth = contentWidth + spacing
|
||||||
|
|
||||||
|
if contentWidth <= bounds.width {
|
||||||
|
trackView.layer.removeAllAnimations()
|
||||||
|
secondaryLabel.isHidden = true
|
||||||
|
trackView.frame = CGRect(x: 0, y: 0, width: bounds.width, height: contentHeight)
|
||||||
|
primaryLabel.frame = CGRect(x: 0, y: 0, width: bounds.width, height: contentHeight)
|
||||||
|
primaryLabel.textAlignment = .left
|
||||||
|
previousBoundsWidth = bounds.width
|
||||||
|
previousContentWidth = contentWidth
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
primaryLabel.textAlignment = .left
|
||||||
|
secondaryLabel.isHidden = false
|
||||||
|
trackView.frame = CGRect(x: 0, y: 0, width: contentWidth * 2 + spacing, height: contentHeight)
|
||||||
|
|
||||||
|
let shouldRestart = abs(previousBoundsWidth - bounds.width) > 0.5
|
||||||
|
|| abs(previousContentWidth - contentWidth) > 0.5
|
||||||
|
|| trackView.layer.animation(forKey: "singleStreamMarquee") == nil
|
||||||
|
|
||||||
|
previousBoundsWidth = bounds.width
|
||||||
|
previousContentWidth = contentWidth
|
||||||
|
|
||||||
|
guard shouldRestart else { return }
|
||||||
|
|
||||||
|
trackView.layer.removeAllAnimations()
|
||||||
|
trackView.transform = .identity
|
||||||
|
|
||||||
|
let animation = CABasicAnimation(keyPath: "transform.translation.x")
|
||||||
|
animation.fromValue = 0
|
||||||
|
animation.toValue = -cycleWidth
|
||||||
|
animation.duration = Double(cycleWidth / pointsPerSecond)
|
||||||
|
animation.repeatCount = .infinity
|
||||||
|
animation.timingFunction = CAMediaTimingFunction(name: .linear)
|
||||||
|
animation.isRemovedOnCompletion = false
|
||||||
|
|
||||||
|
trackView.layer.add(animation, forKey: "singleStreamMarquee")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Full-screen player using AVPlayerViewController for PiP support on tvOS.
|
||||||
|
struct SingleStreamPlayerView: UIViewControllerRepresentable {
|
||||||
|
let resolveURL: @Sendable () async -> URL?
|
||||||
|
|
||||||
|
func makeCoordinator() -> Coordinator {
|
||||||
|
Coordinator()
|
||||||
|
}
|
||||||
|
|
||||||
|
func makeUIViewController(context: Context) -> AVPlayerViewController {
|
||||||
|
logSingleStream("makeUIViewController start")
|
||||||
|
let controller = AVPlayerViewController()
|
||||||
|
controller.allowsPictureInPicturePlayback = true
|
||||||
|
controller.showsPlaybackControls = true
|
||||||
|
logSingleStream("AVPlayerViewController configured without contentOverlayView ticker")
|
||||||
|
|
||||||
|
Task { @MainActor in
|
||||||
|
let resolveStartedAt = Date()
|
||||||
|
logSingleStream("Starting stream URL resolution")
|
||||||
|
guard let url = await resolveURL() else {
|
||||||
|
logSingleStream("resolveURL returned nil; aborting player startup")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let resolveElapsedMs = Int(Date().timeIntervalSince(resolveStartedAt) * 1000)
|
||||||
|
logSingleStream("Resolved stream URL elapsedMs=\(resolveElapsedMs) url=\(singleStreamDebugURLDescription(url))")
|
||||||
|
|
||||||
|
do {
|
||||||
|
try AVAudioSession.sharedInstance().setCategory(.playback, mode: .moviePlayback)
|
||||||
|
try AVAudioSession.sharedInstance().setActive(true)
|
||||||
|
logSingleStream("AVAudioSession configured for playback")
|
||||||
|
} catch {
|
||||||
|
logSingleStream("AVAudioSession configuration failed error=\(error.localizedDescription)")
|
||||||
|
}
|
||||||
|
|
||||||
|
let playerItem = AVPlayerItem(url: url)
|
||||||
|
playerItem.preferredForwardBufferDuration = 2
|
||||||
|
let player = AVPlayer(playerItem: playerItem)
|
||||||
|
player.automaticallyWaitsToMinimizeStalling = false
|
||||||
|
logSingleStream("Configured player for fast start preferredForwardBufferDuration=2 automaticallyWaitsToMinimizeStalling=false")
|
||||||
|
context.coordinator.attachDebugObservers(to: player, url: url)
|
||||||
|
controller.player = player
|
||||||
|
logSingleStream("AVPlayer assigned to controller; calling playImmediately(atRate: 1.0)")
|
||||||
|
player.playImmediately(atRate: 1.0)
|
||||||
|
}
|
||||||
|
|
||||||
|
return controller
|
||||||
|
}
|
||||||
|
|
||||||
|
func updateUIViewController(_ uiViewController: AVPlayerViewController, context: Context) {}
|
||||||
|
|
||||||
|
static func dismantleUIViewController(_ uiViewController: AVPlayerViewController, coordinator: Coordinator) {
|
||||||
|
logSingleStream("dismantleUIViewController start")
|
||||||
|
coordinator.clearDebugObservers()
|
||||||
|
uiViewController.player?.pause()
|
||||||
|
uiViewController.player = nil
|
||||||
|
logSingleStream("dismantleUIViewController complete")
|
||||||
|
}
|
||||||
|
|
||||||
|
final class Coordinator: NSObject {
|
||||||
|
private var playerObservations: [NSKeyValueObservation] = []
|
||||||
|
private var notificationTokens: [NSObjectProtocol] = []
|
||||||
|
|
||||||
|
func attachDebugObservers(to player: AVPlayer, url: URL) {
|
||||||
|
clearDebugObservers()
|
||||||
|
|
||||||
|
logSingleStream("Attaching AVPlayer observers url=\(singleStreamDebugURLDescription(url))")
|
||||||
|
|
||||||
|
playerObservations.append(
|
||||||
|
player.observe(\.status, options: [.initial, .new]) { player, _ in
|
||||||
|
logSingleStream("Player status changed status=\(singleStreamStatusDescription(player.status)) error=\(player.error?.localizedDescription ?? "nil")")
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
playerObservations.append(
|
||||||
|
player.observe(\.timeControlStatus, options: [.initial, .new]) { player, _ in
|
||||||
|
let reason = player.reasonForWaitingToPlay?.rawValue ?? "nil"
|
||||||
|
logSingleStream("Player timeControlStatus=\(singleStreamTimeControlDescription(player.timeControlStatus)) reasonForWaiting=\(reason)")
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
playerObservations.append(
|
||||||
|
player.observe(\.reasonForWaitingToPlay, options: [.initial, .new]) { player, _ in
|
||||||
|
logSingleStream("Player reasonForWaitingToPlay changed value=\(player.reasonForWaitingToPlay?.rawValue ?? "nil")")
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
guard let item = player.currentItem else {
|
||||||
|
logSingleStream("Player currentItem missing immediately after creation")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
playerObservations.append(
|
||||||
|
item.observe(\.status, options: [.initial, .new]) { item, _ in
|
||||||
|
logSingleStream("PlayerItem status changed status=\(singleStreamItemStatusDescription(item.status)) error=\(item.error?.localizedDescription ?? "nil")")
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
playerObservations.append(
|
||||||
|
item.observe(\.isPlaybackBufferEmpty, options: [.initial, .new]) { item, _ in
|
||||||
|
logSingleStream("PlayerItem isPlaybackBufferEmpty=\(item.isPlaybackBufferEmpty)")
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
playerObservations.append(
|
||||||
|
item.observe(\.isPlaybackLikelyToKeepUp, options: [.initial, .new]) { item, _ in
|
||||||
|
logSingleStream("PlayerItem isPlaybackLikelyToKeepUp=\(item.isPlaybackLikelyToKeepUp)")
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
playerObservations.append(
|
||||||
|
item.observe(\.isPlaybackBufferFull, options: [.initial, .new]) { item, _ in
|
||||||
|
logSingleStream("PlayerItem isPlaybackBufferFull=\(item.isPlaybackBufferFull)")
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
notificationTokens.append(
|
||||||
|
NotificationCenter.default.addObserver(
|
||||||
|
forName: .AVPlayerItemPlaybackStalled,
|
||||||
|
object: item,
|
||||||
|
queue: .main
|
||||||
|
) { _ in
|
||||||
|
logSingleStream("Notification AVPlayerItemPlaybackStalled")
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
notificationTokens.append(
|
||||||
|
NotificationCenter.default.addObserver(
|
||||||
|
forName: .AVPlayerItemFailedToPlayToEndTime,
|
||||||
|
object: item,
|
||||||
|
queue: .main
|
||||||
|
) { notification in
|
||||||
|
let error = notification.userInfo?[AVPlayerItemFailedToPlayToEndTimeErrorKey] as? NSError
|
||||||
|
logSingleStream("Notification AVPlayerItemFailedToPlayToEndTime error=\(error?.localizedDescription ?? "nil")")
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
notificationTokens.append(
|
||||||
|
NotificationCenter.default.addObserver(
|
||||||
|
forName: .AVPlayerItemNewErrorLogEntry,
|
||||||
|
object: item,
|
||||||
|
queue: .main
|
||||||
|
) { _ in
|
||||||
|
let event = item.errorLog()?.events.last
|
||||||
|
logSingleStream("Notification AVPlayerItemNewErrorLogEntry domain=\(event?.errorDomain ?? "nil") comment=\(event?.errorComment ?? "nil") statusCode=\(event?.errorStatusCode ?? 0)")
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
notificationTokens.append(
|
||||||
|
NotificationCenter.default.addObserver(
|
||||||
|
forName: .AVPlayerItemNewAccessLogEntry,
|
||||||
|
object: item,
|
||||||
|
queue: .main
|
||||||
|
) { _ in
|
||||||
|
if let event = item.accessLog()?.events.last {
|
||||||
|
logSingleStream(
|
||||||
|
"Notification AVPlayerItemNewAccessLogEntry indicatedBitrate=\(Int(event.indicatedBitrate)) observedBitrate=\(Int(event.observedBitrate)) segmentsDownloaded=\(event.segmentsDownloadedDuration)"
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
logSingleStream("Notification AVPlayerItemNewAccessLogEntry with no access log event")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
func clearDebugObservers() {
|
||||||
|
playerObservations.removeAll()
|
||||||
|
for token in notificationTokens {
|
||||||
|
NotificationCenter.default.removeObserver(token)
|
||||||
|
}
|
||||||
|
notificationTokens.removeAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
deinit {
|
||||||
|
clearDebugObservers()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
8
mlbTVOS/mlbTVOS.entitlements
Normal file
8
mlbTVOS/mlbTVOS.entitlements
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
<?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>com.apple.developer.avfoundation.multitasking-camera-access</key>
|
||||||
|
<true/>
|
||||||
|
</dict>
|
||||||
|
</plist>
|
||||||
28
mlbTVOS/mlbTVOSApp.swift
Normal file
28
mlbTVOS/mlbTVOSApp.swift
Normal file
@@ -0,0 +1,28 @@
|
|||||||
|
import AVFoundation
|
||||||
|
import SwiftUI
|
||||||
|
|
||||||
|
@main
|
||||||
|
struct mlbTVOSApp: App {
|
||||||
|
@State private var viewModel = GamesViewModel()
|
||||||
|
|
||||||
|
init() {
|
||||||
|
configureAudioSession()
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some Scene {
|
||||||
|
WindowGroup {
|
||||||
|
ContentView()
|
||||||
|
.environment(viewModel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func configureAudioSession() {
|
||||||
|
// Start with .ambient so we don't interrupt other audio on launch
|
||||||
|
// Switch to .playback when user starts a stream
|
||||||
|
do {
|
||||||
|
try AVAudioSession.sharedInstance().setCategory(.ambient)
|
||||||
|
} catch {
|
||||||
|
print("Failed to set audio session: \(error)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
24
project.yml
Normal file
24
project.yml
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
name: mlbTVOS
|
||||||
|
options:
|
||||||
|
bundleIdPrefix: com.treyt
|
||||||
|
deploymentTarget:
|
||||||
|
tvOS: "18.0"
|
||||||
|
xcodeVersion: "26.3"
|
||||||
|
generateEmptyDirectories: true
|
||||||
|
|
||||||
|
settings:
|
||||||
|
base:
|
||||||
|
SWIFT_VERSION: "6.0"
|
||||||
|
DEVELOPMENT_TEAM: ""
|
||||||
|
|
||||||
|
targets:
|
||||||
|
mlbTVOS:
|
||||||
|
type: application
|
||||||
|
platform: tvOS
|
||||||
|
sources: [mlbTVOS]
|
||||||
|
settings:
|
||||||
|
base:
|
||||||
|
INFOPLIST_FILE: mlbTVOS/Info.plist
|
||||||
|
CODE_SIGN_ENTITLEMENTS: mlbTVOS/mlbTVOS.entitlements
|
||||||
|
PRODUCT_BUNDLE_IDENTIFIER: com.treyt.mlbTVOS
|
||||||
|
SWIFT_STRICT_CONCURRENCY: complete
|
||||||
Reference in New Issue
Block a user