From bae265b132484e34702d2f522a346f4fecf70c5a Mon Sep 17 00:00:00 2001 From: Trey t Date: Thu, 26 Mar 2026 15:37:31 -0500 Subject: [PATCH] Initial commit --- .gitignore | 38 + mlbTVOS.xcodeproj/project.pbxproj | 440 ++++++ .../contents.xcworkspacedata | 7 + .../AccentColor.colorset/Contents.json | 20 + .../Content.imageset/Contents.json | 4 + .../Back.imagestacklayer/Contents.json | 3 + .../App Icon.imagestack/Contents.json | 11 + .../Content.imageset/Contents.json | 4 + .../Front.imagestacklayer/Contents.json | 3 + .../Content.imageset/Contents.json | 4 + .../Middle.imagestacklayer/Contents.json | 3 + .../Contents.json | 24 + mlbTVOS/Assets.xcassets/Contents.json | 6 + mlbTVOS/Info.plist | 27 + mlbTVOS/Models/Game.swift | 200 +++ mlbTVOS/Models/TeamAssets.swift | 62 + mlbTVOS/Services/MLBServerAPI.swift | 336 +++++ mlbTVOS/Services/MLBStatsAPI.swift | 1056 ++++++++++++++ mlbTVOS/ViewModels/GameCenterViewModel.swift | 45 + mlbTVOS/ViewModels/GamesViewModel.swift | 640 +++++++++ .../ViewModels/LeagueCenterViewModel.swift | 132 ++ mlbTVOS/Views/Components/LinescoreView.swift | 108 ++ mlbTVOS/Views/Components/LiveIndicator.swift | 66 + .../Components/PitcherHeadshotView.swift | 53 + .../Views/Components/ScoreOverlayView.swift | 67 + .../Views/Components/ScoresTickerView.swift | 167 +++ mlbTVOS/Views/Components/TeamLogoView.swift | 38 + mlbTVOS/Views/ContentView.swift | 33 + mlbTVOS/Views/DashboardView.swift | 340 +++++ mlbTVOS/Views/FeaturedGameCard.swift | 584 ++++++++ mlbTVOS/Views/GameCardView.swift | 302 ++++ mlbTVOS/Views/GameCenterView.swift | 375 +++++ mlbTVOS/Views/GameListView.swift | 635 +++++++++ mlbTVOS/Views/LeagueCenterView.swift | 665 +++++++++ mlbTVOS/Views/MLBNetworkSheet.swift | 294 ++++ mlbTVOS/Views/MultiStreamView.swift | 1257 +++++++++++++++++ mlbTVOS/Views/SettingsView.swift | 73 + mlbTVOS/Views/SingleStreamPlayerView.swift | 414 ++++++ mlbTVOS/mlbTVOS.entitlements | 8 + mlbTVOS/mlbTVOSApp.swift | 28 + project.yml | 24 + 41 files changed, 8596 insertions(+) create mode 100644 .gitignore create mode 100644 mlbTVOS.xcodeproj/project.pbxproj create mode 100644 mlbTVOS.xcodeproj/project.xcworkspace/contents.xcworkspacedata create mode 100644 mlbTVOS/Assets.xcassets/AccentColor.colorset/Contents.json create mode 100644 mlbTVOS/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Back.imagestacklayer/Content.imageset/Contents.json create mode 100644 mlbTVOS/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Back.imagestacklayer/Contents.json create mode 100644 mlbTVOS/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Contents.json create mode 100644 mlbTVOS/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Front.imagestacklayer/Content.imageset/Contents.json create mode 100644 mlbTVOS/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Front.imagestacklayer/Contents.json create mode 100644 mlbTVOS/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Middle.imagestacklayer/Content.imageset/Contents.json create mode 100644 mlbTVOS/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Middle.imagestacklayer/Contents.json create mode 100644 mlbTVOS/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Contents.json create mode 100644 mlbTVOS/Assets.xcassets/Contents.json create mode 100644 mlbTVOS/Info.plist create mode 100644 mlbTVOS/Models/Game.swift create mode 100644 mlbTVOS/Models/TeamAssets.swift create mode 100644 mlbTVOS/Services/MLBServerAPI.swift create mode 100644 mlbTVOS/Services/MLBStatsAPI.swift create mode 100644 mlbTVOS/ViewModels/GameCenterViewModel.swift create mode 100644 mlbTVOS/ViewModels/GamesViewModel.swift create mode 100644 mlbTVOS/ViewModels/LeagueCenterViewModel.swift create mode 100644 mlbTVOS/Views/Components/LinescoreView.swift create mode 100644 mlbTVOS/Views/Components/LiveIndicator.swift create mode 100644 mlbTVOS/Views/Components/PitcherHeadshotView.swift create mode 100644 mlbTVOS/Views/Components/ScoreOverlayView.swift create mode 100644 mlbTVOS/Views/Components/ScoresTickerView.swift create mode 100644 mlbTVOS/Views/Components/TeamLogoView.swift create mode 100644 mlbTVOS/Views/ContentView.swift create mode 100644 mlbTVOS/Views/DashboardView.swift create mode 100644 mlbTVOS/Views/FeaturedGameCard.swift create mode 100644 mlbTVOS/Views/GameCardView.swift create mode 100644 mlbTVOS/Views/GameCenterView.swift create mode 100644 mlbTVOS/Views/GameListView.swift create mode 100644 mlbTVOS/Views/LeagueCenterView.swift create mode 100644 mlbTVOS/Views/MLBNetworkSheet.swift create mode 100644 mlbTVOS/Views/MultiStreamView.swift create mode 100644 mlbTVOS/Views/SettingsView.swift create mode 100644 mlbTVOS/Views/SingleStreamPlayerView.swift create mode 100644 mlbTVOS/mlbTVOS.entitlements create mode 100644 mlbTVOS/mlbTVOSApp.swift create mode 100644 project.yml diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1a9af64 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/mlbTVOS.xcodeproj/project.pbxproj b/mlbTVOS.xcodeproj/project.pbxproj new file mode 100644 index 0000000..9b63c9e --- /dev/null +++ b/mlbTVOS.xcodeproj/project.pbxproj @@ -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 = ""; }; + 102E5E8674E188A04637D3EB /* FeaturedGameCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeaturedGameCard.swift; sourceTree = ""; }; + 1ABC999C711B074CF0B536DD /* Game.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Game.swift; sourceTree = ""; }; + 24B58FDA7A8CA27BC512CBA6 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; + 24C6D5F4A3B291807F6E5D4B /* ScoresTickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScoresTickerView.swift; sourceTree = ""; }; + 2BB22BB22BB22BB22BB22BB1 /* GameCenterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameCenterView.swift; sourceTree = ""; }; + 2BB22BB22BB22BB22BB22BB2 /* LeagueCenterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LeagueCenterView.swift; sourceTree = ""; }; + 2BB22BB22BB22BB22BB22BB3 /* GameCenterViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameCenterViewModel.swift; sourceTree = ""; }; + 2BB22BB22BB22BB22BB22BB4 /* LeagueCenterViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LeagueCenterViewModel.swift; sourceTree = ""; }; + 327A33F2676712C2B5B4AF2F /* LinescoreView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinescoreView.swift; sourceTree = ""; }; + 3FB99FBEDA096B95C3D2E5B4 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; + 45142562E644AF04F7719083 /* mlbTVOSApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = mlbTVOSApp.swift; sourceTree = ""; }; + 4DE9E708FA75ABB9F49D2392 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; + 5152ABCC9AACFD91BAC8C95B /* MLBNetworkSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MLBNetworkSheet.swift; sourceTree = ""; }; + 5EA4C33F029665B80F1A6B6D /* DashboardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashboardView.swift; sourceTree = ""; }; + 6067FE4F340FC0DCCFA62C67 /* MLBStatsAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MLBStatsAPI.swift; sourceTree = ""; }; + 645E77DD8183D54EFC97CFB5 /* TeamAssets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeamAssets.swift; sourceTree = ""; }; + 70E610D24BA83FB2F7490B00 /* MLBServerAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MLBServerAPI.swift; sourceTree = ""; }; + 766441BC900073529EE93D69 /* mlbTVOS.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = mlbTVOS.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 81584770090BAFBEF3ABE6FD /* LiveIndicator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveIndicator.swift; sourceTree = ""; }; + 8D10F2D91602CE1FB6D7342E /* MultiStreamView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultiStreamView.swift; sourceTree = ""; }; + 9BD2DEA6BBA55E86B2555099 /* GamesViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GamesViewModel.swift; sourceTree = ""; }; + AC5BCB2E1DA0EC63E422B113 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + C1F593C51BC11444A3D514D3 /* mlbTVOS.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = mlbTVOS.entitlements; sourceTree = ""; }; + C2E99F69727800D0CB795503 /* TeamLogoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeamLogoView.swift; sourceTree = ""; }; + D4E5F6A7B8C9D0E1F2A3B4C5 /* PitcherHeadshotView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PitcherHeadshotView.swift; sourceTree = ""; }; + E21E3115B8A9B4C798ECE828 /* GameCardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameCardView.swift; sourceTree = ""; }; + E5F6A7B8C9D0E1F2A3B4C5D6 /* ScoreOverlayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScoreOverlayView.swift; sourceTree = ""; }; + FA4AF68CDF4FC8D07194B4D0 /* SingleStreamPlayerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SingleStreamPlayerView.swift; sourceTree = ""; }; +/* End PBXFileReference section */ + +/* Begin PBXGroup section */ + 2C171EA64FFA962254F20583 /* ViewModels */ = { + isa = PBXGroup; + children = ( + 2BB22BB22BB22BB22BB22BB3 /* GameCenterViewModel.swift */, + 9BD2DEA6BBA55E86B2555099 /* GamesViewModel.swift */, + 2BB22BB22BB22BB22BB22BB4 /* LeagueCenterViewModel.swift */, + ); + path = ViewModels; + sourceTree = ""; + }; + 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 = ""; + }; + 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 = ""; + }; + 86C1117ADD32A1A57AA32A08 = { + isa = PBXGroup; + children = ( + 4FAE5C2D72CD78BF7C15C4FC /* mlbTVOS */, + 88274023705E1F09B25F86FF /* Products */, + ); + sourceTree = ""; + }; + 88274023705E1F09B25F86FF /* Products */ = { + isa = PBXGroup; + children = ( + 766441BC900073529EE93D69 /* mlbTVOS.app */, + ); + name = Products; + sourceTree = ""; + }; + C6DC4CC5AAB9AF84D3CB9F3B /* Services */ = { + isa = PBXGroup; + children = ( + 70E610D24BA83FB2F7490B00 /* MLBServerAPI.swift */, + 6067FE4F340FC0DCCFA62C67 /* MLBStatsAPI.swift */, + ); + path = Services; + sourceTree = ""; + }; + 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 = ""; + }; + D7F65F5A1205CF61737AA4BF /* Models */ = { + isa = PBXGroup; + children = ( + 1ABC999C711B074CF0B536DD /* Game.swift */, + 645E77DD8183D54EFC97CFB5 /* TeamAssets.swift */, + ); + path = Models; + sourceTree = ""; + }; +/* 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 */; +} diff --git a/mlbTVOS.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/mlbTVOS.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/mlbTVOS.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/mlbTVOS/Assets.xcassets/AccentColor.colorset/Contents.json b/mlbTVOS/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..91dbe6c --- /dev/null +++ b/mlbTVOS/Assets.xcassets/AccentColor.colorset/Contents.json @@ -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 + } +} diff --git a/mlbTVOS/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Back.imagestacklayer/Content.imageset/Contents.json b/mlbTVOS/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Back.imagestacklayer/Content.imageset/Contents.json new file mode 100644 index 0000000..8e9ab17 --- /dev/null +++ b/mlbTVOS/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Back.imagestacklayer/Content.imageset/Contents.json @@ -0,0 +1,4 @@ +{ + "images" : [ { "idiom" : "tv", "scale" : "1x" } ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/mlbTVOS/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Back.imagestacklayer/Contents.json b/mlbTVOS/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Back.imagestacklayer/Contents.json new file mode 100644 index 0000000..d8b757a --- /dev/null +++ b/mlbTVOS/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Back.imagestacklayer/Contents.json @@ -0,0 +1,3 @@ +{ + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/mlbTVOS/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Contents.json b/mlbTVOS/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Contents.json new file mode 100644 index 0000000..cfc6349 --- /dev/null +++ b/mlbTVOS/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Contents.json @@ -0,0 +1,11 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + }, + "layers" : [ + { "filename" : "Front.imagestacklayer" }, + { "filename" : "Middle.imagestacklayer" }, + { "filename" : "Back.imagestacklayer" } + ] +} diff --git a/mlbTVOS/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Front.imagestacklayer/Content.imageset/Contents.json b/mlbTVOS/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Front.imagestacklayer/Content.imageset/Contents.json new file mode 100644 index 0000000..8e9ab17 --- /dev/null +++ b/mlbTVOS/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Front.imagestacklayer/Content.imageset/Contents.json @@ -0,0 +1,4 @@ +{ + "images" : [ { "idiom" : "tv", "scale" : "1x" } ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/mlbTVOS/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Front.imagestacklayer/Contents.json b/mlbTVOS/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Front.imagestacklayer/Contents.json new file mode 100644 index 0000000..d8b757a --- /dev/null +++ b/mlbTVOS/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Front.imagestacklayer/Contents.json @@ -0,0 +1,3 @@ +{ + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/mlbTVOS/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Middle.imagestacklayer/Content.imageset/Contents.json b/mlbTVOS/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Middle.imagestacklayer/Content.imageset/Contents.json new file mode 100644 index 0000000..8e9ab17 --- /dev/null +++ b/mlbTVOS/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Middle.imagestacklayer/Content.imageset/Contents.json @@ -0,0 +1,4 @@ +{ + "images" : [ { "idiom" : "tv", "scale" : "1x" } ], + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/mlbTVOS/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Middle.imagestacklayer/Contents.json b/mlbTVOS/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Middle.imagestacklayer/Contents.json new file mode 100644 index 0000000..d8b757a --- /dev/null +++ b/mlbTVOS/Assets.xcassets/App Icon & Top Shelf Image.brandassets/App Icon.imagestack/Middle.imagestacklayer/Contents.json @@ -0,0 +1,3 @@ +{ + "info" : { "author" : "xcode", "version" : 1 } +} diff --git a/mlbTVOS/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Contents.json b/mlbTVOS/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Contents.json new file mode 100644 index 0000000..cbc16cc --- /dev/null +++ b/mlbTVOS/Assets.xcassets/App Icon & Top Shelf Image.brandassets/Contents.json @@ -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 + } +} diff --git a/mlbTVOS/Assets.xcassets/Contents.json b/mlbTVOS/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/mlbTVOS/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/mlbTVOS/Info.plist b/mlbTVOS/Info.plist new file mode 100644 index 0000000..4f1d0f8 --- /dev/null +++ b/mlbTVOS/Info.plist @@ -0,0 +1,27 @@ + + + + + CFBundleName + $(PRODUCT_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleVersion + 1 + CFBundleShortVersionString + 1.0 + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleExecutable + $(EXECUTABLE_NAME) + UIBackgroundModes + + audio + + NSAppTransportSecurity + + NSAllowsLocalNetworking + + + + diff --git a/mlbTVOS/Models/Game.swift b/mlbTVOS/Models/Game.swift new file mode 100644 index 0000000..5491d1a --- /dev/null +++ b/mlbTVOS/Models/Game.swift @@ -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 + } +} diff --git a/mlbTVOS/Models/TeamAssets.swift b/mlbTVOS/Models/TeamAssets.swift new file mode 100644 index 0000000..0543dfe --- /dev/null +++ b/mlbTVOS/Models/TeamAssets.swift @@ -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")! + } +} diff --git a/mlbTVOS/Services/MLBServerAPI.swift b/mlbTVOS/Services/MLBServerAPI.swift new file mode 100644 index 0000000..8ffd186 --- /dev/null +++ b/mlbTVOS/Services/MLBServerAPI.swift @@ -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: AWAY @ HOME......streams... + let rowPattern = #"]*>\s*([^<]*(?:<(?!/?tr)[^>]*>[^<]*)*)\s*(.*?)"# + 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: "
", with: "\n") + .replacingOccurrences(of: "
", 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.. 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.. 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: NAME + // Split by broadcast entries: look for "CODE: ]*href="/embed\.html\?mediaId=([^"&]+)"[^>]*>([^<]+)\s*]*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" + } + } +} diff --git a/mlbTVOS/Services/MLBStatsAPI.swift b/mlbTVOS/Services/MLBStatsAPI.swift new file mode 100644 index 0000000..06b4a90 --- /dev/null +++ b/mlbTVOS/Services/MLBStatsAPI.swift @@ -0,0 +1,1056 @@ +import Foundation + +/// Fetches supplementary game data from the public MLB Stats API. +actor MLBStatsAPI { + private let baseURL = "https://statsapi.mlb.com/api/v1" + + func fetchStandings(season: String) async throws -> [Int: TeamStanding] { + let response: StandingsResponse = try await fetchJSON( + "\(baseURL)/standings?leagueId=103,104&season=\(season)&hydrate=team" + ) + + var result: [Int: TeamStanding] = [:] + for record in response.records { + let divisionName = record.division?.name + for tr in record.teamRecords { + result[tr.team.id] = TeamStanding( + divisionRank: tr.divisionRank, + gamesBack: tr.gamesBack == "-" ? nil : tr.gamesBack, + streak: tr.streak?.streakCode, + divisionName: divisionName + ) + } + } + return result + } + + func fetchSchedule(date: String) async throws -> [StatsGame] { + let response: MLBScheduleResponse = try await fetchJSON( + "\(baseURL)/schedule?sportId=1&date=\(date)&hydrate=team(record),linescore,probablePitcher,venue" + ) + return response.dates.flatMap(\.games) + } + + func fetchStandingsRecords(season: String) async throws -> [StandingsDivisionRecord] { + let response: StandingsResponse = try await fetchJSON( + "\(baseURL)/standings?leagueId=103,104&season=\(season)&hydrate=team" + ) + return response.records + } + + func fetchGameFeed(gamePk: String) async throws -> LiveGameFeed { + try await fetchJSON("\(baseURL).1/game/\(gamePk)/feed/live") + } + + func fetchLeagueTeams(season: String) async throws -> [LeagueTeamSummary] { + let response: TeamsResponse = try await fetchJSON( + "\(baseURL)/teams?sportId=1&season=\(season)&hydrate=league,division,venue,record" + ) + return response.teams + .filter(\.active) + .sorted { $0.name < $1.name } + .map(LeagueTeamSummary.init) + } + + func fetchTeamProfile(teamID: Int, season: String) async throws -> TeamProfile { + let response: TeamsResponse = try await fetchJSON( + "\(baseURL)/teams/\(teamID)?sportId=1&season=\(season)&hydrate=league,division,venue,record" + ) + guard let team = response.teams.first else { + throw StatsAPIError.notFound + } + return TeamProfile(team: team) + } + + func fetchTeamRoster(teamID: Int, season: String) async throws -> [RosterPlayerSummary] { + let response: TeamRosterResponse = try await fetchJSON( + "\(baseURL)/teams/\(teamID)/roster?season=\(season)" + ) + return response.roster + .sorted { lhs, rhs in + lhs.person.fullName < rhs.person.fullName + } + .map(RosterPlayerSummary.init) + } + + func fetchPlayerProfile(personID: Int, season: String) async throws -> PlayerProfile { + async let personTask: PeopleResponse = fetchJSON("\(baseURL)/people/\(personID)") + async let statsTask: StatsQueryResponse = fetchJSON( + "\(baseURL)/people/\(personID)/stats?stats=yearByYear&group=hitting,pitching,fielding&sportIds=1" + ) + + let personResponse = try await personTask + let statsResponse = try await statsTask + + guard let person = personResponse.people.first else { + throw StatsAPIError.notFound + } + + let resolvedSeason = resolvePlayerStatsSeason( + requestedSeason: season, + from: statsResponse.stats + ) + let filteredStatGroups = statsResponse.stats.compactMap { group in + group.filteredForPlayerProfile(season: resolvedSeason) + } + + return PlayerProfile( + person: person, + season: resolvedSeason ?? season, + statGroups: filteredStatGroups + ) + } + + private func resolvePlayerStatsSeason(requestedSeason: String, from groups: [StatsQueryGroup]) -> String? { + if groups.contains(where: { $0.hasRegularSeasonData(for: requestedSeason) }) { + return requestedSeason + } + + return groups + .flatMap(\.splits) + .filter(\.isRegularSeason) + .compactMap { split in + split.season.flatMap(Int.init) + } + .max() + .map(String.init) + } + + private func fetchJSON(_ urlString: String) async throws -> T { + guard let url = URL(string: urlString) else { + throw StatsAPIError.invalidURL + } + + let (data, response) = try await URLSession.shared.data(from: url) + guard let http = response as? HTTPURLResponse, (200...299).contains(http.statusCode) else { + throw StatsAPIError.requestFailed + } + return try JSONDecoder().decode(T.self, from: data) + } +} + +// MARK: - Codable Models + +struct MLBScheduleResponse: Codable, Sendable { + let dates: [ScheduleDate] +} + +struct ScheduleDate: Codable, Sendable { + let date: String + let games: [StatsGame] +} + +struct StatsGame: Codable, Sendable, Identifiable { + let gamePk: Int + let gameDate: String? + let status: StatsGameStatus + let teams: StatsTeams + let linescore: StatsLinescore? + let venue: StatsVenue? + let seriesDescription: String? + let gameType: String? + + var id: Int { gamePk } + + var isLive: Bool { + status.abstractGameState == "Live" + } + + var isFinal: Bool { + status.abstractGameState == "Final" + } + + var isScheduled: Bool { + status.abstractGameState == "Preview" + } + + /// Format start time from gameDate ISO string + var startTime: String? { + guard let dateStr = gameDate else { return nil } + let isoFormatter = ISO8601DateFormatter() + isoFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + guard let date = isoFormatter.date(from: dateStr) + ?? ISO8601DateFormatter().date(from: dateStr) else { return nil } + let displayFormatter = DateFormatter() + displayFormatter.dateFormat = "h:mm a" + displayFormatter.timeZone = .current + return displayFormatter.string(from: date) + } +} + +struct StatsGameStatus: Codable, Sendable { + let detailedState: String? + let abstractGameState: String? + let statusCode: String? +} + +struct StatsTeams: Codable, Sendable { + let away: StatsTeamGameInfo + let home: StatsTeamGameInfo +} + +struct StatsTeamGameInfo: Codable, Sendable { + let team: StatsTeamBasic + let score: Int? + let leagueRecord: StatsLeagueRecord? + let probablePitcher: StatsPitcher? +} + +struct StatsTeamBasic: Codable, Sendable { + let id: Int + let name: String? + let abbreviation: String? +} + +struct StatsLeagueRecord: Codable, Sendable { + let wins: Int + let losses: Int + let pct: String? +} + +struct StatsPitcher: Codable, Sendable { + let id: Int + let fullName: String? +} + +struct StatsLinescore: Codable, Sendable { + let innings: [StatsInningScore]? + let teams: StatsLinescoreTeams? + let currentInning: Int? + let currentInningOrdinal: String? + let inningState: String? + let inningHalf: String? + let isTopInning: Bool? + let scheduledInnings: Int? + let balls: Int? + let strikes: Int? + let outs: Int? + + var currentInningDisplay: String? { + guard let ordinal = currentInningOrdinal, + let half = inningHalf else { return nil } + return "\(half) \(ordinal)" + } + + var hasData: Bool { + guard let innings, !innings.isEmpty else { return false } + return innings.contains { ($0.away?.runs != nil) || ($0.home?.runs != nil) } + } +} + +struct StatsInningScore: Codable, Sendable { + let num: Int + let away: StatsInningRuns? + let home: StatsInningRuns? +} + +struct StatsInningRuns: Codable, Sendable { + let runs: Int? + let hits: Int? + let errors: Int? +} + +struct StatsLinescoreTeams: Codable, Sendable { + let away: StatsLinescoreTotals? + let home: StatsLinescoreTotals? +} + +struct StatsLinescoreTotals: Codable, Sendable { + let runs: Int? + let hits: Int? + let errors: Int? +} + +struct StatsVenue: Codable, Sendable { + let name: String? +} + +// MARK: - Standings Models + +struct TeamStanding: Sendable { + let divisionRank: String? + let gamesBack: String? + let streak: String? + let divisionName: String? +} + +struct StandingsResponse: Codable, Sendable { + let records: [StandingsDivisionRecord] +} + +struct StandingsDivisionRecord: Codable, Sendable { + let division: StandingsDivision? + let teamRecords: [StandingsTeamRecord] +} + +struct StandingsDivision: Codable, Sendable { + let id: Int? + let name: String? +} + +struct StandingsTeamRecord: Codable, Sendable { + let team: StatsTeamBasic + let divisionRank: String? + let gamesBack: String? + let streak: StandingsStreak? + let wins: Int? + let losses: Int? +} + +struct StandingsStreak: Codable, Sendable { + let streakCode: String? +} + +enum StatsAPIError: Error { + case invalidURL + case requestFailed + case notFound +} + +// MARK: - League Hub Models + +struct TeamsResponse: Codable, Sendable { + let teams: [LeagueTeam] +} + +struct LeagueTeam: Codable, Sendable, Identifiable { + let id: Int + let name: String + let abbreviation: String? + let teamName: String? + let locationName: String? + let clubName: String? + let franchiseName: String? + let firstYearOfPlay: String? + let league: LeagueReference? + let division: LeagueReference? + let venue: LeagueVenue? + let record: LeagueRecordSummary? + let active: Bool +} + +struct LeagueReference: Codable, Sendable { + let id: Int? + let name: String? + let abbreviation: String? +} + +struct LeagueVenue: Codable, Sendable { + let id: Int? + let name: String? +} + +struct LeagueRecordSummary: Codable, Sendable { + let gamesPlayed: Int? + let divisionGamesBack: String? + let wildCardGamesBack: String? + let leagueRecord: LeagueRecordLine? + let wins: Int? + let losses: Int? + let winningPercentage: String? +} + +struct LeagueRecordLine: Codable, Sendable { + let wins: Int + let losses: Int + let ties: Int? + let pct: String? +} + +struct LeagueTeamSummary: Identifiable, Sendable { + let id: Int + let name: String + let abbreviation: String + let locationName: String + let clubName: String + let leagueName: String? + let divisionName: String? + let venueName: String? + let recordText: String? + + init(team: LeagueTeam) { + id = team.id + name = team.name + abbreviation = team.abbreviation ?? team.teamName ?? "MLB" + locationName = team.locationName ?? team.name + clubName = team.clubName ?? team.teamName ?? team.name + leagueName = team.league?.name + divisionName = team.division?.name + venueName = team.venue?.name + if let wins = team.record?.wins, let losses = team.record?.losses { + recordText = "\(wins)-\(losses)" + } else { + recordText = nil + } + } + + var teamInfo: TeamInfo { + TeamInfo(code: abbreviation, name: name, score: nil, teamId: id, record: recordText) + } +} + +struct TeamProfile: Identifiable, Sendable { + let id: Int + let name: String + let abbreviation: String + let locationName: String + let clubName: String + let franchiseName: String? + let firstYearOfPlay: String? + let venueName: String? + let leagueName: String? + let divisionName: String? + let recordText: String? + let gamesBackText: String? + + init(team: LeagueTeam) { + id = team.id + name = team.name + abbreviation = team.abbreviation ?? team.teamName ?? "MLB" + locationName = team.locationName ?? team.name + clubName = team.clubName ?? team.teamName ?? team.name + franchiseName = team.franchiseName + firstYearOfPlay = team.firstYearOfPlay + venueName = team.venue?.name + leagueName = team.league?.name + divisionName = team.division?.name + if let wins = team.record?.wins, let losses = team.record?.losses { + recordText = "\(wins)-\(losses)" + } else { + recordText = nil + } + if let divisionGamesBack = team.record?.divisionGamesBack, divisionGamesBack != "-" { + gamesBackText = "\(divisionGamesBack) GB" + } else { + gamesBackText = nil + } + } + + var teamInfo: TeamInfo { + TeamInfo(code: abbreviation, name: name, score: nil, teamId: id, record: recordText) + } +} + +struct TeamRosterResponse: Codable, Sendable { + let roster: [RosterEntry] +} + +struct RosterEntry: Codable, Sendable, Identifiable { + let person: RosterPerson + let jerseyNumber: String? + let position: RosterPosition? + let status: RosterStatus? + + var id: Int { person.id } +} + +struct RosterPerson: Codable, Sendable { + let id: Int + let fullName: String +} + +struct RosterPosition: Codable, Sendable { + let code: String? + let name: String? + let type: String? + let abbreviation: String? +} + +struct RosterStatus: Codable, Sendable { + let code: String? + let description: String? +} + +struct RosterPlayerSummary: Identifiable, Sendable { + let id: Int + let fullName: String + let jerseyNumber: String? + let positionAbbreviation: String? + let status: String? + + init(entry: RosterEntry) { + id = entry.person.id + fullName = entry.person.fullName + jerseyNumber = entry.jerseyNumber + positionAbbreviation = entry.position?.abbreviation + status = entry.status?.description + } + + var headshotURL: URL { + URL(string: "https://img.mlbstatic.com/mlb-photos/image/upload/w_360,q_auto:best/v1/people/\(id)/headshot/67/current")! + } +} + +struct PeopleResponse: Codable, Sendable { + let people: [PersonDetail] +} + +struct PersonDetail: Codable, Sendable, Identifiable { + let id: Int + let fullName: String + let firstName: String? + let lastName: String? + let primaryNumber: String? + let birthDate: String? + let currentAge: Int? + let birthCity: String? + let birthStateProvince: String? + let birthCountry: String? + let height: String? + let weight: Int? + let active: Bool? + let primaryPosition: PersonPosition? + let batSide: Handedness? + let pitchHand: Handedness? + let mlbDebutDate: String? +} + +struct PersonPosition: Codable, Sendable { + let code: String? + let name: String? + let type: String? + let abbreviation: String? +} + +struct Handedness: Codable, Sendable { + let code: String? + let description: String? +} + +struct StatsQueryResponse: Codable, Sendable { + let stats: [StatsQueryGroup] +} + +struct StatsQueryGroup: Codable, Sendable, Identifiable { + let type: StatsQueryDescriptor? + let group: StatsQueryDescriptor? + let splits: [StatsQuerySplit] + + var id: String { + "\(group?.displayName ?? "stats")-\(type?.displayName ?? "season")" + } +} + +struct StatsQueryDescriptor: Codable, Sendable { + let displayName: String? +} + +struct StatsQuerySplit: Codable, Sendable { + let season: String? + let gameType: String? + let stat: [String: JSONValue] +} + +struct PlayerProfile: Identifiable, Sendable { + let id: Int + let fullName: String + let primaryNumber: String? + let primaryPosition: String? + let currentAge: Int? + let birthDate: String? + let birthPlace: String? + let height: String? + let weight: Int? + let bats: String? + let throwsHand: String? + let debutDate: String? + let seasonLabel: String + let statGroups: [PlayerStatSummary] + + init(person: PersonDetail, season: String, statGroups: [StatsQueryGroup]) { + id = person.id + fullName = person.fullName + primaryNumber = person.primaryNumber + primaryPosition = person.primaryPosition?.abbreviation ?? person.primaryPosition?.name + currentAge = person.currentAge + birthDate = person.birthDate + let placeParts = [person.birthCity, person.birthStateProvince, person.birthCountry] + .compactMap { $0?.isEmpty == false ? $0 : nil } + birthPlace = placeParts.isEmpty ? nil : placeParts.joined(separator: ", ") + height = person.height + weight = person.weight + bats = person.batSide?.description + throwsHand = person.pitchHand?.description + debutDate = person.mlbDebutDate + seasonLabel = season + self.statGroups = statGroups.compactMap(PlayerStatSummary.init) + } + + var headshotURL: URL { + URL(string: "https://img.mlbstatic.com/mlb-photos/image/upload/w_360,q_auto:best/v1/people/\(id)/headshot/67/current")! + } +} + +struct PlayerStatSummary: Identifiable, Sendable { + let id: String + let title: String + let items: [PlayerStatItem] + + init?(group: StatsQueryGroup) { + guard let split = group.splits.first else { return nil } + + let groupName = group.group?.displayName ?? "Stats" + let title = groupName.capitalized + let preferredKeys: [String] + switch groupName.lowercased() { + case "hitting": + preferredKeys = ["avg", "obp", "slg", "ops", "homeRuns", "rbi", "hits", "runs", "baseOnBalls", "stolenBases"] + case "pitching": + preferredKeys = ["era", "wins", "losses", "saves", "inningsPitched", "strikeOuts", "whip", "baseOnBalls", "hits", "runs"] + case "fielding": + preferredKeys = ["fielding", "assists", "putOuts", "errors", "chances", "gamesStarted"] + default: + preferredKeys = Array(split.stat.keys.sorted().prefix(8)) + } + + let items = preferredKeys.compactMap { key -> PlayerStatItem? in + guard let value = split.stat[key]?.displayString else { return nil } + return PlayerStatItem(label: key.statLabel, value: value) + } + + guard !items.isEmpty else { return nil } + + id = group.id + self.title = title + self.items = items + } +} + +private extension StatsQueryGroup { + func hasRegularSeasonData(for season: String) -> Bool { + splits.contains { split in + split.isRegularSeason && split.season == season + } + } + + func filteredForPlayerProfile(season: String?) -> StatsQueryGroup? { + guard let season else { return nil } + + let filteredSplits = splits.filter { split in + split.isRegularSeason && split.season == season + } + + guard !filteredSplits.isEmpty else { return nil } + + return StatsQueryGroup( + type: type, + group: group, + splits: filteredSplits + ) + } +} + +private extension StatsQuerySplit { + var isRegularSeason: Bool { + guard let gameType else { return true } + return gameType == "R" + } +} + +struct PlayerStatItem: Sendable { + let label: String + let value: String +} + +enum JSONValue: Codable, Sendable { + case string(String) + case int(Int) + case double(Double) + case bool(Bool) + case object([String: JSONValue]) + case array([JSONValue]) + case null + + init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if container.decodeNil() { + self = .null + } else if let value = try? container.decode(String.self) { + self = .string(value) + } else if let value = try? container.decode(Int.self) { + self = .int(value) + } else if let value = try? container.decode(Double.self) { + self = .double(value) + } else if let value = try? container.decode(Bool.self) { + self = .bool(value) + } else if let value = try? container.decode([String: JSONValue].self) { + self = .object(value) + } else if let value = try? container.decode([JSONValue].self) { + self = .array(value) + } else { + throw DecodingError.typeMismatch( + JSONValue.self, + .init(codingPath: decoder.codingPath, debugDescription: "Unsupported JSON value") + ) + } + } + + func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .string(let value): + try container.encode(value) + case .int(let value): + try container.encode(value) + case .double(let value): + try container.encode(value) + case .bool(let value): + try container.encode(value) + case .object(let value): + try container.encode(value) + case .array(let value): + try container.encode(value) + case .null: + try container.encodeNil() + } + } + + var displayString: String? { + switch self { + case .string(let value): + return value + case .int(let value): + return String(value) + case .double(let value): + if value.rounded() == value { + return String(Int(value)) + } + return String(format: "%.3f", value) + .replacingOccurrences(of: #"(\.\d*?[1-9])0+$"#, with: "$1", options: .regularExpression) + .replacingOccurrences(of: #"\.0+$"#, with: "", options: .regularExpression) + case .bool(let value): + return value ? "Yes" : "No" + case .object, .array, .null: + return nil + } + } +} + +private extension String { + var statLabel: String { + switch self { + case "avg": return "AVG" + case "obp": return "OBP" + case "slg": return "SLG" + case "ops": return "OPS" + case "era": return "ERA" + case "whip": return "WHIP" + case "rbi": return "RBI" + default: + let pattern = "([a-z0-9])([A-Z])" + return replacingOccurrences(of: pattern, with: "$1 $2", options: .regularExpression) + .replacingOccurrences(of: "_", with: " ") + .capitalized + } + } +} + +// MARK: - Live Game Feed Models + +struct LiveGameFeed: Codable, Sendable { + let metaData: LiveFeedMetaData? + let gameData: LiveFeedGameData + let liveData: LiveFeedData + + var recentPlays: [LiveFeedPlay] { + Array(liveData.plays.allPlays.suffix(12).reversed()) + } + + var scoringPlays: [LiveFeedPlay] { + liveData.plays.allPlays.filter { $0.about?.isScoringPlay == true } + } + + var currentPlay: LiveFeedPlay? { + liveData.plays.currentPlay ?? liveData.plays.allPlays.last + } + + var currentBatter: LiveFeedPlayerReference? { + liveData.linescore?.offense?.batter ?? currentPlay?.matchup?.batter + } + + var currentPitcher: LiveFeedPlayerReference? { + liveData.linescore?.defense?.pitcher ?? currentPlay?.matchup?.pitcher + } + + var onDeckBatter: LiveFeedPlayerReference? { + liveData.linescore?.offense?.onDeck + } + + var inHoleBatter: LiveFeedPlayerReference? { + liveData.linescore?.offense?.inHole + } + + var currentCountText: String? { + guard let count = currentPlay?.count else { return nil } + return "\(count.balls)-\(count.strikes), \(count.outs) out\(count.outs == 1 ? "" : "s")" + } + + var occupiedBases: [String] { + var bases: [String] = [] + if liveData.linescore?.offense?.first != nil { bases.append("1B") } + if liveData.linescore?.offense?.second != nil { bases.append("2B") } + if liveData.linescore?.offense?.third != nil { bases.append("3B") } + return bases + } + + var officialSummary: [String] { + (liveData.boxscore?.officials ?? []).compactMap { official in + guard let type = official.officialType, let name = official.official?.fullName else { return nil } + return "\(type): \(name)" + } + } + + var weatherSummary: String? { + guard let weather = gameData.weather else { return nil } + return [weather.condition, weather.temp.map { "\($0)°" }, weather.wind] + .compactMap { $0 } + .joined(separator: " • ") + } + + var decisionsSummary: [String] { + var result: [String] = [] + if let winner = liveData.decisions?.winner?.fullName { + result.append("W: \(winner)") + } + if let loser = liveData.decisions?.loser?.fullName { + result.append("L: \(loser)") + } + if let save = liveData.decisions?.save?.fullName { + result.append("SV: \(save)") + } + return result + } + + var awayLineup: [LiveFeedBoxscorePlayer] { + liveData.boxscore?.teams?.away.lineupPlayers ?? [] + } + + var homeLineup: [LiveFeedBoxscorePlayer] { + liveData.boxscore?.teams?.home.lineupPlayers ?? [] + } +} + +struct LiveFeedMetaData: Codable, Sendable { + let wait: Int? + let timeStamp: String? + let gameEvents: [String]? + let logicalEvents: [String]? +} + +struct LiveFeedGameData: Codable, Sendable { + let game: LiveFeedGameInfo? + let datetime: LiveFeedDateTime? + let status: LiveFeedStatus? + let teams: LiveFeedGameTeams? + let probablePitchers: LiveFeedProbablePitchers? + let venue: LiveFeedVenue? + let weather: LiveFeedWeather? +} + +struct LiveFeedGameInfo: Codable, Sendable { + let pk: Int? + let type: String? + let season: String? +} + +struct LiveFeedDateTime: Codable, Sendable { + let dateTime: String? + let officialDate: String? + let time: String? + let ampm: String? +} + +struct LiveFeedStatus: Codable, Sendable { + let abstractGameState: String? + let detailedState: String? + let codedGameState: String? +} + +struct LiveFeedGameTeams: Codable, Sendable { + let away: LiveFeedGameTeam? + let home: LiveFeedGameTeam? +} + +struct LiveFeedGameTeam: Codable, Sendable { + let id: Int? + let name: String? + let abbreviation: String? + let record: LeagueRecordSummary? +} + +struct LiveFeedProbablePitchers: Codable, Sendable { + let away: LiveFeedPlayerReference? + let home: LiveFeedPlayerReference? +} + +struct LiveFeedVenue: Codable, Sendable { + let id: Int? + let name: String? +} + +struct LiveFeedWeather: Codable, Sendable { + let condition: String? + let temp: Int? + let wind: String? +} + +struct LiveFeedData: Codable, Sendable { + let plays: LiveFeedPlays + let linescore: LiveFeedLinescore? + let boxscore: LiveFeedBoxscore? + let decisions: LiveFeedDecisions? +} + +struct LiveFeedPlays: Codable, Sendable { + let currentPlay: LiveFeedPlay? + let allPlays: [LiveFeedPlay] +} + +struct LiveFeedPlay: Codable, Sendable, Identifiable { + let result: LiveFeedPlayResult? + let about: LiveFeedPlayAbout? + let count: LiveFeedPlayCount? + let matchup: LiveFeedPlayMatchup? + + var id: String { + let inning = about?.inning ?? -1 + let half = about?.halfInning ?? "play" + let atBat = about?.atBatIndex ?? -1 + let event = result?.eventType ?? result?.event ?? result?.description ?? "update" + return "\(inning)-\(half)-\(atBat)-\(event)" + } + + var summaryText: String { + result?.description ?? result?.event ?? "Play update" + } +} + +struct LiveFeedPlayResult: Codable, Sendable { + let type: String? + let event: String? + let eventType: String? + let description: String? +} + +struct LiveFeedPlayAbout: Codable, Sendable { + let atBatIndex: Int? + let halfInning: String? + let inning: Int? + let isScoringPlay: Bool? +} + +struct LiveFeedPlayCount: Codable, Sendable { + let balls: Int + let strikes: Int + let outs: Int +} + +struct LiveFeedPlayMatchup: Codable, Sendable { + let batter: LiveFeedPlayerReference? + let pitcher: LiveFeedPlayerReference? +} + +struct LiveFeedPlayerReference: Codable, Sendable, Identifiable { + let id: Int? + let fullName: String? + let link: String? + + var displayName: String { + fullName ?? "TBD" + } +} + +struct LiveFeedLinescore: Codable, Sendable { + let currentInning: Int? + let currentInningOrdinal: String? + let inningHalf: String? + let inningState: String? + let scheduledInnings: Int? + let offense: LiveFeedOffenseState? + let defense: LiveFeedDefenseState? + let teams: StatsLinescoreTeams? + + var inningDisplay: String? { + if let inningHalf, let currentInningOrdinal { + return "\(inningHalf) \(currentInningOrdinal)" + } + if let inningState, let currentInningOrdinal { + return "\(inningState) \(currentInningOrdinal)" + } + return nil + } +} + +struct LiveFeedOffenseState: Codable, Sendable { + let batter: LiveFeedPlayerReference? + let onDeck: LiveFeedPlayerReference? + let inHole: LiveFeedPlayerReference? + let first: LiveFeedPlayerReference? + let second: LiveFeedPlayerReference? + let third: LiveFeedPlayerReference? +} + +struct LiveFeedDefenseState: Codable, Sendable { + let pitcher: LiveFeedPlayerReference? +} + +struct LiveFeedBoxscore: Codable, Sendable { + let teams: LiveFeedBoxscoreTeams? + let officials: [LiveFeedOfficial]? +} + +struct LiveFeedBoxscoreTeams: Codable, Sendable { + let away: LiveFeedBoxscoreTeam + let home: LiveFeedBoxscoreTeam +} + +struct LiveFeedBoxscoreTeam: Codable, Sendable { + let team: StatsTeamBasic? + let players: [String: LiveFeedBoxscorePlayer] + let battingOrder: [JSONValue]? + let batters: [Int]? + + var lineupPlayers: [LiveFeedBoxscorePlayer] { + let orderedIDs = battingOrder?.compactMap(\.displayString) ?? batters?.map(String.init) ?? [] + let players = orderedIDs.compactMap(player(forIdentifier:)) + if !players.isEmpty { + return players + } + return self.players.values.sorted { lhs, rhs in + (lhs.battingOrder ?? "999") < (rhs.battingOrder ?? "999") + } + } + + private func player(forIdentifier identifier: String) -> LiveFeedBoxscorePlayer? { + if let player = players[identifier] { + return player + } + if let player = players["ID\(identifier)"] { + return player + } + if identifier.hasPrefix("ID"), let player = players[String(identifier.dropFirst(2))] { + return player + } + return nil + } +} + +struct LiveFeedBoxscorePlayer: Codable, Sendable, Identifiable { + let person: LiveFeedPlayerReference + let jerseyNumber: String? + let position: PersonPosition? + let battingOrder: String? + + var id: Int { person.id ?? -1 } +} + +struct LiveFeedOfficial: Codable, Sendable { + let official: LiveFeedPlayerReference? + let officialType: String? +} + +struct LiveFeedDecisions: Codable, Sendable { + let winner: LiveFeedPlayerReference? + let loser: LiveFeedPlayerReference? + let save: LiveFeedPlayerReference? +} diff --git a/mlbTVOS/ViewModels/GameCenterViewModel.swift b/mlbTVOS/ViewModels/GameCenterViewModel.swift new file mode 100644 index 0000000..b6f6758 --- /dev/null +++ b/mlbTVOS/ViewModels/GameCenterViewModel.swift @@ -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 + } +} diff --git a/mlbTVOS/ViewModels/GamesViewModel.swift b/mlbTVOS/ViewModels/GamesViewModel.swift new file mode 100644 index 0000000..00d5cf9 --- /dev/null +++ b/mlbTVOS/ViewModels/GamesViewModel.swift @@ -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? + + // 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 +} diff --git a/mlbTVOS/ViewModels/LeagueCenterViewModel.swift b/mlbTVOS/ViewModels/LeagueCenterViewModel.swift new file mode 100644 index 0000000..fb7dd1e --- /dev/null +++ b/mlbTVOS/ViewModels/LeagueCenterViewModel.swift @@ -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 + }() +} diff --git a/mlbTVOS/Views/Components/LinescoreView.swift b/mlbTVOS/Views/Components/LinescoreView.swift new file mode 100644 index 0000000..0a64c2e --- /dev/null +++ b/mlbTVOS/Views/Components/LinescoreView.swift @@ -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 + } + } +} diff --git a/mlbTVOS/Views/Components/LiveIndicator.swift b/mlbTVOS/Views/Components/LiveIndicator.swift new file mode 100644 index 0000000..f53151e --- /dev/null +++ b/mlbTVOS/Views/Components/LiveIndicator.swift @@ -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() + } + } +} diff --git a/mlbTVOS/Views/Components/PitcherHeadshotView.swift b/mlbTVOS/Views/Components/PitcherHeadshotView.swift new file mode 100644 index 0000000..64622d3 --- /dev/null +++ b/mlbTVOS/Views/Components/PitcherHeadshotView.swift @@ -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) + } + } +} diff --git a/mlbTVOS/Views/Components/ScoreOverlayView.swift b/mlbTVOS/Views/Components/ScoreOverlayView.swift new file mode 100644 index 0000000..3400f93 --- /dev/null +++ b/mlbTVOS/Views/Components/ScoreOverlayView.swift @@ -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)) + } + } +} diff --git a/mlbTVOS/Views/Components/ScoresTickerView.swift b/mlbTVOS/Views/Components/ScoresTickerView.swift new file mode 100644 index 0000000..d1b7fec --- /dev/null +++ b/mlbTVOS/Views/Components/ScoresTickerView.swift @@ -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") + } +} diff --git a/mlbTVOS/Views/Components/TeamLogoView.swift b/mlbTVOS/Views/Components/TeamLogoView.swift new file mode 100644 index 0000000..b4b65e4 --- /dev/null +++ b/mlbTVOS/Views/Components/TeamLogoView.swift @@ -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) + } + } +} diff --git a/mlbTVOS/Views/ContentView.swift b/mlbTVOS/Views/ContentView.swift new file mode 100644 index 0000000..bd3f10f --- /dev/null +++ b/mlbTVOS/Views/ContentView.swift @@ -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() + } + } +} diff --git a/mlbTVOS/Views/DashboardView.swift b/mlbTVOS/Views/DashboardView.swift new file mode 100644 index 0000000..edb30f3 --- /dev/null +++ b/mlbTVOS/Views/DashboardView.swift @@ -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 + } +} diff --git a/mlbTVOS/Views/FeaturedGameCard.swift b/mlbTVOS/Views/FeaturedGameCard.swift new file mode 100644 index 0000000..9e2707b --- /dev/null +++ b/mlbTVOS/Views/FeaturedGameCard.swift @@ -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) + } +} diff --git a/mlbTVOS/Views/GameCardView.swift b/mlbTVOS/Views/GameCardView.swift new file mode 100644 index 0000000..3ab1abf --- /dev/null +++ b/mlbTVOS/Views/GameCardView.swift @@ -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) + } +} diff --git a/mlbTVOS/Views/GameCenterView.swift b/mlbTVOS/Views/GameCenterView.swift new file mode 100644 index 0000000..dffc2b1 --- /dev/null +++ b/mlbTVOS/Views/GameCenterView.swift @@ -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) + } + } +} diff --git a/mlbTVOS/Views/GameListView.swift b/mlbTVOS/Views/GameListView.swift new file mode 100644 index 0000000..a1d9236 --- /dev/null +++ b/mlbTVOS/Views/GameListView.swift @@ -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) + } + } +} diff --git a/mlbTVOS/Views/LeagueCenterView.swift b/mlbTVOS/Views/LeagueCenterView.swift new file mode 100644 index 0000000..bdab2f3 --- /dev/null +++ b/mlbTVOS/Views/LeagueCenterView.swift @@ -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) + } + } +} diff --git a/mlbTVOS/Views/MLBNetworkSheet.swift b/mlbTVOS/Views/MLBNetworkSheet.swift new file mode 100644 index 0000000..2f9a8eb --- /dev/null +++ b/mlbTVOS/Views/MLBNetworkSheet.swift @@ -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) + } + } +} diff --git a/mlbTVOS/Views/MultiStreamView.swift b/mlbTVOS/Views/MultiStreamView.swift new file mode 100644 index 0000000..44cbe70 --- /dev/null +++ b/mlbTVOS/Views/MultiStreamView.swift @@ -0,0 +1,1257 @@ +import AVFoundation +import AVKit +import OSLog +import SwiftUI + +private struct StreamSelection: Identifiable { + let id: String +} + +private let multiViewLogger = Logger(subsystem: "com.treyt.mlbTVOS", category: "MultiView") + +private func logMultiView(_ message: String) { + multiViewLogger.debug("\(message, privacy: .public)") + print("[MultiView] \(message)") +} + +private func multiViewStatusDescription(_ status: AVPlayer.Status) -> String { + switch status { + case .unknown: "unknown" + case .readyToPlay: "readyToPlay" + case .failed: "failed" + @unknown default: "unknown-future" + } +} + +private func multiViewItemStatusDescription(_ status: AVPlayerItem.Status) -> String { + switch status { + case .unknown: "unknown" + case .readyToPlay: "readyToPlay" + case .failed: "failed" + @unknown default: "unknown-future" + } +} + +private func multiViewTimeControlDescription(_ status: AVPlayer.TimeControlStatus) -> String { + switch status { + case .paused: "paused" + case .waitingToPlayAtSpecifiedRate: "waitingToPlayAtSpecifiedRate" + case .playing: "playing" + @unknown default: "unknown-future" + } +} + +struct MultiStreamView: View { + @Environment(GamesViewModel.self) private var viewModel + @State private var selectedStream: StreamSelection? + @State private var showFullScreen = false + + var body: some View { + ZStack(alignment: .bottom) { + LinearGradient( + colors: [ + Color(red: 0.03, green: 0.04, blue: 0.08), + Color(red: 0.02, green: 0.05, blue: 0.1), + Color(red: 0.01, green: 0.02, blue: 0.05), + ], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + .ignoresSafeArea() + + if viewModel.activeStreams.isEmpty { + emptyState + } else { + VStack(spacing: 0) { + toolbar + .padding(.horizontal, 40) + .padding(.top, 26) + .padding(.bottom, 18) + + MultiViewCanvas( + contentInsets: 18, + gap: 14, + videoGravity: .resizeAspectFill, + cornerRadius: 22, + onSelect: { stream in + selectedStream = StreamSelection(id: stream.id) + } + ) + .padding(.horizontal, 18) + .padding(.bottom, 82) + } + + ScoresTickerView() + .allowsHitTesting(false) + .padding(.horizontal, 18) + .padding(.bottom, 14) + } + } + .fullScreenCover(isPresented: $showFullScreen) { + MultiStreamFullScreenView() + } + .sheet(item: $selectedStream) { selection in + StreamControlSheet( + streamID: selection.id, + onRemove: { + viewModel.removeStream(id: selection.id) + selectedStream = nil + } + ) + } + .onAppear { + viewModel.startAutoRefresh() + } + .onDisappear { + viewModel.stopAutoRefresh() + } + } + + private var emptyState: some View { + VStack(spacing: 24) { + Image(systemName: "rectangle.split.2x2") + .font(.system(size: 82, weight: .light)) + .foregroundStyle(.white.opacity(0.14)) + + Text("No Active Streams") + .font(.system(size: 30, weight: .bold, design: .rounded)) + .foregroundStyle(.white.opacity(0.9)) + + Text("Add broadcasts from Games to build a quadbox, then pick the main tile and live audio here.") + .font(.system(size: 18, weight: .medium)) + .foregroundStyle(.white.opacity(0.38)) + .multilineTextAlignment(.center) + .frame(maxWidth: 560) + } + .padding(40) + } + + private var toolbar: some View { + VStack(alignment: .leading, spacing: 18) { + HStack(alignment: .top, spacing: 24) { + VStack(alignment: .leading, spacing: 10) { + Text("Multi-View 2.0") + .font(.system(size: 32, weight: .bold, design: .rounded)) + .foregroundStyle(.white) + + Text("Choose the main tile, route audio to the game you want, and reorder feeds without leaving the grid.") + .font(.system(size: 15, weight: .medium)) + .foregroundStyle(.white.opacity(0.58)) + } + + Spacer() + + HStack(spacing: 10) { + infoChip( + title: "\(viewModel.activeStreams.count)/4", + icon: "rectangle.split.2x2", + tint: .white + ) + infoChip( + title: viewModel.multiViewLayoutMode.title, + icon: viewModel.multiViewLayoutMode.systemImage, + tint: .blue + ) + infoChip( + title: viewModel.activeAudioStream?.label ?? "All Muted", + icon: viewModel.activeAudioStream == nil ? "speaker.slash.fill" : "speaker.wave.2.fill", + tint: viewModel.activeAudioStream == nil ? .white : .green + ) + } + } + + HStack(spacing: 14) { + MultiViewLayoutPicker(compact: false) + + Spacer() + + Button { + showFullScreen = true + } label: { + toolbarButtonLabel( + title: "Full Screen", + icon: "arrow.up.left.and.arrow.down.right", + tint: .white, + destructive: false + ) + } + .buttonStyle(.card) + + Button { + viewModel.clearAllStreams() + } label: { + toolbarButtonLabel( + title: "Clear All", + icon: "xmark", + tint: .red, + destructive: true + ) + } + .buttonStyle(.card) + } + } + } + + private func infoChip(title: String, icon: String, tint: Color) -> some View { + HStack(spacing: 8) { + Image(systemName: icon) + .font(.system(size: 12, weight: .bold)) + Text(title) + .font(.system(size: 14, weight: .semibold)) + .lineLimit(1) + } + .foregroundStyle(tint.opacity(0.95)) + .padding(.horizontal, 12) + .padding(.vertical, 9) + .background( + RoundedRectangle(cornerRadius: 999) + .fill(.white.opacity(0.08)) + ) + } + + private func toolbarButtonLabel(title: String, icon: String, tint: Color, destructive: Bool) -> some View { + HStack(spacing: 8) { + Image(systemName: icon) + .font(.system(size: 13, weight: .bold)) + Text(title) + .font(.system(size: 15, weight: .semibold)) + } + .foregroundStyle(tint.opacity(0.9)) + .padding(.horizontal, 18) + .padding(.vertical, 12) + .background( + RoundedRectangle(cornerRadius: 999) + .fill(destructive ? .red.opacity(0.12) : .white.opacity(0.08)) + ) + } +} + +private struct MultiViewCanvas: View { + @Environment(GamesViewModel.self) private var viewModel + @FocusState private var focusedStreamID: String? + + let contentInsets: CGFloat + let gap: CGFloat + let videoGravity: AVLayerVideoGravity + let cornerRadius: CGFloat + let onSelect: (ActiveStream) -> Void + + var body: some View { + GeometryReader { geo in + let frames = multiViewFrames( + count: viewModel.activeStreams.count, + mode: viewModel.multiViewLayoutMode, + size: geo.size, + inset: contentInsets, + gap: gap + ) + let focusEntries = Array(viewModel.activeStreams.enumerated()).compactMap { index, stream -> MultiViewFocusEntry? in + guard index < frames.count else { return nil } + return MultiViewFocusEntry(streamID: stream.id, frame: frames[index]) + } + + ZStack(alignment: .topLeading) { + ForEach(Array(viewModel.activeStreams.enumerated()), id: \.element.id) { index, stream in + if index < frames.count { + let frame = frames[index] + MultiStreamTile( + stream: stream, + position: index + 1, + isPrimary: viewModel.isPrimaryStream(stream.id), + isAudioFocused: viewModel.audioFocusStreamID == stream.id, + isFocused: focusedStreamID == stream.id, + showsPrimaryBadge: viewModel.multiViewLayoutMode == .spotlight + && viewModel.isPrimaryStream(stream.id) + && viewModel.activeStreams.count > 1, + videoGravity: videoGravity, + cornerRadius: cornerRadius, + onSelect: { onSelect(stream) } + ) + .frame(width: frame.width, height: frame.height) + .position(x: frame.midX, y: frame.midY) + .focused($focusedStreamID, equals: stream.id) + } + } + } + .focusSection() + .onMoveCommand { direction in + let currentID = focusedStreamID ?? viewModel.audioFocusStreamID ?? viewModel.activeStreams.first?.id + if let nextID = nextMultiViewFocusID( + from: currentID, + direction: direction, + entries: focusEntries + ) { + focusedStreamID = nextID + } + } + } + .onAppear { + if focusedStreamID == nil { + focusedStreamID = viewModel.audioFocusStreamID ?? viewModel.activeStreams.first?.id + } + } + .onChange(of: viewModel.activeStreams.map(\.id)) { _, streamIDs in + if let focusedStreamID, streamIDs.contains(focusedStreamID) { + return + } + self.focusedStreamID = viewModel.audioFocusStreamID ?? streamIDs.first + } + .onChange(of: focusedStreamID) { _, streamID in + guard let streamID else { return } + if viewModel.audioFocusStreamID != streamID { + viewModel.setAudioFocus(streamID: streamID) + } + } + } +} + +private struct MultiStreamTile: View { + let stream: ActiveStream + let position: Int + let isPrimary: Bool + let isAudioFocused: Bool + let isFocused: Bool + let showsPrimaryBadge: Bool + let videoGravity: AVLayerVideoGravity + let cornerRadius: CGFloat + let onSelect: () -> Void + + @Environment(GamesViewModel.self) private var viewModel + @State private var player: AVPlayer? + @State private var hasError = false + @State private var startupPlaybackTask: Task? + @State private var qualityUpgradeTask: Task? + @StateObject private var playbackDiagnostics = MultiStreamPlaybackDiagnostics() + + var body: some View { + ZStack { + videoLayer + + LinearGradient( + colors: [ + .black.opacity(0.68), + .black.opacity(0.08), + .black.opacity(0.52), + ], + startPoint: .top, + endPoint: .bottom + ) + + VStack(spacing: 0) { + HStack(alignment: .top, spacing: 10) { + HStack(spacing: 10) { + Text("\(position)") + .font(.system(size: 12, weight: .bold, design: .rounded)) + .foregroundStyle(.white.opacity(0.95)) + .frame(width: 24, height: 24) + .background(.black.opacity(0.58)) + .clipShape(Circle()) + + Text(stream.label) + .font(.system(size: 16, weight: .bold, design: .rounded)) + .foregroundStyle(.white) + .lineLimit(1) + } + .padding(.horizontal, 12) + .padding(.vertical, 9) + .background(.black.opacity(0.52)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + + Spacer() + + HStack(spacing: 8) { + if showsPrimaryBadge { + tileBadge(title: "MAIN", color: .blue) + } + if isAudioFocused { + tileBadge(title: "AUDIO", color: .green) + } else { + tileBadge(title: "MUTED", color: .white.opacity(0.7)) + } + } + } + + Spacer() + + HStack { + Spacer() + + if let statusText = tileStatusText, !statusText.isEmpty { + Text(statusText) + .font(.system(size: 13, weight: .semibold, design: .rounded)) + .foregroundStyle(.white.opacity(0.82)) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(.black.opacity(0.48)) + .clipShape(Capsule()) + } + } + } + .padding(14) + } + .background(.black) + .overlay(tileBorder) + .clipShape(RoundedRectangle(cornerRadius: cornerRadius)) + .contentShape(RoundedRectangle(cornerRadius: cornerRadius)) + .scaleEffect(isFocused ? 1.025 : 1.0) + .shadow( + color: isFocused ? .blue.opacity(0.22) : isAudioFocused ? .green.opacity(0.22) : .black.opacity(0.28), + radius: isFocused ? 26 : 20, + y: 10 + ) + .focusEffectDisabled() + .focusable(true) + .animation(.easeOut(duration: 0.18), value: isFocused) + .onAppear { + logMultiView("tile appeared id=\(stream.id) label=\(stream.label)") + } + .onDisappear { + logMultiView("tile disappeared id=\(stream.id) label=\(stream.label)") + startupPlaybackTask?.cancel() + startupPlaybackTask = nil + qualityUpgradeTask?.cancel() + qualityUpgradeTask = nil + playbackDiagnostics.clear(streamID: stream.id, reason: "tile disappeared") + } + .onPlayPauseCommand { + if player?.rate == 0 { + player?.play() + } else { + player?.pause() + } + } + .onTapGesture { + onSelect() + } + .task(id: stream.id) { + await startStream() + } + } + + @ViewBuilder + private var videoLayer: some View { + if let player { + MultiStreamPlayerLayerView(player: player, streamID: stream.id, videoGravity: videoGravity) + .onAppear { + logMultiView("videoLayer showing player id=\(stream.id) rate=\(player.rate) muted=\(player.isMuted) gravity=\(videoGravity.rawValue)") + } + } else if hasError { + Color.black.overlay { + VStack(spacing: 10) { + Image(systemName: "exclamationmark.triangle.fill") + .font(.system(size: 28)) + .foregroundStyle(.red.opacity(0.9)) + Text("Stream unavailable") + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(.white.opacity(0.82)) + } + } + } else { + Color.black.overlay { + VStack(spacing: 10) { + ProgressView() + Text("Loading feed") + .font(.system(size: 15, weight: .semibold)) + .foregroundStyle(.white.opacity(0.72)) + } + } + } + } + + private var tileBorder: some View { + RoundedRectangle(cornerRadius: cornerRadius) + .stroke( + isAudioFocused ? .green.opacity(0.95) : isFocused ? .blue.opacity(0.9) : isPrimary ? .white.opacity(0.28) : .white.opacity(0.12), + lineWidth: isAudioFocused || isFocused ? 3 : isPrimary ? 2 : 1 + ) + } + + private var tileStatusText: String? { + if stream.game.isLive { + return stream.game.currentInningDisplay ?? stream.game.status.label + } + if stream.game.isFinal { + return "Final" + } + if stream.game.status.isScheduled { + return stream.game.startTime ?? stream.game.status.label + } + return stream.game.status.label + } + + private func tileBadge(title: String, color: Color) -> some View { + Text(title) + .font(.system(size: 11, weight: .bold, design: .rounded)) + .foregroundStyle(color.opacity(0.98)) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(.black.opacity(0.5)) + .clipShape(Capsule()) + } + + private var multiViewStartupResolution: String { "504p" } + + private var multiViewUpgradeTargetResolution: String? { + let desiredResolution = viewModel.defaultResolution + return desiredResolution == multiViewStartupResolution ? nil : desiredResolution + } + + private func startStream() async { + logMultiView("startStream begin id=\(stream.id) label=\(stream.label) hasInlinePlayer=\(player != nil) hasSharedPlayer=\(stream.player != nil) hasOverrideURL=\(stream.overrideURL != nil)") + + if let player { + player.isMuted = viewModel.audioFocusStreamID != stream.id + playbackDiagnostics.attach(to: player, streamID: stream.id, label: stream.label) + scheduleStartupPlaybackRecovery(for: player) + scheduleQualityUpgrade(for: player) + logMultiView("startStream reused inline player id=\(stream.id) muted=\(player.isMuted)") + return + } + + do { + try AVAudioSession.sharedInstance().setCategory(.playback) + try AVAudioSession.sharedInstance().setActive(true) + logMultiView("startStream audio session configured id=\(stream.id)") + } catch { + logMultiView("startStream audio session failed id=\(stream.id) error=\(error.localizedDescription)") + } + + if let existingPlayer = stream.player { + existingPlayer.isMuted = viewModel.audioFocusStreamID != stream.id + self.player = existingPlayer + hasError = false + playbackDiagnostics.attach(to: existingPlayer, streamID: stream.id, label: stream.label) + scheduleStartupPlaybackRecovery(for: existingPlayer) + scheduleQualityUpgrade(for: existingPlayer) + logMultiView("startStream reused shared player id=\(stream.id) muted=\(existingPlayer.isMuted)") + return + } + + let url: URL? + if let overrideURL = stream.overrideURL { + url = overrideURL + logMultiView("startStream using override URL id=\(stream.id) url=\(overrideURL.absoluteString)") + } else { + url = await viewModel.resolveStreamURL( + for: stream, + resolutionOverride: multiViewStartupResolution, + preserveServerResolutionWhenBest: false + ) + } + guard let url else { + hasError = true + logMultiView("startStream failed id=\(stream.id) resolveStreamURL returned nil") + return + } + + logMultiView("startStream creating AVPlayer id=\(stream.id) url=\(url.absoluteString)") + let avPlayer = AVPlayer(url: url) + avPlayer.automaticallyWaitsToMinimizeStalling = false + avPlayer.currentItem?.preferredForwardBufferDuration = 2 + + self.player = avPlayer + playbackDiagnostics.attach(to: avPlayer, streamID: stream.id, label: stream.label) + viewModel.attachPlayer(avPlayer, to: stream.id) + scheduleStartupPlaybackRecovery(for: avPlayer) + scheduleQualityUpgrade(for: avPlayer) + logMultiView("startStream attached player id=\(stream.id) muted=\(avPlayer.isMuted) startupResolution=\(multiViewStartupResolution) fastStart=true calling playImmediately(atRate: 1.0)") + avPlayer.playImmediately(atRate: 1.0) + } + + private func scheduleStartupPlaybackRecovery(for player: AVPlayer) { + startupPlaybackTask?.cancel() + + let streamID = stream.id + let label = stream.label + startupPlaybackTask = Task { @MainActor in + let retryDelays: [Double] = [0.35, 1.0, 2.0, 4.0] + + for delay in retryDelays { + try? await Task.sleep(for: .seconds(delay)) + guard !Task.isCancelled else { return } + guard let currentPlayer = self.player, currentPlayer === player else { + logMultiView("startupRecovery abort id=\(streamID) label=\(label) reason=player-changed") + return + } + + let itemStatus = multiViewItemStatusDescription(player.currentItem?.status ?? .unknown) + let likelyToKeepUp = player.currentItem?.isPlaybackLikelyToKeepUp ?? false + let bufferEmpty = player.currentItem?.isPlaybackBufferEmpty ?? false + let timeControl = multiViewTimeControlDescription(player.timeControlStatus) + let startupSatisfied = player.rate > 0 && (itemStatus == "readyToPlay" || likelyToKeepUp) + logMultiView( + "startupRecovery check id=\(streamID) delay=\(delay)s rate=\(player.rate) timeControl=\(timeControl) itemStatus=\(itemStatus) likelyToKeepUp=\(likelyToKeepUp) bufferEmpty=\(bufferEmpty)" + ) + + if startupSatisfied { + logMultiView("startupRecovery satisfied id=\(streamID) delay=\(delay)s") + return + } + + if player.rate == 0 { + logMultiView("startupRecovery replay id=\(streamID) delay=\(delay)s") + player.playImmediately(atRate: 1.0) + } + } + } + } + + private func scheduleQualityUpgrade(for player: AVPlayer) { + qualityUpgradeTask?.cancel() + + guard stream.overrideURL == nil else { + logMultiView("qualityUpgrade skip id=\(stream.id) reason=override-url") + return + } + + guard let targetResolution = multiViewUpgradeTargetResolution else { + logMultiView("qualityUpgrade skip id=\(stream.id) reason=target-already-\(multiViewStartupResolution)") + return + } + + let streamID = stream.id + let label = stream.label + qualityUpgradeTask = Task { @MainActor in + let checkDelays: [Double] = [2.0, 4.0, 7.0] + + for delay in checkDelays { + try? await Task.sleep(for: .seconds(delay)) + guard !Task.isCancelled else { return } + guard let currentPlayer = self.player, currentPlayer === player else { + logMultiView("qualityUpgrade abort id=\(streamID) label=\(label) reason=player-changed") + return + } + + let itemStatus = multiViewItemStatusDescription(player.currentItem?.status ?? .unknown) + let likelyToKeepUp = player.currentItem?.isPlaybackLikelyToKeepUp ?? false + let bufferEmpty = player.currentItem?.isPlaybackBufferEmpty ?? false + let currentResolution = currentStreamResolution(for: player) ?? "unknown" + let stable = (itemStatus == "readyToPlay" || likelyToKeepUp) && !bufferEmpty + + logMultiView( + "qualityUpgrade check id=\(streamID) delay=\(delay)s currentResolution=\(currentResolution) targetResolution=\(targetResolution) stable=\(stable) rate=\(player.rate)" + ) + + guard stable else { continue } + + if currentResolution == targetResolution { + logMultiView("qualityUpgrade skip id=\(streamID) reason=already-\(targetResolution)") + return + } + + guard let upgradedURL = await viewModel.resolveStreamURL( + for: stream, + resolutionOverride: targetResolution, + preserveServerResolutionWhenBest: false + ) else { + logMultiView("qualityUpgrade failed id=\(streamID) targetResolution=\(targetResolution) reason=resolve-nil") + return + } + + if let currentURL = currentStreamURL(for: player), currentURL == upgradedURL { + logMultiView("qualityUpgrade skip id=\(streamID) reason=same-url targetResolution=\(targetResolution)") + return + } + + logMultiView("qualityUpgrade begin id=\(streamID) targetResolution=\(targetResolution) url=\(upgradedURL.absoluteString)") + let upgradedItem = AVPlayerItem(url: upgradedURL) + upgradedItem.preferredForwardBufferDuration = 4 + player.replaceCurrentItem(with: upgradedItem) + player.automaticallyWaitsToMinimizeStalling = false + playbackDiagnostics.attach(to: player, streamID: streamID, label: label) + viewModel.attachPlayer(player, to: streamID) + scheduleStartupPlaybackRecovery(for: player) + logMultiView("qualityUpgrade replay id=\(streamID) targetResolution=\(targetResolution)") + player.playImmediately(atRate: 1.0) + return + } + + logMultiView("qualityUpgrade timeout id=\(streamID) targetResolution=\(targetResolution)") + } + } + + private func currentStreamURL(for player: AVPlayer) -> URL? { + (player.currentItem?.asset as? AVURLAsset)?.url + } + + private func currentStreamResolution(for player: AVPlayer) -> String? { + guard let url = currentStreamURL(for: player) else { return nil } + return URLComponents(url: url, resolvingAgainstBaseURL: false)? + .queryItems? + .first(where: { $0.name == "resolution" })? + .value + } +} + +private struct MultiStreamPlayerLayerView: UIViewRepresentable { + let player: AVPlayer + let streamID: String + let videoGravity: AVLayerVideoGravity + + func makeUIView(context: Context) -> MultiStreamPlayerLayerContainerView { + let view = MultiStreamPlayerLayerContainerView(videoGravity: videoGravity) + view.playerLayer.player = player + logMultiView("playerLayer makeUIView id=\(streamID) rate=\(player.rate) gravity=\(videoGravity.rawValue)") + return view + } + + func updateUIView(_ uiView: MultiStreamPlayerLayerContainerView, context: Context) { + if uiView.playerLayer.player !== player { + uiView.playerLayer.player = player + logMultiView("playerLayer updateUIView reassigned player id=\(streamID) rate=\(player.rate)") + } + if uiView.playerLayer.videoGravity != videoGravity { + uiView.playerLayer.videoGravity = videoGravity + logMultiView("playerLayer updateUIView changed gravity id=\(streamID) gravity=\(videoGravity.rawValue)") + } + } + + static func dismantleUIView(_ uiView: MultiStreamPlayerLayerContainerView, coordinator: ()) { + uiView.playerLayer.player = nil + } +} + +private final class MultiStreamPlayerLayerContainerView: UIView { + override class var layerClass: AnyClass { AVPlayerLayer.self } + + var playerLayer: AVPlayerLayer { + layer as! AVPlayerLayer + } + + init(videoGravity: AVLayerVideoGravity) { + super.init(frame: .zero) + backgroundColor = .black + playerLayer.videoGravity = videoGravity + } + + override init(frame: CGRect) { + super.init(frame: frame) + backgroundColor = .black + playerLayer.videoGravity = .resizeAspectFill + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +private final class MultiStreamPlaybackDiagnostics: ObservableObject { + private var playerObservations: [NSKeyValueObservation] = [] + private var notificationTokens: [NSObjectProtocol] = [] + private var attachedPlayerIdentifier: ObjectIdentifier? + private var attachedItemIdentifier: ObjectIdentifier? + + func attach(to player: AVPlayer, streamID: String, label: String) { + let playerIdentifier = ObjectIdentifier(player) + let itemIdentifier = player.currentItem.map { ObjectIdentifier($0) } + if attachedPlayerIdentifier == playerIdentifier, attachedItemIdentifier == itemIdentifier { + return + } + + clear(streamID: streamID, reason: "reattach") + attachedPlayerIdentifier = playerIdentifier + attachedItemIdentifier = itemIdentifier + + logMultiView("diagnostics attach id=\(streamID) label=\(label) playerRate=\(player.rate)") + + playerObservations.append( + player.observe(\.status, options: [.initial, .new]) { player, _ in + logMultiView("player status id=\(streamID) status=\(multiViewStatusDescription(player.status)) error=\(player.error?.localizedDescription ?? "nil")") + } + ) + + playerObservations.append( + player.observe(\.timeControlStatus, options: [.initial, .new]) { player, _ in + let reason = player.reasonForWaitingToPlay?.rawValue ?? "nil" + logMultiView("player timeControl id=\(streamID) status=\(multiViewTimeControlDescription(player.timeControlStatus)) reason=\(reason) rate=\(player.rate)") + } + ) + + playerObservations.append( + player.observe(\.reasonForWaitingToPlay, options: [.initial, .new]) { player, _ in + logMultiView("player waitingReason id=\(streamID) value=\(player.reasonForWaitingToPlay?.rawValue ?? "nil")") + } + ) + + guard let item = player.currentItem else { + logMultiView("diagnostics attach id=\(streamID) missing currentItem") + return + } + + playerObservations.append( + item.observe(\.status, options: [.initial, .new]) { item, _ in + logMultiView("playerItem status id=\(streamID) status=\(multiViewItemStatusDescription(item.status)) error=\(item.error?.localizedDescription ?? "nil")") + } + ) + + playerObservations.append( + item.observe(\.isPlaybackBufferEmpty, options: [.initial, .new]) { item, _ in + logMultiView("playerItem bufferEmpty id=\(streamID) value=\(item.isPlaybackBufferEmpty)") + } + ) + + playerObservations.append( + item.observe(\.isPlaybackLikelyToKeepUp, options: [.initial, .new]) { item, _ in + logMultiView("playerItem likelyToKeepUp id=\(streamID) value=\(item.isPlaybackLikelyToKeepUp)") + } + ) + + playerObservations.append( + item.observe(\.isPlaybackBufferFull, options: [.initial, .new]) { item, _ in + logMultiView("playerItem bufferFull id=\(streamID) value=\(item.isPlaybackBufferFull)") + } + ) + + notificationTokens.append( + NotificationCenter.default.addObserver( + forName: .AVPlayerItemPlaybackStalled, + object: item, + queue: .main + ) { _ in + logMultiView("playerItem stalled id=\(streamID)") + } + ) + + notificationTokens.append( + NotificationCenter.default.addObserver( + forName: .AVPlayerItemFailedToPlayToEndTime, + object: item, + queue: .main + ) { notification in + let error = notification.userInfo?[AVPlayerItemFailedToPlayToEndTimeErrorKey] as? NSError + logMultiView("playerItem failedToEnd id=\(streamID) error=\(error?.localizedDescription ?? "nil")") + } + ) + + notificationTokens.append( + NotificationCenter.default.addObserver( + forName: .AVPlayerItemNewErrorLogEntry, + object: item, + queue: .main + ) { _ in + let event = item.errorLog()?.events.last + logMultiView("playerItem errorLog id=\(streamID) domain=\(event?.errorDomain ?? "nil") statusCode=\(event?.errorStatusCode ?? 0) comment=\(event?.errorComment ?? "nil")") + } + ) + + notificationTokens.append( + NotificationCenter.default.addObserver( + forName: .AVPlayerItemNewAccessLogEntry, + object: item, + queue: .main + ) { _ in + if let event = item.accessLog()?.events.last { + logMultiView("playerItem accessLog id=\(streamID) indicatedBitrate=\(Int(event.indicatedBitrate)) observedBitrate=\(Int(event.observedBitrate)) transferDuration=\(event.transferDuration)") + } else { + logMultiView("playerItem accessLog id=\(streamID) missing event") + } + } + ) + } + + func clear(streamID: String, reason: String) { + if !playerObservations.isEmpty || !notificationTokens.isEmpty { + logMultiView("diagnostics clear id=\(streamID) reason=\(reason)") + } + playerObservations.removeAll() + for token in notificationTokens { + NotificationCenter.default.removeObserver(token) + } + notificationTokens.removeAll() + attachedPlayerIdentifier = nil + attachedItemIdentifier = nil + } + + deinit { + playerObservations.removeAll() + for token in notificationTokens { + NotificationCenter.default.removeObserver(token) + } + notificationTokens.removeAll() + } +} + +private struct MultiViewLayoutPicker: View { + @Environment(GamesViewModel.self) private var viewModel + let compact: Bool + + var body: some View { + HStack(spacing: compact ? 8 : 10) { + ForEach(MultiViewLayoutMode.allCases) { mode in + Button { + viewModel.multiViewLayoutMode = mode + } label: { + HStack(spacing: 8) { + Image(systemName: mode.systemImage) + .font(.system(size: compact ? 11 : 12, weight: .bold)) + Text(mode.title) + .font(.system(size: compact ? 13 : 14, weight: .semibold)) + } + .foregroundStyle(viewModel.multiViewLayoutMode == mode ? .white : .white.opacity(0.62)) + .padding(.horizontal, compact ? 12 : 14) + .padding(.vertical, compact ? 9 : 10) + .background( + RoundedRectangle(cornerRadius: 999) + .fill(viewModel.multiViewLayoutMode == mode ? .blue.opacity(0.28) : .white.opacity(0.06)) + ) + .overlay( + RoundedRectangle(cornerRadius: 999) + .stroke(viewModel.multiViewLayoutMode == mode ? .blue.opacity(0.9) : .white.opacity(0.12), lineWidth: 1) + ) + } + .buttonStyle(.card) + } + } + } +} + +struct StreamControlSheet: View { + let streamID: String + let onRemove: () -> Void + + @Environment(GamesViewModel.self) private var viewModel + @Environment(\.dismiss) private var dismiss + + private var stream: ActiveStream? { + viewModel.activeStreams.first { $0.id == streamID } + } + + private var isPrimary: Bool { + viewModel.isPrimaryStream(streamID) + } + + private var isAudioFocused: Bool { + viewModel.audioFocusStreamID == streamID + } + + var body: some View { + ZStack { + LinearGradient( + colors: [ + Color(red: 0.05, green: 0.06, blue: 0.1), + Color(red: 0.03, green: 0.04, blue: 0.08), + ], + startPoint: .topLeading, + endPoint: .bottomTrailing + ) + .ignoresSafeArea() + + if let stream { + VStack(alignment: .leading, spacing: 26) { + VStack(alignment: .leading, spacing: 10) { + Text(stream.label) + .font(.system(size: 30, weight: .bold, design: .rounded)) + .foregroundStyle(.white) + + Text(stream.game.displayTitle) + .font(.system(size: 17, weight: .medium)) + .foregroundStyle(.white.opacity(0.6)) + + HStack(spacing: 10) { + controlBadge(title: isPrimary ? "Primary Tile" : "Secondary Tile", tint: .blue) + controlBadge(title: isAudioFocused ? "Live Audio" : "Muted", tint: isAudioFocused ? .green : .white) + } + } + + VStack(spacing: 14) { + HStack(spacing: 14) { + actionCard( + title: isAudioFocused ? "Mute All" : "Listen Here", + subtitle: isAudioFocused ? "Silence the multiview mix." : "Route game audio to this tile.", + icon: isAudioFocused ? "speaker.slash.fill" : "speaker.wave.2.fill", + tint: isAudioFocused ? .white : .green, + disabled: false + ) { + viewModel.toggleAudioFocus(streamID: streamID) + } + + actionCard( + title: isPrimary ? "Already Main" : "Make Main Tile", + subtitle: "Spotlight layout always favors the first stream.", + icon: "arrow.up.left.and.arrow.down.right", + tint: .blue, + disabled: isPrimary + ) { + viewModel.promoteStream(id: streamID) + } + } + + HStack(spacing: 14) { + actionCard( + title: "Move Earlier", + subtitle: "Shift this feed toward the front of the order.", + icon: "arrow.left", + tint: .white, + disabled: !viewModel.canMoveStream(id: streamID, direction: -1) + ) { + viewModel.moveStream(id: streamID, direction: -1) + } + + actionCard( + title: "Move Later", + subtitle: "Shift this feed deeper into the stack.", + icon: "arrow.right", + tint: .white, + disabled: !viewModel.canMoveStream(id: streamID, direction: 1) + ) { + viewModel.moveStream(id: streamID, direction: 1) + } + } + + actionCard( + title: "Remove from Multi-View", + subtitle: "Close this feed and free one slot immediately.", + icon: "trash.fill", + tint: .red, + disabled: false, + destructive: true, + wide: true + ) { + onRemove() + } + } + + Spacer() + } + .padding(44) + } else { + VStack(spacing: 18) { + Text("Stream Removed") + .font(.system(size: 28, weight: .bold, design: .rounded)) + .foregroundStyle(.white) + Text("This feed is no longer active.") + .font(.system(size: 17, weight: .medium)) + .foregroundStyle(.white.opacity(0.6)) + } + .padding(40) + } + } + } + + private func controlBadge(title: String, tint: Color) -> some View { + Text(title) + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(tint.opacity(0.95)) + .padding(.horizontal, 12) + .padding(.vertical, 8) + .background(.white.opacity(0.08)) + .clipShape(Capsule()) + } + + private func actionCard( + title: String, + subtitle: String, + icon: String, + tint: Color, + disabled: Bool, + destructive: Bool = false, + wide: Bool = false, + action: @escaping () -> Void + ) -> some View { + Button { + action() + dismiss() + } label: { + VStack(alignment: .leading, spacing: 14) { + Image(systemName: icon) + .font(.system(size: 22, weight: .bold)) + .foregroundStyle(disabled ? .white.opacity(0.28) : tint.opacity(0.95)) + + VStack(alignment: .leading, spacing: 6) { + Text(title) + .font(.system(size: 20, weight: .bold, design: .rounded)) + .foregroundStyle(disabled ? .white.opacity(0.32) : .white) + Text(subtitle) + .font(.system(size: 14, weight: .medium)) + .foregroundStyle(.white.opacity(disabled ? 0.2 : 0.5)) + } + } + .frame(maxWidth: .infinity, minHeight: wide ? 108 : 168, alignment: .leading) + .padding(22) + .background( + RoundedRectangle(cornerRadius: 22) + .fill(destructive ? .red.opacity(0.12) : .white.opacity(0.06)) + ) + .overlay( + RoundedRectangle(cornerRadius: 22) + .stroke(disabled ? .white.opacity(0.08) : tint.opacity(0.28), lineWidth: 1) + ) + } + .disabled(disabled) + .buttonStyle(.card) + } +} + +struct MultiStreamFullScreenView: View { + @Environment(GamesViewModel.self) private var viewModel + @Environment(\.dismiss) private var dismiss + @State private var selectedStream: StreamSelection? + + var body: some View { + ZStack(alignment: .bottom) { + Color.black.ignoresSafeArea() + + MultiViewCanvas( + contentInsets: 10, + gap: 10, + videoGravity: .resizeAspect, + cornerRadius: 12, + onSelect: { stream in + selectedStream = StreamSelection(id: stream.id) + } + ) + .ignoresSafeArea() + + ScoresTickerView() + .allowsHitTesting(false) + .padding(.horizontal, 18) + .padding(.bottom, 12) + } + .onExitCommand { + dismiss() + } + .onChange(of: viewModel.activeStreams.count) { _, count in + if count == 0 { + dismiss() + } + } + .sheet(item: $selectedStream) { selection in + StreamControlSheet( + streamID: selection.id, + onRemove: { + viewModel.removeStream(id: selection.id) + selectedStream = nil + if viewModel.activeStreams.isEmpty { + dismiss() + } + } + ) + } + .onAppear { + viewModel.startAutoRefresh() + } + } +} + +private func multiViewFrames( + count: Int, + mode: MultiViewLayoutMode, + size: CGSize, + inset: CGFloat, + gap: CGFloat +) -> [CGRect] { + let width = max(size.width - (inset * 2), 0) + let height = max(size.height - (inset * 2), 0) + + switch (mode, count) { + case (_, 0): + return [] + case (_, 1): + return [CGRect(x: inset, y: inset, width: width, height: height)] + case (.spotlight, 2): + let primaryWidth = width * 0.66 + let secondaryWidth = width - primaryWidth - gap + return [ + CGRect(x: inset, y: inset, width: primaryWidth, height: height), + CGRect(x: inset + primaryWidth + gap, y: inset, width: secondaryWidth, height: height), + ] + case (.spotlight, 3): + let primaryWidth = width * 0.66 + let railWidth = width - primaryWidth - gap + let railHeight = (height - gap) / 2 + return [ + CGRect(x: inset, y: inset, width: primaryWidth, height: height), + CGRect(x: inset + primaryWidth + gap, y: inset, width: railWidth, height: railHeight), + CGRect(x: inset + primaryWidth + gap, y: inset + railHeight + gap, width: railWidth, height: railHeight), + ] + case (.spotlight, _): + let primaryWidth = width * 0.68 + let railWidth = width - primaryWidth - gap + let railHeight = (height - (gap * 2)) / 3 + return [ + CGRect(x: inset, y: inset, width: primaryWidth, height: height), + CGRect(x: inset + primaryWidth + gap, y: inset, width: railWidth, height: railHeight), + CGRect(x: inset + primaryWidth + gap, y: inset + railHeight + gap, width: railWidth, height: railHeight), + CGRect(x: inset + primaryWidth + gap, y: inset + (railHeight * 2) + (gap * 2), width: railWidth, height: railHeight), + ] + case (.balanced, 2): + let cellWidth = (width - gap) / 2 + return [ + CGRect(x: inset, y: inset, width: cellWidth, height: height), + CGRect(x: inset + cellWidth + gap, y: inset, width: cellWidth, height: height), + ] + case (.balanced, 3): + let cellWidth = (width - gap) / 2 + let cellHeight = (height - gap) / 2 + return [ + CGRect(x: inset, y: inset, width: cellWidth, height: cellHeight), + CGRect(x: inset + cellWidth + gap, y: inset, width: cellWidth, height: cellHeight), + CGRect(x: inset, y: inset + cellHeight + gap, width: width, height: cellHeight), + ] + default: + let cellWidth = (width - gap) / 2 + let cellHeight = (height - gap) / 2 + return [ + CGRect(x: inset, y: inset, width: cellWidth, height: cellHeight), + CGRect(x: inset + cellWidth + gap, y: inset, width: cellWidth, height: cellHeight), + CGRect(x: inset, y: inset + cellHeight + gap, width: cellWidth, height: cellHeight), + CGRect(x: inset + cellWidth + gap, y: inset + cellHeight + gap, width: cellWidth, height: cellHeight), + ] + } +} + +private struct MultiViewFocusEntry { + let streamID: String + let frame: CGRect +} + +private func nextMultiViewFocusID( + from currentID: String?, + direction: MoveCommandDirection, + entries: [MultiViewFocusEntry] +) -> String? { + guard !entries.isEmpty else { return nil } + + guard + let currentID, + let currentEntry = entries.first(where: { $0.streamID == currentID }) + else { + return entries.first?.streamID + } + + let candidates: [(entry: MultiViewFocusEntry, primaryDistance: CGFloat, secondaryDistance: CGFloat)] = entries.compactMap { candidate in + guard candidate.streamID != currentEntry.streamID else { return nil } + + let xDelta = candidate.frame.midX - currentEntry.frame.midX + let yDelta = candidate.frame.midY - currentEntry.frame.midY + + switch direction { + case .left: + guard xDelta < 0 else { return nil } + return (candidate, abs(xDelta), abs(yDelta)) + case .right: + guard xDelta > 0 else { return nil } + return (candidate, abs(xDelta), abs(yDelta)) + case .up: + guard yDelta < 0 else { return nil } + return (candidate, abs(yDelta), abs(xDelta)) + case .down: + guard yDelta > 0 else { return nil } + return (candidate, abs(yDelta), abs(xDelta)) + default: + return nil + } + } + + return candidates + .sorted { lhs, rhs in + if abs(lhs.primaryDistance - rhs.primaryDistance) > 1 { + return lhs.primaryDistance < rhs.primaryDistance + } + return lhs.secondaryDistance < rhs.secondaryDistance + } + .first? + .entry + .streamID +} diff --git a/mlbTVOS/Views/SettingsView.swift b/mlbTVOS/Views/SettingsView.swift new file mode 100644 index 0000000..223315b --- /dev/null +++ b/mlbTVOS/Views/SettingsView.swift @@ -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") + } + } +} diff --git a/mlbTVOS/Views/SingleStreamPlayerView.swift b/mlbTVOS/Views/SingleStreamPlayerView.swift new file mode 100644 index 0000000..933b5a6 --- /dev/null +++ b/mlbTVOS/Views/SingleStreamPlayerView.swift @@ -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() + } + } +} diff --git a/mlbTVOS/mlbTVOS.entitlements b/mlbTVOS/mlbTVOS.entitlements new file mode 100644 index 0000000..4d799ed --- /dev/null +++ b/mlbTVOS/mlbTVOS.entitlements @@ -0,0 +1,8 @@ + + + + + com.apple.developer.avfoundation.multitasking-camera-access + + + diff --git a/mlbTVOS/mlbTVOSApp.swift b/mlbTVOS/mlbTVOSApp.swift new file mode 100644 index 0000000..8067369 --- /dev/null +++ b/mlbTVOS/mlbTVOSApp.swift @@ -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)") + } + } +} diff --git a/project.yml b/project.yml new file mode 100644 index 0000000..5ac849e --- /dev/null +++ b/project.yml @@ -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