diff --git a/README.md b/README.md new file mode 100644 index 0000000..ffd07b3 --- /dev/null +++ b/README.md @@ -0,0 +1,91 @@ +# mlbTVOS + +Personal MLB streaming app for Apple TV and iPhone/iPad, built with SwiftUI. +Streams live and archived MLB games, plus a few non-MLB channels +(MLB Network, an authenticated private feed). + +## Targets + +- `mlbTVOS` — tvOS application (primary) +- `mlbIOS` — iOS/iPadOS application (shares most sources with the tvOS target) +- `mlbTVOSTests` — Swift Testing unit tests + +The Xcode project is generated from `project.yml` via +[XcodeGen](https://github.com/yonaskolb/XcodeGen). After editing +`project.yml` or adding/removing source files, run: + +```sh +xcodegen generate +``` + +## Building + +Requires Xcode 26.3+ and tvOS/iOS 18.0 SDKs. + +```sh +# tvOS +xcodebuild build -project mlbTVOS.xcodeproj -scheme mlbTVOS \ + -destination 'platform=tvOS Simulator,name=Apple TV 4K (3rd generation)' + +# Tests +xcodebuild test -project mlbTVOS.xcodeproj -scheme mlbTVOSTests \ + -destination 'platform=tvOS Simulator,name=Apple TV 4K (3rd generation)' +``` + +## Features + +### Dashboard +Today's live/scheduled/final games, standings ticker, and special channel +entry points. Tapping a game opens the Game Center with at-bat timeline, +pitch sequence, spray chart, and strike zone overlays. + +### Multi-stream view +Up to four simultaneous streams in an adaptive grid. Only one stream holds +audio focus at a time; tap any tile to transfer focus. Streams can be +single broadcasts, team-filtered feeds, or special channels. + +### Single-stream player +Full-screen AVPlayer with HLS adaptive streaming, custom HTTP headers, +and automatic retry on failure. + +### Special playback channels +- **MLB Network** — 24/7 channel via the public MLB stream +- **Werkout NSFW** — authenticated private feed served by + [`ofapp`](https://gitea.treytartt.com) on the home server; uses a JWT + cookie and rotates clips through a per-model random shuffle + +### Video shuffle (per-model random) +`VideoShuffle` groups clips by source folder (e.g. which model/creator +the video came from) and picks randomly one bucket at a time, excluding +the previously-played bucket. This guarantees **no back-to-back** same +source and produces a roughly uniform distribution across sources even +when one source dominates the raw data by a large factor. See +`mlbTVOS/Services/VideoShuffle.swift` and +`mlbTVOSTests/VideoShuffleTests.swift` for the pure functions and +their test coverage. + +## Architecture + +- **`mlbTVOS/mlbTVOSApp.swift`** — app entry point, creates a single + `GamesViewModel` injected into the environment +- **`mlbTVOS/ViewModels/GamesViewModel.swift`** — observable state store + for games, standings, active streams, audio focus, and video shuffle + bags; fetches from MLB Stats API and the auxiliary stream service +- **`mlbTVOS/ViewModels/GameCenterViewModel.swift`** — per-game live + data (at-bats, pitches, box score) used by the Game Center screen +- **`mlbTVOS/Services/`** — API clients (`MLBStatsAPI`, + `MLBServerAPI`) and pure helpers (`VideoShuffle`) +- **`mlbTVOS/Views/`** — SwiftUI screens and component library + (`Components/AtBatTimelineView`, `PitchSequenceView`, `SprayChartView`, + `StrikeZoneView`, etc.) + +All new Swift code uses Swift 6 strict concurrency +(`SWIFT_STRICT_CONCURRENCY: complete`). + +## Configuration + +No secret management is set up. The authenticated feed cookie is +hardcoded in `mlbTVOS/Views/DashboardView.swift` +(`SpecialPlaybackChannelConfig.werkoutNSFWCookie`) and must be refreshed +manually when the JWT expires — generate a new one from the `ofapp` +server's `jwt_secret` using the same `userId` and a longer `exp`. diff --git a/mlbTVOS.xcodeproj/project.pbxproj b/mlbTVOS.xcodeproj/project.pbxproj index 9bc531c..6177703 100644 --- a/mlbTVOS.xcodeproj/project.pbxproj +++ b/mlbTVOS.xcodeproj/project.pbxproj @@ -7,147 +7,149 @@ objects = { /* Begin PBXBuildFile section */ - 02BA6240A20E957B5337A545 /* Game.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1ABC999C711B074CF0B536DD /* Game.swift */; }; 051C3D14A06061D44E325FCC /* LinescoreView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 327A33F2676712C2B5B4AF2F /* LinescoreView.swift */; }; + 0653C2F5CC55F4234E8950E5 /* LiveIndicator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 81584770090BAFBEF3ABE6FD /* LiveIndicator.swift */; }; + 07BBD0E7B7F122213708A406 /* LeagueCenterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55E6F6797DA08FA657C04818 /* LeagueCenterView.swift */; }; + 08A22DE07ECE074D17D8A74E /* FeaturedGameCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 102E5E8674E188A04637D3EB /* FeaturedGameCard.swift */; }; 0B1C43E83CA856543F58E16A /* MultiStreamView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D10F2D91602CE1FB6D7342E /* MultiStreamView.swift */; }; - 11640185C392B0406BBE619D /* MLBServerAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 70E610D24BA83FB2F7490B00 /* MLBServerAPI.swift */; }; - 1555D223058B858E4C6F419D /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FB99FBEDA096B95C3D2E5B4 /* ContentView.swift */; }; - 1AA11AA11AA11AA11AA11AA1 /* GameCenterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2BB22BB22BB22BB22BB22BB1 /* GameCenterView.swift */; }; - 1AA11AA11AA11AA11AA11AA2 /* LeagueCenterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2BB22BB22BB22BB22BB22BB2 /* LeagueCenterView.swift */; }; - 1AA11AA11AA11AA11AA11AA3 /* GameCenterViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2BB22BB22BB22BB22BB22BB3 /* GameCenterViewModel.swift */; }; - 1AA11AA11AA11AA11AA11AA4 /* LeagueCenterViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2BB22BB22BB22BB22BB22BB4 /* LeagueCenterViewModel.swift */; }; 1E31C6F500CEEA5284081D22 /* MLBServerAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 70E610D24BA83FB2F7490B00 /* MLBServerAPI.swift */; }; - 1E5405E1C27A16971961091C /* ScoreOverlayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5F6A7B8C9D0E1F2A3B4C5D6 /* ScoreOverlayView.swift */; }; - 22B1A4EB0E8E279B054FEC3E /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E04B78C78BA9DA73B62AA3EE /* Foundation.framework */; }; - 24C6D5F4A3B291807F6E5D4C /* ScoresTickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 24C6D5F4A3B291807F6E5D4B /* ScoresTickerView.swift */; }; + 20A122430083142C3C74AF5F /* TeamLogoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2E99F69727800D0CB795503 /* TeamLogoView.swift */; }; + 26AE6D91B829B233CEDAE7DF /* AtBatTimelineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3D1C29BF9DF2B8F811368BC /* AtBatTimelineView.swift */; }; 29627CA5EE85BA103B38D1F5 /* Game.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1ABC999C711B074CF0B536DD /* Game.swift */; }; - 31353216A6288D986544F61E /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DE9E708FA75ABB9F49D2392 /* SettingsView.swift */; }; + 2A6155FFEE85643ABDE4111A /* VideoShuffle.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3A003EA560655E4B2200DBE /* VideoShuffle.swift */; }; + 2B52AC228AE12CC671348964 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DE9E708FA75ABB9F49D2392 /* SettingsView.swift */; }; 342EED600E1A5B4A3D083E50 /* TeamAssets.swift in Sources */ = {isa = PBXBuildFile; fileRef = 645E77DD8183D54EFC97CFB5 /* TeamAssets.swift */; }; - 37BFF8231552B49E4775AA59 /* GamesViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9BD2DEA6BBA55E86B2555099 /* GamesViewModel.swift */; }; - 3FB9BCB07B6DBFE6D1D2C523 /* GameCenterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2BB22BB22BB22BB22BB22BB1 /* GameCenterView.swift */; }; - 4318098424F9B3FF3B256EED /* LeagueCenterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2BB22BB22BB22BB22BB22BB2 /* LeagueCenterView.swift */; }; - 43AC37B4F957CDC3986F044A /* mlbIOSApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0209C78036DA08FA794D8D9 /* mlbIOSApp.swift */; }; - 43B16E04582A874E87514C30 /* mlbIOSRootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F874FDD20F82AFEB475CD73 /* mlbIOSRootView.swift */; }; + 3679F9F4949C8B3B8D5D6E94 /* GameCenterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E80EF8B01452220FA6BCD9B2 /* GameCenterView.swift */; }; + 36E4F79F23EF867A100E924D /* PitchSequenceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7F13A88254E53AF5A4AAA2AA /* PitchSequenceView.swift */; }; + 468EB7F6EC98D83099E2C352 /* PlatformUI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B4E350321B6673B42545860 /* PlatformUI.swift */; }; + 470E17740A5C54C6BF2C1C97 /* StrikeZoneView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E345FE2F997DC60C7A3E9158 /* StrikeZoneView.swift */; }; + 49B79787C58CA43F2C1F9A9C /* ScoreOverlayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 60B7F01667B2EBFBDFC1BBF6 /* ScoreOverlayView.swift */; }; + 49FB2101E46F2DAD10BC6201 /* SprayChartView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B14F1577DCCDBEF7858E6BE5 /* SprayChartView.swift */; }; + 4CB947BC5B5ECF83EF84F7E4 /* LinescoreView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 327A33F2676712C2B5B4AF2F /* LinescoreView.swift */; }; 4F67D44E9A36BED9CD8724CF /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FB99FBEDA096B95C3D2E5B4 /* ContentView.swift */; }; + 4FB2E354854C1408419634BB /* ScoresTickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 339D5F94415E8C018D384CDE /* ScoresTickerView.swift */; }; + 50D870AF95561129D706C411 /* GameListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B5D51D6E4990515411D3BCD /* GameListView.swift */; }; + 512148AC1C1F35F5CB688F98 /* DashboardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5EA4C33F029665B80F1A6B6D /* DashboardView.swift */; }; 52498A59FF923A1494A0139B /* GameListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B5D51D6E4990515411D3BCD /* GameListView.swift */; }; - 5A76A7BB97FFA089156A0ABA /* PlatformUI.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBA78A938A07E07B064CEA54 /* PlatformUI.swift */; }; + 58B5982C6D8DC6F41EAC5CCD /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 7B361C2076C86F2EE841B2AB /* Assets.xcassets */; }; 5DFD3805A76DDE49B0DC1E2D /* SingleStreamPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA4AF68CDF4FC8D07194B4D0 /* SingleStreamPlayerView.swift */; }; + 5EBF83734395FCF7BCE1E26B /* MLBStatsAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6067FE4F340FC0DCCFA62C67 /* MLBStatsAPI.swift */; }; + 5ED9CAF1722802A4AD561617 /* mlbIOSRootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4B8E51B59B366184CDCCED5 /* mlbIOSRootView.swift */; }; + 5F5AF9B045B8F2BA87CBECD3 /* ScoreOverlayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 60B7F01667B2EBFBDFC1BBF6 /* ScoreOverlayView.swift */; }; 63AF55498896C9BBCD1D4337 /* GameCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E21E3115B8A9B4C798ECE828 /* GameCardView.swift */; }; - 7D2D21DAE73958578FE2E730 /* LeagueCenterViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2BB22BB22BB22BB22BB22BB4 /* LeagueCenterViewModel.swift */; }; + 65ABD3574B88BDDBB206B1DC /* GamesViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9BD2DEA6BBA55E86B2555099 /* GamesViewModel.swift */; }; + 6CD8E1E4B6A4C5D0F0F9299E /* MultiStreamView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D10F2D91602CE1FB6D7342E /* MultiStreamView.swift */; }; + 6E502A815BB3C150E56E7E38 /* PlatformUI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B4E350321B6673B42545860 /* PlatformUI.swift */; }; + 718E7200B5BBB22CB48C2EF8 /* mlbIOSApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CC4A28B180001C2A1B0BA5F /* mlbIOSApp.swift */; }; + 769DF65BBCC400691B98C71F /* AtBatTimelineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3D1C29BF9DF2B8F811368BC /* AtBatTimelineView.swift */; }; 80A690C52C9C8140E7C519E8 /* TeamLogoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2E99F69727800D0CB795503 /* TeamLogoView.swift */; }; 820FB03D0E97C521BA239071 /* MLBNetworkSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5152ABCC9AACFD91BAC8C95B /* MLBNetworkSheet.swift */; }; - 82DEB54FF3BFD51A18ED98A1 /* MLBStatsAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6067FE4F340FC0DCCFA62C67 /* MLBStatsAPI.swift */; }; - 8D5FE07166FAD17836A0AFF4 /* PitcherHeadshotView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4E5F6A7B8C9D0E1F2A3B4C5 /* PitcherHeadshotView.swift */; }; + 8649D4F513A663D2493D91C9 /* LeagueCenterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55E6F6797DA08FA657C04818 /* LeagueCenterView.swift */; }; + 8801888DFD48351B1D344D45 /* MLBServerAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 70E610D24BA83FB2F7490B00 /* MLBServerAPI.swift */; }; + 8E2F3FAAE778FE6F44C0B5D5 /* PitcherHeadshotView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C6E303B753C604504C2762C7 /* PitcherHeadshotView.swift */; }; + 95046231282AA1B67F4B5A8D /* SprayChartView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B14F1577DCCDBEF7858E6BE5 /* SprayChartView.swift */; }; + 9525FA984562C90A677AD453 /* GameCenterViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5DA48863884BB6B4983BEE6 /* GameCenterViewModel.swift */; }; + 96250318C3F0491D095DDAA9 /* ScoresTickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 339D5F94415E8C018D384CDE /* ScoresTickerView.swift */; }; 9D29D3508CD05CB3F4975910 /* GamesViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9BD2DEA6BBA55E86B2555099 /* GamesViewModel.swift */; }; - A1B2C3D4E5F60718091A2B3C /* PitcherHeadshotView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4E5F6A7B8C9D0E1F2A3B4C5 /* PitcherHeadshotView.swift */; }; - A506FDC86C3A78F4A2C04C10 /* GameListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B5D51D6E4990515411D3BCD /* GameListView.swift */; }; - AFA76F6EBE7977914A849447 /* FeaturedGameCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 102E5E8674E188A04637D3EB /* FeaturedGameCard.swift */; }; - B215941F5A269C59C0939958 /* TeamLogoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2E99F69727800D0CB795503 /* TeamLogoView.swift */; }; - B2C3D4E5F6A70819A1B2C3D4 /* ScoreOverlayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5F6A7B8C9D0E1F2A3B4C5D6 /* ScoreOverlayView.swift */; }; - B921B07F43A13CBBDE9C499D /* GameCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E21E3115B8A9B4C798ECE828 /* GameCardView.swift */; }; - BA1B3240360F79FE2DF42543 /* LinescoreView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 327A33F2676712C2B5B4AF2F /* LinescoreView.swift */; }; - BB48CC4C732672536CD5EF96 /* TeamAssets.swift in Sources */ = {isa = PBXBuildFile; fileRef = 645E77DD8183D54EFC97CFB5 /* TeamAssets.swift */; }; + A70F5B83F079279CF2B41FCE /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FB99FBEDA096B95C3D2E5B4 /* ContentView.swift */; }; + A7195DD696E4C07218D74F19 /* PitcherHeadshotView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C6E303B753C604504C2762C7 /* PitcherHeadshotView.swift */; }; + AAD1562E79743AA0B09CB857 /* GameCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E21E3115B8A9B4C798ECE828 /* GameCardView.swift */; }; + ACEB18A23D20F79AA757DD63 /* Game.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1ABC999C711B074CF0B536DD /* Game.swift */; }; + AEE0045C962DDF789E61429B /* TeamAssets.swift in Sources */ = {isa = PBXBuildFile; fileRef = 645E77DD8183D54EFC97CFB5 /* TeamAssets.swift */; }; + AF6279D4B9E386009C27EC23 /* VideoShuffleTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A5568E639232B59AEB84FD4 /* VideoShuffleTests.swift */; }; + B0A9FFD5C20A80E16532CC91 /* LeagueCenterViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E9E572D932D96FE2C5BD7A4 /* LeagueCenterViewModel.swift */; }; + B345E89FC7B8C263B067E082 /* GameCenterViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5DA48863884BB6B4983BEE6 /* GameCenterViewModel.swift */; }; + B730E81141D5686CD7C6DE4F /* VideoShuffle.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3A003EA560655E4B2200DBE /* VideoShuffle.swift */; }; C1DA213471DC246B8DF3F840 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = AC5BCB2E1DA0EC63E422B113 /* Assets.xcassets */; }; - C67442678865108796537BBC /* GameCenterViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2BB22BB22BB22BB22BB22BB3 /* GameCenterViewModel.swift */; }; - CA20CF293D75E6058E0CB78C /* SingleStreamPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA4AF68CDF4FC8D07194B4D0 /* SingleStreamPlayerView.swift */; }; - D199D622D64E3489E049B04A /* LiveIndicator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 81584770090BAFBEF3ABE6FD /* LiveIndicator.swift */; }; - D48041F6CFBCEB2F3492747F /* MultiStreamView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D10F2D91602CE1FB6D7342E /* MultiStreamView.swift */; }; - DA156E98ACD90B582F3BBAB2 /* PlatformUI.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBA78A938A07E07B064CEA54 /* PlatformUI.swift */; }; - DB6E6A4890D0D22526DA5D96 /* ScoresTickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 24C6D5F4A3B291807F6E5D4B /* ScoresTickerView.swift */; }; + D688F6B0680C29AECD289CA9 /* SingleStreamPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA4AF68CDF4FC8D07194B4D0 /* SingleStreamPlayerView.swift */; }; + D864A2844FD222A04B48F6EF /* MLBNetworkSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5152ABCC9AACFD91BAC8C95B /* MLBNetworkSheet.swift */; }; DC37555DE8C367E929974484 /* MLBStatsAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6067FE4F340FC0DCCFA62C67 /* MLBStatsAPI.swift */; }; + E42B27D852AB037246167C23 /* LeagueCenterViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E9E572D932D96FE2C5BD7A4 /* LeagueCenterViewModel.swift */; }; + E6A3D6E740DBE329B8EAD531 /* StrikeZoneView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E345FE2F997DC60C7A3E9158 /* StrikeZoneView.swift */; }; E7FB366456557069990F550C /* LiveIndicator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 81584770090BAFBEF3ABE6FD /* LiveIndicator.swift */; }; + E96459544473FC4A3CD92B4F /* GameCenterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E80EF8B01452220FA6BCD9B2 /* GameCenterView.swift */; }; + F096F11642B32BF240C367A5 /* PitchSequenceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7F13A88254E53AF5A4AAA2AA /* PitchSequenceView.swift */; }; F36A71E407BC707B8E03457D /* DashboardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5EA4C33F029665B80F1A6B6D /* DashboardView.swift */; }; - F60F24246B08223DC2BA5825 /* DashboardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5EA4C33F029665B80F1A6B6D /* DashboardView.swift */; }; - F761076F826FEB86E972D516 /* MLBNetworkSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5152ABCC9AACFD91BAC8C95B /* MLBNetworkSheet.swift */; }; FAFA9B29273C4D8A54CD5916 /* FeaturedGameCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 102E5E8674E188A04637D3EB /* FeaturedGameCard.swift */; }; FD31074216AC0115B3F1C057 /* mlbTVOSApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 45142562E644AF04F7719083 /* mlbTVOSApp.swift */; }; FD9322BED3A2B827CB021163 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DE9E708FA75ABB9F49D2392 /* SettingsView.swift */; }; - FE80BDA15CC8CE2F18DB4883 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 56358D0D5DC5CC531034287F /* Assets.xcassets */; }; /* End PBXBuildFile section */ +/* Begin PBXContainerItemProxy section */ + BB037F390E873F3105CC93DC /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 89437C3F6A1DA0DA4ADEA3AB /* Project object */; + proxyType = 1; + remoteGlobalIDString = 6ACA532AEDED1D5945D6153A; + remoteInfo = mlbTVOS; + }; +/* End PBXContainerItemProxy section */ + /* Begin PBXFileReference section */ 0B5D51D6E4990515411D3BCD /* GameListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameListView.swift; sourceTree = ""; }; + 0FE1BD4A8517243AFABE371C /* mlbIOS.app */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.application; path = mlbIOS.app; sourceTree = BUILT_PRODUCTS_DIR; }; 102E5E8674E188A04637D3EB /* FeaturedGameCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeaturedGameCard.swift; sourceTree = ""; }; + 166FABBD2D739048BB1A86A6 /* mlbIOS.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = mlbIOS.entitlements; 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 = ""; }; + 2CC4A28B180001C2A1B0BA5F /* mlbIOSApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = mlbIOSApp.swift; sourceTree = ""; }; 327A33F2676712C2B5B4AF2F /* LinescoreView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinescoreView.swift; sourceTree = ""; }; - 3F874FDD20F82AFEB475CD73 /* mlbIOSRootView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = mlbIOSRootView.swift; sourceTree = ""; }; + 339D5F94415E8C018D384CDE /* ScoresTickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScoresTickerView.swift; sourceTree = ""; }; 3FB99FBEDA096B95C3D2E5B4 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; 45142562E644AF04F7719083 /* mlbTVOSApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = mlbTVOSApp.swift; sourceTree = ""; }; 4DE9E708FA75ABB9F49D2392 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = ""; }; 5152ABCC9AACFD91BAC8C95B /* MLBNetworkSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MLBNetworkSheet.swift; sourceTree = ""; }; - 55A3DC0B5087BAD6BC59B35A /* mlbIOS.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = mlbIOS.app; sourceTree = BUILT_PRODUCTS_DIR; }; - 56358D0D5DC5CC531034287F /* Assets.xcassets */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 55E6F6797DA08FA657C04818 /* LeagueCenterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LeagueCenterView.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 = ""; }; + 60B7F01667B2EBFBDFC1BBF6 /* ScoreOverlayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScoreOverlayView.swift; sourceTree = ""; }; 645E77DD8183D54EFC97CFB5 /* TeamAssets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeamAssets.swift; sourceTree = ""; }; + 6FE64B2FA917EF98B16A3286 /* mlbTVOSTests.xctest */ = {isa = PBXFileReference; includeInIndex = 0; lastKnownFileType = wrapper.cfbundle; path = mlbTVOSTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 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; }; + 7B361C2076C86F2EE841B2AB /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; + 7E9E572D932D96FE2C5BD7A4 /* LeagueCenterViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LeagueCenterViewModel.swift; sourceTree = ""; }; + 7F13A88254E53AF5A4AAA2AA /* PitchSequenceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PitchSequenceView.swift; sourceTree = ""; }; 81584770090BAFBEF3ABE6FD /* LiveIndicator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveIndicator.swift; sourceTree = ""; }; + 8A5568E639232B59AEB84FD4 /* VideoShuffleTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoShuffleTests.swift; sourceTree = ""; }; + 8B4E350321B6673B42545860 /* PlatformUI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlatformUI.swift; sourceTree = ""; }; 8D10F2D91602CE1FB6D7342E /* MultiStreamView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultiStreamView.swift; sourceTree = ""; }; - 95C4DEDEAA64C74AF3DB24F2 /* mlbIOS.entitlements */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.entitlements; path = mlbIOS.entitlements; sourceTree = ""; }; 9BD2DEA6BBA55E86B2555099 /* GamesViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GamesViewModel.swift; sourceTree = ""; }; - A6FE9AFE34BB8CE4F33B62D0 /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; AC5BCB2E1DA0EC63E422B113 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; - BBA78A938A07E07B064CEA54 /* PlatformUI.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PlatformUI.swift; sourceTree = ""; }; + B14F1577DCCDBEF7858E6BE5 /* SprayChartView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SprayChartView.swift; sourceTree = ""; }; + B4B8E51B59B366184CDCCED5 /* mlbIOSRootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = mlbIOSRootView.swift; sourceTree = ""; }; C1F593C51BC11444A3D514D3 /* mlbTVOS.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = mlbTVOS.entitlements; sourceTree = ""; }; C2E99F69727800D0CB795503 /* TeamLogoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeamLogoView.swift; sourceTree = ""; }; - D4E5F6A7B8C9D0E1F2A3B4C5 /* PitcherHeadshotView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PitcherHeadshotView.swift; sourceTree = ""; }; - E04B78C78BA9DA73B62AA3EE /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS14.0.sdk/System/Library/Frameworks/Foundation.framework; sourceTree = DEVELOPER_DIR; }; + C3A003EA560655E4B2200DBE /* VideoShuffle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoShuffle.swift; sourceTree = ""; }; + C3D1C29BF9DF2B8F811368BC /* AtBatTimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AtBatTimelineView.swift; sourceTree = ""; }; + C6E303B753C604504C2762C7 /* 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 = ""; }; - F0209C78036DA08FA794D8D9 /* mlbIOSApp.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = mlbIOSApp.swift; sourceTree = ""; }; + E345FE2F997DC60C7A3E9158 /* StrikeZoneView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StrikeZoneView.swift; sourceTree = ""; }; + E462EB259532ADD83A9534B5 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; + E80EF8B01452220FA6BCD9B2 /* GameCenterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameCenterView.swift; sourceTree = ""; }; + F5DA48863884BB6B4983BEE6 /* GameCenterViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameCenterViewModel.swift; sourceTree = ""; }; FA4AF68CDF4FC8D07194B4D0 /* SingleStreamPlayerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SingleStreamPlayerView.swift; sourceTree = ""; }; /* End PBXFileReference section */ -/* Begin PBXFrameworksBuildPhase section */ - EF6C5375C48E5C8359230651 /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - 22B1A4EB0E8E279B054FEC3E /* Foundation.framework in Frameworks */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; -/* End PBXFrameworksBuildPhase section */ - /* Begin PBXGroup section */ - 0B56828FEC5E5902FE078143 /* iOS */ = { - isa = PBXGroup; - children = ( - E04B78C78BA9DA73B62AA3EE /* Foundation.framework */, - ); - name = iOS; - sourceTree = ""; - }; - 1646E84AFB7C540A2D228B64 /* mlbIOS */ = { - isa = PBXGroup; - children = ( - 96E8DA91A0CDD10D8953C8BA /* Views */, - A6FE9AFE34BB8CE4F33B62D0 /* Info.plist */, - 95C4DEDEAA64C74AF3DB24F2 /* mlbIOS.entitlements */, - 56358D0D5DC5CC531034287F /* Assets.xcassets */, - F0209C78036DA08FA794D8D9 /* mlbIOSApp.swift */, - ); - name = mlbIOS; - path = mlbIOS; - sourceTree = ""; - }; 2C171EA64FFA962254F20583 /* ViewModels */ = { isa = PBXGroup; children = ( - 2BB22BB22BB22BB22BB22BB3 /* GameCenterViewModel.swift */, + F5DA48863884BB6B4983BEE6 /* GameCenterViewModel.swift */, 9BD2DEA6BBA55E86B2555099 /* GamesViewModel.swift */, - 2BB22BB22BB22BB22BB22BB4 /* LeagueCenterViewModel.swift */, + 7E9E572D932D96FE2C5BD7A4 /* LeagueCenterViewModel.swift */, ); path = ViewModels; sourceTree = ""; }; + 2C7BE69056BC157DD7B0EA69 /* Views */ = { + isa = PBXGroup; + children = ( + B4B8E51B59B366184CDCCED5 /* mlbIOSRootView.swift */, + ); + path = Views; + sourceTree = ""; + }; 4FAE5C2D72CD78BF7C15C4FC /* mlbTVOS */ = { isa = PBXGroup; children = ( @@ -163,61 +165,70 @@ path = mlbTVOS; sourceTree = ""; }; - 5AE491A6B2C1767EFEF5CE88 /* Frameworks */ = { - isa = PBXGroup; - children = ( - 0B56828FEC5E5902FE078143 /* iOS */, - ); - name = Frameworks; - sourceTree = ""; - }; 62F95C3E8B0F6AFE3BEDE9C3 /* Components */ = { isa = PBXGroup; children = ( + C3D1C29BF9DF2B8F811368BC /* AtBatTimelineView.swift */, 327A33F2676712C2B5B4AF2F /* LinescoreView.swift */, 81584770090BAFBEF3ABE6FD /* LiveIndicator.swift */, - D4E5F6A7B8C9D0E1F2A3B4C5 /* PitcherHeadshotView.swift */, - 24C6D5F4A3B291807F6E5D4B /* ScoresTickerView.swift */, - E5F6A7B8C9D0E1F2A3B4C5D6 /* ScoreOverlayView.swift */, + C6E303B753C604504C2762C7 /* PitcherHeadshotView.swift */, + 7F13A88254E53AF5A4AAA2AA /* PitchSequenceView.swift */, + 8B4E350321B6673B42545860 /* PlatformUI.swift */, + 60B7F01667B2EBFBDFC1BBF6 /* ScoreOverlayView.swift */, + 339D5F94415E8C018D384CDE /* ScoresTickerView.swift */, + B14F1577DCCDBEF7858E6BE5 /* SprayChartView.swift */, + E345FE2F997DC60C7A3E9158 /* StrikeZoneView.swift */, C2E99F69727800D0CB795503 /* TeamLogoView.swift */, - BBA78A938A07E07B064CEA54 /* PlatformUI.swift */, ); path = Components; sourceTree = ""; }; + 6CCF8450F0096AAE94F8E0EF /* mlbIOS */ = { + isa = PBXGroup; + children = ( + 7B361C2076C86F2EE841B2AB /* Assets.xcassets */, + E462EB259532ADD83A9534B5 /* Info.plist */, + 166FABBD2D739048BB1A86A6 /* mlbIOS.entitlements */, + 2CC4A28B180001C2A1B0BA5F /* mlbIOSApp.swift */, + 2C7BE69056BC157DD7B0EA69 /* Views */, + ); + path = mlbIOS; + sourceTree = ""; + }; + 6DA200592E30D8963373A30C /* mlbTVOSTests */ = { + isa = PBXGroup; + children = ( + 8A5568E639232B59AEB84FD4 /* VideoShuffleTests.swift */, + ); + path = mlbTVOSTests; + sourceTree = ""; + }; 86C1117ADD32A1A57AA32A08 = { isa = PBXGroup; children = ( + 6CCF8450F0096AAE94F8E0EF /* mlbIOS */, 4FAE5C2D72CD78BF7C15C4FC /* mlbTVOS */, + 6DA200592E30D8963373A30C /* mlbTVOSTests */, 88274023705E1F09B25F86FF /* Products */, - 1646E84AFB7C540A2D228B64 /* mlbIOS */, - 5AE491A6B2C1767EFEF5CE88 /* Frameworks */, ); sourceTree = ""; }; 88274023705E1F09B25F86FF /* Products */ = { isa = PBXGroup; children = ( + 0FE1BD4A8517243AFABE371C /* mlbIOS.app */, 766441BC900073529EE93D69 /* mlbTVOS.app */, - 55A3DC0B5087BAD6BC59B35A /* mlbIOS.app */, + 6FE64B2FA917EF98B16A3286 /* mlbTVOSTests.xctest */, ); name = Products; sourceTree = ""; }; - 96E8DA91A0CDD10D8953C8BA /* Views */ = { - isa = PBXGroup; - children = ( - 3F874FDD20F82AFEB475CD73 /* mlbIOSRootView.swift */, - ); - name = Views; - path = Views; - sourceTree = ""; - }; C6DC4CC5AAB9AF84D3CB9F3B /* Services */ = { isa = PBXGroup; children = ( 70E610D24BA83FB2F7490B00 /* MLBServerAPI.swift */, 6067FE4F340FC0DCCFA62C67 /* MLBStatsAPI.swift */, + C3A003EA560655E4B2200DBE /* VideoShuffle.swift */, ); path = Services; sourceTree = ""; @@ -229,9 +240,9 @@ 5EA4C33F029665B80F1A6B6D /* DashboardView.swift */, 102E5E8674E188A04637D3EB /* FeaturedGameCard.swift */, E21E3115B8A9B4C798ECE828 /* GameCardView.swift */, - 2BB22BB22BB22BB22BB22BB1 /* GameCenterView.swift */, + E80EF8B01452220FA6BCD9B2 /* GameCenterView.swift */, 0B5D51D6E4990515411D3BCD /* GameListView.swift */, - 2BB22BB22BB22BB22BB22BB2 /* LeagueCenterView.swift */, + 55E6F6797DA08FA657C04818 /* LeagueCenterView.swift */, 5152ABCC9AACFD91BAC8C95B /* MLBNetworkSheet.swift */, 8D10F2D91602CE1FB6D7342E /* MultiStreamView.swift */, 4DE9E708FA75ABB9F49D2392 /* SettingsView.swift */, @@ -253,6 +264,42 @@ /* End PBXGroup section */ /* Begin PBXNativeTarget section */ + 3A9B7B05A37413FD435D907D /* mlbIOS */ = { + isa = PBXNativeTarget; + buildConfigurationList = 7E61CD36E6F461F6012C8AEC /* Build configuration list for PBXNativeTarget "mlbIOS" */; + buildPhases = ( + 161A48F0EE45E1D49E1ADD61 /* Sources */, + AF3410DF4206295AA0FDC9F7 /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + name = mlbIOS; + packageProductDependencies = ( + ); + productName = mlbIOS; + productReference = 0FE1BD4A8517243AFABE371C /* mlbIOS.app */; + productType = "com.apple.product-type.application"; + }; + 3B0B3D1BD19FB0B2F845FE00 /* mlbTVOSTests */ = { + isa = PBXNativeTarget; + buildConfigurationList = E19144DDC167AFC338BA9481 /* Build configuration list for PBXNativeTarget "mlbTVOSTests" */; + buildPhases = ( + ABB29E457F4A3E72130EE536 /* Sources */, + ); + buildRules = ( + ); + dependencies = ( + 540BB52ABCD2F688E02B1982 /* PBXTargetDependency */, + ); + name = mlbTVOSTests; + packageProductDependencies = ( + ); + productName = mlbTVOSTests; + productReference = 6FE64B2FA917EF98B16A3286 /* mlbTVOSTests.xctest */; + productType = "com.apple.product-type.bundle.unit-test"; + }; 6ACA532AEDED1D5945D6153A /* mlbTVOS */ = { isa = PBXNativeTarget; buildConfigurationList = 36D4353993A0A22378BC4770 /* Build configuration list for PBXNativeTarget "mlbTVOS" */; @@ -265,27 +312,12 @@ dependencies = ( ); name = mlbTVOS; + packageProductDependencies = ( + ); productName = mlbTVOS; productReference = 766441BC900073529EE93D69 /* mlbTVOS.app */; productType = "com.apple.product-type.application"; }; - 9C5FE08A60985D23E089CEE5 /* mlbIOS */ = { - isa = PBXNativeTarget; - buildConfigurationList = 67C1F966BDD84456877DC373 /* Build configuration list for PBXNativeTarget "mlbIOS" */; - buildPhases = ( - 9386CC819A80738E79744F6A /* Sources */, - EF6C5375C48E5C8359230651 /* Frameworks */, - 3CEE2E3AB4AAF6D8CC0F12D7 /* Resources */, - ); - buildRules = ( - ); - dependencies = ( - ); - name = mlbIOS; - productName = mlbIOS; - productReference = 55A3DC0B5087BAD6BC59B35A /* mlbIOS.app */; - productType = "com.apple.product-type.application"; - }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ @@ -294,6 +326,14 @@ attributes = { BuildIndependentTargetsInParallel = YES; LastUpgradeCheck = 2630; + TargetAttributes = { + 3A9B7B05A37413FD435D907D = { + DevelopmentTeam = ""; + }; + 3B0B3D1BD19FB0B2F845FE00 = { + DevelopmentTeam = ""; + }; + }; }; buildConfigurationList = B8EE3C08200DF28CFF279E15 /* Build configuration list for PBXProject "mlbTVOS" */; compatibilityVersion = "Xcode 14.0"; @@ -305,25 +345,17 @@ ); mainGroup = 86C1117ADD32A1A57AA32A08; minimizedProjectReferenceProxies = 1; - productRefGroup = 88274023705E1F09B25F86FF /* Products */; projectDirPath = ""; projectRoot = ""; targets = ( + 3A9B7B05A37413FD435D907D /* mlbIOS */, 6ACA532AEDED1D5945D6153A /* mlbTVOS */, - 9C5FE08A60985D23E089CEE5 /* mlbIOS */, + 3B0B3D1BD19FB0B2F845FE00 /* mlbTVOSTests */, ); }; /* End PBXProject section */ /* Begin PBXResourcesBuildPhase section */ - 3CEE2E3AB4AAF6D8CC0F12D7 /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - FE80BDA15CC8CE2F18DB4883 /* Assets.xcassets in Resources */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; 5C7F99E1451B54AD3CC382CB /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -332,40 +364,61 @@ ); runOnlyForDeploymentPostprocessing = 0; }; + AF3410DF4206295AA0FDC9F7 /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + 58B5982C6D8DC6F41EAC5CCD /* Assets.xcassets in Resources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ - 9386CC819A80738E79744F6A /* Sources */ = { + 161A48F0EE45E1D49E1ADD61 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 02BA6240A20E957B5337A545 /* Game.swift in Sources */, - BB48CC4C732672536CD5EF96 /* TeamAssets.swift in Sources */, - 11640185C392B0406BBE619D /* MLBServerAPI.swift in Sources */, - 82DEB54FF3BFD51A18ED98A1 /* MLBStatsAPI.swift in Sources */, - C67442678865108796537BBC /* GameCenterViewModel.swift in Sources */, - 37BFF8231552B49E4775AA59 /* GamesViewModel.swift in Sources */, - 7D2D21DAE73958578FE2E730 /* LeagueCenterViewModel.swift in Sources */, - BA1B3240360F79FE2DF42543 /* LinescoreView.swift in Sources */, - D199D622D64E3489E049B04A /* LiveIndicator.swift in Sources */, - 8D5FE07166FAD17836A0AFF4 /* PitcherHeadshotView.swift in Sources */, - DA156E98ACD90B582F3BBAB2 /* PlatformUI.swift in Sources */, - 1E5405E1C27A16971961091C /* ScoreOverlayView.swift in Sources */, - DB6E6A4890D0D22526DA5D96 /* ScoresTickerView.swift in Sources */, - B215941F5A269C59C0939958 /* TeamLogoView.swift in Sources */, - 1555D223058B858E4C6F419D /* ContentView.swift in Sources */, - F60F24246B08223DC2BA5825 /* DashboardView.swift in Sources */, - AFA76F6EBE7977914A849447 /* FeaturedGameCard.swift in Sources */, - B921B07F43A13CBBDE9C499D /* GameCardView.swift in Sources */, - 3FB9BCB07B6DBFE6D1D2C523 /* GameCenterView.swift in Sources */, - A506FDC86C3A78F4A2C04C10 /* GameListView.swift in Sources */, - 4318098424F9B3FF3B256EED /* LeagueCenterView.swift in Sources */, - F761076F826FEB86E972D516 /* MLBNetworkSheet.swift in Sources */, - D48041F6CFBCEB2F3492747F /* MultiStreamView.swift in Sources */, - 31353216A6288D986544F61E /* SettingsView.swift in Sources */, - CA20CF293D75E6058E0CB78C /* SingleStreamPlayerView.swift in Sources */, - 43AC37B4F957CDC3986F044A /* mlbIOSApp.swift in Sources */, - 43B16E04582A874E87514C30 /* mlbIOSRootView.swift in Sources */, + 26AE6D91B829B233CEDAE7DF /* AtBatTimelineView.swift in Sources */, + A70F5B83F079279CF2B41FCE /* ContentView.swift in Sources */, + 512148AC1C1F35F5CB688F98 /* DashboardView.swift in Sources */, + 08A22DE07ECE074D17D8A74E /* FeaturedGameCard.swift in Sources */, + ACEB18A23D20F79AA757DD63 /* Game.swift in Sources */, + AAD1562E79743AA0B09CB857 /* GameCardView.swift in Sources */, + E96459544473FC4A3CD92B4F /* GameCenterView.swift in Sources */, + B345E89FC7B8C263B067E082 /* GameCenterViewModel.swift in Sources */, + 50D870AF95561129D706C411 /* GameListView.swift in Sources */, + 65ABD3574B88BDDBB206B1DC /* GamesViewModel.swift in Sources */, + 8649D4F513A663D2493D91C9 /* LeagueCenterView.swift in Sources */, + E42B27D852AB037246167C23 /* LeagueCenterViewModel.swift in Sources */, + 4CB947BC5B5ECF83EF84F7E4 /* LinescoreView.swift in Sources */, + 0653C2F5CC55F4234E8950E5 /* LiveIndicator.swift in Sources */, + D864A2844FD222A04B48F6EF /* MLBNetworkSheet.swift in Sources */, + 8801888DFD48351B1D344D45 /* MLBServerAPI.swift in Sources */, + 5EBF83734395FCF7BCE1E26B /* MLBStatsAPI.swift in Sources */, + 6CD8E1E4B6A4C5D0F0F9299E /* MultiStreamView.swift in Sources */, + 36E4F79F23EF867A100E924D /* PitchSequenceView.swift in Sources */, + 8E2F3FAAE778FE6F44C0B5D5 /* PitcherHeadshotView.swift in Sources */, + 6E502A815BB3C150E56E7E38 /* PlatformUI.swift in Sources */, + 49B79787C58CA43F2C1F9A9C /* ScoreOverlayView.swift in Sources */, + 96250318C3F0491D095DDAA9 /* ScoresTickerView.swift in Sources */, + 2B52AC228AE12CC671348964 /* SettingsView.swift in Sources */, + D688F6B0680C29AECD289CA9 /* SingleStreamPlayerView.swift in Sources */, + 95046231282AA1B67F4B5A8D /* SprayChartView.swift in Sources */, + E6A3D6E740DBE329B8EAD531 /* StrikeZoneView.swift in Sources */, + AEE0045C962DDF789E61429B /* TeamAssets.swift in Sources */, + 20A122430083142C3C74AF5F /* TeamLogoView.swift in Sources */, + B730E81141D5686CD7C6DE4F /* VideoShuffle.swift in Sources */, + 718E7200B5BBB22CB48C2EF8 /* mlbIOSApp.swift in Sources */, + 5ED9CAF1722802A4AD561617 /* mlbIOSRootView.swift in Sources */, + ); + runOnlyForDeploymentPostprocessing = 0; + }; + ABB29E457F4A3E72130EE536 /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + AF6279D4B9E386009C27EC23 /* VideoShuffleTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -373,37 +426,50 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 769DF65BBCC400691B98C71F /* AtBatTimelineView.swift in Sources */, 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 */, + 3679F9F4949C8B3B8D5D6E94 /* GameCenterView.swift in Sources */, + 9525FA984562C90A677AD453 /* GameCenterViewModel.swift in Sources */, 52498A59FF923A1494A0139B /* GameListView.swift in Sources */, 9D29D3508CD05CB3F4975910 /* GamesViewModel.swift in Sources */, - 1AA11AA11AA11AA11AA11AA2 /* LeagueCenterView.swift in Sources */, - 1AA11AA11AA11AA11AA11AA4 /* LeagueCenterViewModel.swift in Sources */, + 07BBD0E7B7F122213708A406 /* LeagueCenterView.swift in Sources */, + B0A9FFD5C20A80E16532CC91 /* 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 */, + F096F11642B32BF240C367A5 /* PitchSequenceView.swift in Sources */, + A7195DD696E4C07218D74F19 /* PitcherHeadshotView.swift in Sources */, + 468EB7F6EC98D83099E2C352 /* PlatformUI.swift in Sources */, + 5F5AF9B045B8F2BA87CBECD3 /* ScoreOverlayView.swift in Sources */, + 4FB2E354854C1408419634BB /* ScoresTickerView.swift in Sources */, FD9322BED3A2B827CB021163 /* SettingsView.swift in Sources */, 5DFD3805A76DDE49B0DC1E2D /* SingleStreamPlayerView.swift in Sources */, + 49FB2101E46F2DAD10BC6201 /* SprayChartView.swift in Sources */, + 470E17740A5C54C6BF2C1C97 /* StrikeZoneView.swift in Sources */, 342EED600E1A5B4A3D083E50 /* TeamAssets.swift in Sources */, 80A690C52C9C8140E7C519E8 /* TeamLogoView.swift in Sources */, + 2A6155FFEE85643ABDE4111A /* VideoShuffle.swift in Sources */, FD31074216AC0115B3F1C057 /* mlbTVOSApp.swift in Sources */, - 5A76A7BB97FFA089156A0ABA /* PlatformUI.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXSourcesBuildPhase section */ +/* Begin PBXTargetDependency section */ + 540BB52ABCD2F688E02B1982 /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 6ACA532AEDED1D5945D6153A /* mlbTVOS */; + targetProxy = BB037F390E873F3105CC93DC /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + /* Begin XCBuildConfiguration section */ 0AEA011CF6D6485B876BC988 /* Debug */ = { isa = XCBuildConfiguration; @@ -457,11 +523,11 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; 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; @@ -469,53 +535,6 @@ }; name = Debug; }; - 0D8A815004F8CC601240D2DB /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; - CLANG_ENABLE_OBJC_WEAK = NO; - CODE_SIGN_ENTITLEMENTS = mlbIOS/mlbIOS.entitlements; - DEVELOPMENT_TEAM = ""; - INFOPLIST_FILE = mlbIOS/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 18.0; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - ); - PRODUCT_BUNDLE_IDENTIFIER = com.treyt.mlbIOS; - PRODUCT_NAME = mlbIOS; - SDKROOT = iphoneos; - SWIFT_STRICT_CONCURRENCY = complete; - SWIFT_VERSION = 6.0; - TARGETED_DEVICE_FAMILY = "1,2"; - }; - name = Debug; - }; - 1FC82661BC31026C62934BD7 /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; - ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; - CLANG_ENABLE_OBJC_WEAK = NO; - CODE_SIGN_ENTITLEMENTS = mlbIOS/mlbIOS.entitlements; - DEVELOPMENT_TEAM = ""; - INFOPLIST_FILE = mlbIOS/Info.plist; - IPHONEOS_DEPLOYMENT_TARGET = 18.0; - LD_RUNPATH_SEARCH_PATHS = ( - "$(inherited)", - "@executable_path/Frameworks", - ); - PRODUCT_BUNDLE_IDENTIFIER = com.treyt.mlbIOS; - PRODUCT_NAME = mlbIOS; - SDKROOT = iphoneos; - SWIFT_STRICT_CONCURRENCY = complete; - SWIFT_VERSION = 6.0; - TARGETED_DEVICE_FAMILY = "1,2"; - VALIDATE_PRODUCT = YES; - }; - name = Release; - }; 1FE0846F10814B35388166A8 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -581,10 +600,10 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; 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; @@ -592,6 +611,78 @@ }; name = Release; }; + 75DEC3365E4FE5397DBA6D23 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_ENTITLEMENTS = mlbIOS/mlbIOS.entitlements; + CODE_SIGN_IDENTITY = "iPhone Developer"; + INFOPLIST_FILE = mlbIOS/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.treyt.mlbIOS; + SDKROOT = iphoneos; + SWIFT_STRICT_CONCURRENCY = complete; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 766EF2A8A8F5ABA868469554 /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + GENERATE_INFOPLIST_FILE = YES; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.treyt.mlbTVOSTests; + SDKROOT = appletvos; + SWIFT_STRICT_CONCURRENCY = complete; + TARGETED_DEVICE_FAMILY = 3; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/mlbTVOS.app/mlbTVOS"; + }; + name = Debug; + }; + 91415A3F240ADBD891F74114 /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + BUNDLE_LOADER = "$(TEST_HOST)"; + GENERATE_INFOPLIST_FILE = YES; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + "@loader_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.treyt.mlbTVOSTests; + SDKROOT = appletvos; + SWIFT_STRICT_CONCURRENCY = complete; + TARGETED_DEVICE_FAMILY = 3; + TEST_HOST = "$(BUILT_PRODUCTS_DIR)/mlbTVOS.app/mlbTVOS"; + }; + name = Release; + }; + D7E3D2E659BC9B3A584585FE /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + CODE_SIGN_ENTITLEMENTS = mlbIOS/mlbIOS.entitlements; + CODE_SIGN_IDENTITY = "iPhone Developer"; + INFOPLIST_FILE = mlbIOS/Info.plist; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + PRODUCT_BUNDLE_IDENTIFIER = com.treyt.mlbIOS; + SDKROOT = iphoneos; + SWIFT_STRICT_CONCURRENCY = complete; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; F266D26959D831BC0C0C782C /* Release */ = { isa = XCBuildConfiguration; buildSettings = { @@ -623,14 +714,14 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Debug; }; - 67C1F966BDD84456877DC373 /* Build configuration list for PBXNativeTarget "mlbIOS" */ = { + 7E61CD36E6F461F6012C8AEC /* Build configuration list for PBXNativeTarget "mlbIOS" */ = { isa = XCConfigurationList; buildConfigurations = ( - 1FC82661BC31026C62934BD7 /* Release */, - 0D8A815004F8CC601240D2DB /* Debug */, + 75DEC3365E4FE5397DBA6D23 /* Debug */, + D7E3D2E659BC9B3A584585FE /* Release */, ); defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; + defaultConfigurationName = Debug; }; B8EE3C08200DF28CFF279E15 /* Build configuration list for PBXProject "mlbTVOS" */ = { isa = XCConfigurationList; @@ -641,6 +732,15 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Debug; }; + E19144DDC167AFC338BA9481 /* Build configuration list for PBXNativeTarget "mlbTVOSTests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 766EF2A8A8F5ABA868469554 /* Debug */, + 91415A3F240ADBD891F74114 /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Debug; + }; /* End XCConfigurationList section */ }; rootObject = 89437C3F6A1DA0DA4ADEA3AB /* Project object */; diff --git a/mlbTVOS/Services/MLBStatsAPI.swift b/mlbTVOS/Services/MLBStatsAPI.swift index 06b4a90..e780eb7 100644 --- a/mlbTVOS/Services/MLBStatsAPI.swift +++ b/mlbTVOS/Services/MLBStatsAPI.swift @@ -42,6 +42,10 @@ actor MLBStatsAPI { try await fetchJSON("\(baseURL).1/game/\(gamePk)/feed/live") } + func fetchWinProbability(gamePk: String) async throws -> [WinProbabilityEntry] { + try await fetchJSON("\(baseURL).1/game/\(gamePk)/winProbability") + } + func fetchLeagueTeams(season: String) async throws -> [LeagueTeamSummary] { let response: TeamsResponse = try await fetchJSON( "\(baseURL)/teams?sportId=1&season=\(season)&hydrate=league,division,venue,record" @@ -828,6 +832,19 @@ struct LiveGameFeed: Codable, Sendable { var homeLineup: [LiveFeedBoxscorePlayer] { liveData.boxscore?.teams?.home.lineupPlayers ?? [] } + + var currentAtBatPitches: [LiveFeedPlayEvent] { + currentPlay?.pitches ?? [] + } + + var allGameHits: [(play: LiveFeedPlay, hitData: LiveFeedHitData)] { + liveData.plays.allPlays.compactMap { play in + guard let lastEvent = play.playEvents?.last, + let hitData = lastEvent.hitData, + hitData.coordinates?.coordX != nil else { return nil } + return (play: play, hitData: hitData) + } + } } struct LiveFeedMetaData: Codable, Sendable { @@ -890,7 +907,7 @@ struct LiveFeedVenue: Codable, Sendable { struct LiveFeedWeather: Codable, Sendable { let condition: String? - let temp: Int? + let temp: String? let wind: String? } @@ -911,6 +928,11 @@ struct LiveFeedPlay: Codable, Sendable, Identifiable { let about: LiveFeedPlayAbout? let count: LiveFeedPlayCount? let matchup: LiveFeedPlayMatchup? + let playEvents: [LiveFeedPlayEvent]? + + var pitches: [LiveFeedPlayEvent] { + playEvents?.filter { $0.isPitch == true } ?? [] + } var id: String { let inning = about?.inning ?? -1 @@ -958,6 +980,11 @@ struct LiveFeedPlayerReference: Codable, Sendable, Identifiable { var displayName: String { fullName ?? "TBD" } + + var headshotURL: URL? { + guard let id 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") + } } struct LiveFeedLinescore: Codable, Sendable { @@ -1054,3 +1081,87 @@ struct LiveFeedDecisions: Codable, Sendable { let loser: LiveFeedPlayerReference? let save: LiveFeedPlayerReference? } + +// MARK: - Play Event / Pitch Data Models + +struct LiveFeedPlayEvent: Codable, Sendable, Identifiable { + let details: LiveFeedPlayEventDetails? + let count: LiveFeedPlayCount? + let pitchData: LiveFeedPitchData? + let hitData: LiveFeedHitData? + let isPitch: Bool? + let pitchNumber: Int? + let index: Int? + + var id: Int { index ?? pitchNumber ?? 0 } + + var callCode: String { + details?.call?.code ?? "" + } + + var callDescription: String { + details?.call?.description ?? details?.description ?? "" + } + + var pitchTypeDescription: String { + details?.type?.description ?? "Unknown" + } + + var pitchTypeCode: String { + details?.type?.code ?? "" + } + + var speedMPH: Double? { + pitchData?.startSpeed + } +} + +struct LiveFeedPlayEventDetails: Codable, Sendable { + let call: LiveFeedCallInfo? + let description: String? + let type: LiveFeedPitchType? +} + +struct LiveFeedCallInfo: Codable, Sendable { + let code: String? + let description: String? +} + +struct LiveFeedPitchType: Codable, Sendable { + let code: String? + let description: String? +} + +struct LiveFeedPitchData: Codable, Sendable { + let startSpeed: Double? + let endSpeed: Double? + let strikeZoneTop: Double? + let strikeZoneBottom: Double? + let coordinates: LiveFeedPitchCoordinates? + let zone: Int? +} + +struct LiveFeedPitchCoordinates: Codable, Sendable { + let pX: Double? + let pZ: Double? +} + +struct LiveFeedHitData: Codable, Sendable { + let launchSpeed: Double? + let launchAngle: Double? + let totalDistance: Double? + let coordinates: LiveFeedHitCoordinates? +} + +struct LiveFeedHitCoordinates: Codable, Sendable { + let coordX: Double? + let coordY: Double? +} + +// MARK: - Win Probability + +struct WinProbabilityEntry: Codable, Sendable { + let atBatIndex: Int? + let homeTeamWinProbability: Double? + let awayTeamWinProbability: Double? +} diff --git a/mlbTVOS/Services/VideoShuffle.swift b/mlbTVOS/Services/VideoShuffle.swift new file mode 100644 index 0000000..556fd68 --- /dev/null +++ b/mlbTVOS/Services/VideoShuffle.swift @@ -0,0 +1,78 @@ +import Foundation + +enum VideoShuffle { + /// Groups items by key, shuffling within each group. + /// + /// Items whose key is `nil` are grouped under the empty string `""` so they + /// form their own bucket rather than being dropped. + static func groupByModel( + _ items: [T], + keyFor: (T) -> String?, + using rng: inout some RandomNumberGenerator + ) -> [String: [T]] { + var groups: [String: [T]] = [:] + for item in items { + let key = keyFor(item) ?? "" + groups[key, default: []].append(item) + } + for key in groups.keys { + groups[key]!.shuffle(using: &rng) + } + return groups + } + + /// Picks a random item from a random non-empty bucket, avoiding + /// `excludingKey` when possible. + /// + /// Returned buckets are a mutated copy of the input with the picked item + /// removed (pure — input is not modified). When the excluded bucket is the + /// only non-empty one, it is still picked (falling back gracefully). + /// + /// - Returns: the picked item, the key of the bucket it came from, and the + /// updated bucket dictionary — or `nil` if all buckets are empty. + static func pickRandomFromBuckets( + _ buckets: [String: [T]], + excludingKey: String? = nil, + using rng: inout some RandomNumberGenerator + ) -> (item: T, key: String, remaining: [String: [T]])? { + // Collect keys with at least one item. Sort for deterministic ordering + // before the random pick so test output is reproducible when a seeded + // RNG is used. + let nonEmptyKeys = buckets + .filter { !$0.value.isEmpty } + .keys + .sorted() + guard !nonEmptyKeys.isEmpty else { return nil } + + // Prefer keys other than the excluded one, but fall back if excluding + // would leave no candidates. + let candidateKeys: [String] + if let excludingKey, nonEmptyKeys.contains(excludingKey), nonEmptyKeys.count > 1 { + candidateKeys = nonEmptyKeys.filter { $0 != excludingKey } + } else { + candidateKeys = nonEmptyKeys + } + + guard let pickedKey = candidateKeys.randomElement(using: &rng), + var items = buckets[pickedKey], + !items.isEmpty else { + return nil + } + + // Items were shuffled during groupByModel, so removeFirst is a + // uniform random pick among the remaining items in this bucket. + let item = items.removeFirst() + var remaining = buckets + remaining[pickedKey] = items + return (item, pickedKey, remaining) + } + + /// Convenience overload using the system random generator. + static func pickRandomFromBuckets( + _ buckets: [String: [T]], + excludingKey: String? = nil + ) -> (item: T, key: String, remaining: [String: [T]])? { + var rng = SystemRandomNumberGenerator() + return pickRandomFromBuckets(buckets, excludingKey: excludingKey, using: &rng) + } +} diff --git a/mlbTVOS/ViewModels/GameCenterViewModel.swift b/mlbTVOS/ViewModels/GameCenterViewModel.swift index b6f6758..70bc61d 100644 --- a/mlbTVOS/ViewModels/GameCenterViewModel.swift +++ b/mlbTVOS/ViewModels/GameCenterViewModel.swift @@ -1,24 +1,41 @@ import Foundation import Observation +import OSLog + +private let gameCenterLogger = Logger(subsystem: "com.treyt.mlbTVOS", category: "GameCenter") + +private func logGameCenter(_ message: String) { + gameCenterLogger.debug("\(message, privacy: .public)") + print("[GameCenter] \(message)") +} @Observable @MainActor final class GameCenterViewModel { var feed: LiveGameFeed? + var highlights: [Highlight] = [] + var winProbabilityHome: Double? + var winProbabilityAway: Double? var isLoading = false var errorMessage: String? var lastUpdated: Date? private let statsAPI = MLBStatsAPI() + @ObservationIgnored + private var lastHighlightsFetch: Date? func watch(game: Game) async { guard let gamePk = game.gamePk else { + logGameCenter("watch: no gamePk for game id=\(game.id)") errorMessage = "No live game feed is available for this matchup." return } + logGameCenter("watch: starting for gamePk=\(gamePk)") while !Task.isCancelled { await refresh(gamePk: gamePk) + await refreshHighlightsIfNeeded(gamePk: gamePk, gameDate: game.gameDate) + await refreshWinProbability(gamePk: gamePk) let liveState = feed?.gameData.status?.abstractGameState == "Live" if !liveState { @@ -34,12 +51,44 @@ final class GameCenterViewModel { errorMessage = nil do { + logGameCenter("refresh: fetching feed for gamePk=\(gamePk)") feed = try await statsAPI.fetchGameFeed(gamePk: gamePk) + logGameCenter("refresh: success playEvents=\(feed?.currentPlay?.playEvents?.count ?? 0) allPlays=\(feed?.liveData.plays.allPlays.count ?? 0)") lastUpdated = Date() } catch { + logGameCenter("refresh: FAILED gamePk=\(gamePk) error=\(error)") errorMessage = "Failed to load game center." } isLoading = false } + + private func refreshHighlightsIfNeeded(gamePk: String, gameDate: String) async { + // Only fetch highlights every 60 seconds + if let last = lastHighlightsFetch, Date().timeIntervalSince(last) < 60 { + return + } + + let serverAPI = MLBServerAPI() + do { + highlights = try await serverAPI.fetchHighlights(gamePk: gamePk, gameDate: gameDate) + lastHighlightsFetch = Date() + } catch { + // Highlights are supplementary — don't surface errors + } + } + + private func refreshWinProbability(gamePk: String) async { + do { + let entries = try await statsAPI.fetchWinProbability(gamePk: gamePk) + if let latest = entries.last, + let home = latest.homeTeamWinProbability, + let away = latest.awayTeamWinProbability { + winProbabilityHome = home + winProbabilityAway = away + } + } catch { + // Win probability is supplementary — don't surface errors + } + } } diff --git a/mlbTVOS/ViewModels/GamesViewModel.swift b/mlbTVOS/ViewModels/GamesViewModel.swift index c193cb8..308c8fd 100644 --- a/mlbTVOS/ViewModels/GamesViewModel.swift +++ b/mlbTVOS/ViewModels/GamesViewModel.swift @@ -55,7 +55,7 @@ final class GamesViewModel { @ObservationIgnored private var authenticatedVideoFeedCache: [String: AuthenticatedVideoFeedCacheEntry] = [:] @ObservationIgnored - private var videoShuffleBags: [String: [URL]] = [:] + private var videoShuffleBagsByModel: [String: [String: [URL]]] = [:] @ObservationIgnored private var cachedStandings: (date: String, standings: [Int: TeamStanding])? @@ -536,11 +536,14 @@ final class GamesViewModel { func attachPlayer(_ player: AVPlayer, to streamID: String) { guard let index = activeStreams.firstIndex(where: { $0.id == streamID }) else { return } + let alreadyAttached = activeStreams[index].player === player activeStreams[index].player = player activeStreams[index].isPlaying = true - let shouldMute = shouldMuteAudio(for: activeStreams[index]) - activeStreams[index].isMuted = shouldMute - player.isMuted = shouldMute + if !alreadyAttached { + let shouldMute = shouldMuteAudio(for: activeStreams[index]) + activeStreams[index].isMuted = shouldMute + player.isMuted = shouldMute + } } func updateStreamOverrideSource(id: String, url: URL, headers: [String: String] = [:]) { @@ -614,30 +617,79 @@ final class GamesViewModel { excluding excludedURL: URL? = nil ) async -> URL? { guard let urls = await fetchAuthenticatedVideoFeedURLs(feedURL: feedURL, headers: headers) else { + logGamesViewModel("resolveAuthenticatedVideoFeedURL FAILED reason=fetchReturned-nil") return nil } let cacheKey = authenticatedVideoFeedCacheKey(feedURL: feedURL, headers: headers) - var bag = videoShuffleBags[cacheKey] ?? [] + var buckets = videoShuffleBagsByModel[cacheKey] ?? [:] - // Refill the bag when empty (or first time), shuffled - if bag.isEmpty { - bag = urls.shuffled() - logGamesViewModel("resolveAuthenticatedVideoFeedURL reshuffled bag count=\(bag.count)") + // Refill ANY model bucket that is empty — this keeps all models in + // rotation so small models cycle through their videos while large + // models continue drawing from unplayed ones. + var refilledKeys: [String] = [] + if buckets.isEmpty { + // First access: build every bucket from scratch. + var rng = SystemRandomNumberGenerator() + buckets = VideoShuffle.groupByModel( + urls, + keyFor: Self.modelKey(from:), + using: &rng + ) + refilledKeys = buckets.keys.sorted() + } else { + // Refill just the depleted buckets. + var rng = SystemRandomNumberGenerator() + for key in buckets.keys where buckets[key]?.isEmpty ?? true { + let modelURLs = urls.filter { Self.modelKey(from: $0) ?? "" == key } + guard !modelURLs.isEmpty else { continue } + buckets[key] = modelURLs.shuffled(using: &rng) + refilledKeys.append(key) + } + } + if !refilledKeys.isEmpty { + let counts = buckets + .map { "\($0.key):\($0.value.count)" } + .sorted() + .joined(separator: ",") + logGamesViewModel( + "resolveAuthenticatedVideoFeedURL refilled buckets=[\(refilledKeys.joined(separator: ","))] currentCounts=[\(counts)]" + ) } - // If the next video is the one we just played, push it to the back - if let excludedURL, bag.count > 1, bag.first == excludedURL { - bag.append(bag.removeFirst()) + let excludedModel = excludedURL.flatMap(Self.modelKey(from:)) + guard let pick = VideoShuffle.pickRandomFromBuckets( + buckets, + excludingKey: excludedModel + ) else { + logGamesViewModel("resolveAuthenticatedVideoFeedURL FAILED reason=all-buckets-empty") + return nil } - let selectedURL = bag.removeFirst() - videoShuffleBags[cacheKey] = bag + videoShuffleBagsByModel[cacheKey] = pick.remaining + let remainingTotal = pick.remaining.values.map(\.count).reduce(0, +) logGamesViewModel( - "resolveAuthenticatedVideoFeedURL success resolvedURL=\(gamesViewModelDebugURLDescription(selectedURL)) remaining=\(bag.count) excludedURL=\(excludedURL.map(gamesViewModelDebugURLDescription) ?? "nil")" + "resolveAuthenticatedVideoFeedURL pop model=\(pick.key) remainingTotal=\(remainingTotal) excludedModel=\(excludedModel ?? "nil")" ) - return selectedURL + return pick.item + } + + /// Extracts the model/folder identifier from an authenticated feed URL. + /// Expected path shapes: + /// /api/hls///master.m3u8 + /// /data/media//.mp4 + private static func modelKey(from url: URL) -> String? { + let components = url.pathComponents + if let hlsIndex = components.firstIndex(of: "hls"), + components.index(after: hlsIndex) < components.endIndex { + return components[components.index(after: hlsIndex)] + } + if let mediaIndex = components.firstIndex(of: "media"), + components.index(after: mediaIndex) < components.endIndex { + return components[components.index(after: mediaIndex)] + } + return nil } private func fetchAuthenticatedVideoFeedURLs( diff --git a/mlbTVOS/Views/Components/AtBatTimelineView.swift b/mlbTVOS/Views/Components/AtBatTimelineView.swift new file mode 100644 index 0000000..6c88ba4 --- /dev/null +++ b/mlbTVOS/Views/Components/AtBatTimelineView.swift @@ -0,0 +1,80 @@ +import SwiftUI + +struct AtBatTimelineView: View { + let pitches: [LiveFeedPlayEvent] + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + Text("AT-BAT") + .font(.system(size: 12, weight: .bold, design: .rounded)) + .foregroundStyle(.white.opacity(0.5)) + .kerning(1.2) + + HStack(spacing: 6) { + ForEach(Array(pitches.enumerated()), id: \.offset) { index, pitch in + let isLatest = index == pitches.count - 1 + let code = pitchCallLetter(pitch.callCode) + let color = pitchCallColor(pitch.callCode) + + Text(code) + .font(.system(size: isLatest ? 14 : 13, weight: .black, design: .rounded)) + .foregroundStyle(color) + .padding(.horizontal, isLatest ? 12 : 10) + .padding(.vertical, isLatest ? 7 : 6) + .background(color.opacity(isLatest ? 0.25 : 0.15)) + .clipShape(Capsule()) + .overlay { + if isLatest { + Capsule() + .strokeBorder(color.opacity(0.5), lineWidth: 1) + } + } + } + } + + if let last = pitches.last?.count { + Text("\(last.balls)-\(last.strikes), \(last.outs) out\(last.outs == 1 ? "" : "s")") + .font(.system(size: 14, weight: .semibold, design: .rounded)) + .foregroundStyle(.white.opacity(0.6)) + } + } + } +} + +func pitchCallLetter(_ code: String) -> String { + switch code { + case "B": return "B" + case "C": return "S" + case "S": return "S" + case "F": return "F" + case "X", "D", "E": return "X" + default: return "·" + } +} + +func pitchCallColor(_ code: String) -> Color { + switch code { + case "B": return .green + case "C": return .red + case "S": return .orange + case "F": return .yellow + case "X", "D", "E": return .blue + default: return .white.opacity(0.5) + } +} + +func shortPitchType(_ code: String) -> String { + switch code { + case "FF": return "4SM" + case "SI": return "SNK" + case "FC": return "CUT" + case "SL": return "SLD" + case "CU", "KC": return "CRV" + case "CH": return "CHG" + case "FS": return "SPL" + case "KN": return "KNK" + case "ST": return "SWP" + case "SV": return "SLV" + default: return code + } +} diff --git a/mlbTVOS/Views/Components/PitchSequenceView.swift b/mlbTVOS/Views/Components/PitchSequenceView.swift new file mode 100644 index 0000000..2842baa --- /dev/null +++ b/mlbTVOS/Views/Components/PitchSequenceView.swift @@ -0,0 +1,68 @@ +import SwiftUI + +struct PitchSequenceView: View { + let pitches: [LiveFeedPlayEvent] + + var body: some View { + VStack(alignment: .leading, spacing: 8) { + Text("PITCH SEQUENCE") + .font(.system(size: 12, weight: .bold, design: .rounded)) + .foregroundStyle(.white.opacity(0.5)) + .kerning(1.2) + + ForEach(Array(pitches.enumerated()), id: \.offset) { index, pitch in + HStack(spacing: 10) { + Text("#\(pitch.pitchNumber ?? (index + 1))") + .font(.system(size: 13, weight: .bold, design: .rounded)) + .foregroundStyle(.white.opacity(0.5)) + .frame(width: 28, alignment: .leading) + + Text(pitch.pitchTypeDescription) + .font(.system(size: 14, weight: .semibold, design: .rounded)) + .foregroundStyle(.white) + .lineLimit(1) + + Spacer() + + if let speed = pitch.speedMPH { + Text("\(speed, specifier: "%.1f") mph") + .font(.system(size: 13, weight: .medium).monospacedDigit()) + .foregroundStyle(.white.opacity(0.7)) + } + + pitchResultPill(code: pitch.callCode, description: pitch.callDescription) + } + .padding(.vertical, 4) + .padding(.horizontal, 8) + .background( + index == pitches.count - 1 + ? RoundedRectangle(cornerRadius: 8, style: .continuous).fill(.white.opacity(0.06)) + : RoundedRectangle(cornerRadius: 8, style: .continuous).fill(.clear) + ) + } + } + } + + private func pitchResultPill(code: String, description: String) -> some View { + let color = pitchCallColor(code) + let label: String + switch code { + case "B": label = "Ball" + case "C": label = "Called Strike" + case "S": label = "Swinging Strike" + case "F": label = "Foul" + case "X": label = "In Play" + case "D": label = "In Play (Out)" + case "E": label = "In Play (Error)" + default: label = description + } + + return Text(label) + .font(.system(size: 11, weight: .bold, design: .rounded)) + .foregroundStyle(color) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background(color.opacity(0.15)) + .clipShape(Capsule()) + } +} diff --git a/mlbTVOS/Views/Components/SprayChartView.swift b/mlbTVOS/Views/Components/SprayChartView.swift new file mode 100644 index 0000000..c566b30 --- /dev/null +++ b/mlbTVOS/Views/Components/SprayChartView.swift @@ -0,0 +1,126 @@ +import SwiftUI + +struct SprayChartHit: Identifiable { + let id: String + let coordX: Double + let coordY: Double + let event: String? + + var color: Color { + switch event?.lowercased() { + case "single": return .blue + case "double": return .green + case "triple": return .orange + case "home_run", "home run": return .red + default: return .white.opacity(0.4) + } + } + + var label: String { + switch event?.lowercased() { + case "single": return "1B" + case "double": return "2B" + case "triple": return "3B" + case "home_run", "home run": return "HR" + default: return "Out" + } + } +} + +struct SprayChartView: View { + let hits: [SprayChartHit] + var size: CGFloat = 280 + + // MLB API coordinate system: home plate ~(125, 200), field extends upward + // coordX: 0-250 (left to right), coordY: 0-250 (top to bottom, lower Y = deeper) + private let coordRange: ClosedRange = 0...250 + private let homePlate = CGPoint(x: 125, y: 200) + + var body: some View { + Canvas { context, canvasSize in + let w = canvasSize.width + let h = canvasSize.height + let scale = min(w, h) / 250.0 + + let homeX = homePlate.x * scale + let homeY = homePlate.y * scale + + // Draw foul lines from home plate + let foulLineLength: CGFloat = 200 * scale + + // Left field foul line (~135 degrees from horizontal) + let leftEnd = CGPoint( + x: homeX - foulLineLength * cos(.pi / 4), + y: homeY - foulLineLength * sin(.pi / 4) + ) + var leftLine = Path() + leftLine.move(to: CGPoint(x: homeX, y: homeY)) + leftLine.addLine(to: leftEnd) + context.stroke(leftLine, with: .color(.white.opacity(0.15)), lineWidth: 1) + + // Right field foul line + let rightEnd = CGPoint( + x: homeX + foulLineLength * cos(.pi / 4), + y: homeY - foulLineLength * sin(.pi / 4) + ) + var rightLine = Path() + rightLine.move(to: CGPoint(x: homeX, y: homeY)) + rightLine.addLine(to: rightEnd) + context.stroke(rightLine, with: .color(.white.opacity(0.15)), lineWidth: 1) + + // Outfield arc + var arc = Path() + arc.addArc( + center: CGPoint(x: homeX, y: homeY), + radius: foulLineLength, + startAngle: .degrees(-135), + endAngle: .degrees(-45), + clockwise: false + ) + context.stroke(arc, with: .color(.white.opacity(0.1)), lineWidth: 1) + + // Infield arc (smaller) + var infieldArc = Path() + let infieldRadius: CGFloat = 60 * scale + infieldArc.addArc( + center: CGPoint(x: homeX, y: homeY), + radius: infieldRadius, + startAngle: .degrees(-135), + endAngle: .degrees(-45), + clockwise: false + ) + context.stroke(infieldArc, with: .color(.white.opacity(0.08)), lineWidth: 0.5) + + // Infield diamond + let baseDistance: CGFloat = 40 * scale + let first = CGPoint(x: homeX + baseDistance * cos(.pi / 4), y: homeY - baseDistance * sin(.pi / 4)) + let second = CGPoint(x: homeX, y: homeY - baseDistance * sqrt(2)) + let third = CGPoint(x: homeX - baseDistance * cos(.pi / 4), y: homeY - baseDistance * sin(.pi / 4)) + + var diamond = Path() + diamond.move(to: CGPoint(x: homeX, y: homeY)) + diamond.addLine(to: first) + diamond.addLine(to: second) + diamond.addLine(to: third) + diamond.closeSubpath() + context.stroke(diamond, with: .color(.white.opacity(0.12)), lineWidth: 0.5) + + // Home plate marker + let plateSize: CGFloat = 4 + let plateRect = CGRect(x: homeX - plateSize, y: homeY - plateSize, width: plateSize * 2, height: plateSize * 2) + context.fill(Path(plateRect), with: .color(.white.opacity(0.3))) + + // Draw hit dots + for hit in hits { + let x = hit.coordX * scale + let y = hit.coordY * scale + let dotRadius: CGFloat = 5 + + let dotRect = CGRect(x: x - dotRadius, y: y - dotRadius, width: dotRadius * 2, height: dotRadius * 2) + context.fill(Circle().path(in: dotRect), with: .color(hit.color)) + context.stroke(Circle().path(in: dotRect), with: .color(.white.opacity(0.2)), lineWidth: 0.5) + } + } + .frame(width: size, height: size) + } +} diff --git a/mlbTVOS/Views/Components/StrikeZoneView.swift b/mlbTVOS/Views/Components/StrikeZoneView.swift new file mode 100644 index 0000000..7110778 --- /dev/null +++ b/mlbTVOS/Views/Components/StrikeZoneView.swift @@ -0,0 +1,104 @@ +import SwiftUI + +struct StrikeZoneView: View { + let pitches: [LiveFeedPlayEvent] + var size: CGFloat = 200 + + // Visible range: enough to show balls just outside the zone + // pX: feet from center of plate. Zone is -0.83 to 0.83. Show -1.5 to 1.5. + // pZ: height in feet. Zone is ~1.5 to 3.5. Show 0.5 to 4.5. + private let viewMinX: Double = -1.5 + private let viewMaxX: Double = 1.5 + private let viewMinZ: Double = 0.5 + private let viewMaxZ: Double = 4.5 + private let zoneHalfWidth: Double = 0.83 + + private var strikeZoneTop: Double { + pitches.compactMap { $0.pitchData?.strikeZoneTop }.last ?? 3.4 + } + + private var strikeZoneBottom: Double { + pitches.compactMap { $0.pitchData?.strikeZoneBottom }.last ?? 1.6 + } + + var body: some View { + Canvas { context, canvasSize in + let w = canvasSize.width + let h = canvasSize.height + let rangeX = viewMaxX - viewMinX + let rangeZ = viewMaxZ - viewMinZ + + func mapX(_ pX: Double) -> CGFloat { + CGFloat((pX - viewMinX) / rangeX) * w + } + + func mapZ(_ pZ: Double) -> CGFloat { + // Higher pZ = higher on screen = lower canvas Y + h - CGFloat((pZ - viewMinZ) / rangeZ) * h + } + + // Strike zone rectangle + let zoneLeft = mapX(-zoneHalfWidth) + let zoneRight = mapX(zoneHalfWidth) + let zoneTop = mapZ(strikeZoneTop) + let zoneBottom = mapZ(strikeZoneBottom) + let zoneRect = CGRect(x: zoneLeft, y: zoneTop, width: zoneRight - zoneLeft, height: zoneBottom - zoneTop) + + context.fill(Path(zoneRect), with: .color(.white.opacity(0.06))) + context.stroke(Path(zoneRect), with: .color(.white.opacity(0.3)), lineWidth: 1.5) + + // 3x3 grid + let zoneW = zoneRight - zoneLeft + let zoneH = zoneBottom - zoneTop + for i in 1...2 { + let xLine = zoneLeft + zoneW * CGFloat(i) / 3.0 + var vPath = Path() + vPath.move(to: CGPoint(x: xLine, y: zoneTop)) + vPath.addLine(to: CGPoint(x: xLine, y: zoneBottom)) + context.stroke(vPath, with: .color(.white.opacity(0.12)), lineWidth: 0.5) + + let yLine = zoneTop + zoneH * CGFloat(i) / 3.0 + var hPath = Path() + hPath.move(to: CGPoint(x: zoneLeft, y: yLine)) + hPath.addLine(to: CGPoint(x: zoneRight, y: yLine)) + context.stroke(hPath, with: .color(.white.opacity(0.12)), lineWidth: 0.5) + } + + // Pitch dots + let dotScale = min(size / 200.0, 1.0) + for (index, pitch) in pitches.enumerated() { + guard let coords = pitch.pitchData?.coordinates, + let pX = coords.pX, let pZ = coords.pZ else { continue } + + let cx = mapX(pX) + let cy = mapZ(pZ) + let isLatest = index == pitches.count - 1 + let dotRadius: CGFloat = (isLatest ? 9 : 7) * dotScale + let color = pitchCallColor(pitch.callCode) + + let dotRect = CGRect( + x: cx - dotRadius, + y: cy - dotRadius, + width: dotRadius * 2, + height: dotRadius * 2 + ) + + if isLatest { + let glowRect = dotRect.insetBy(dx: -4 * dotScale, dy: -4 * dotScale) + context.fill(Circle().path(in: glowRect), with: .color(color.opacity(0.3))) + } + + context.fill(Circle().path(in: dotRect), with: .color(color)) + context.stroke(Circle().path(in: dotRect), with: .color(.white.opacity(0.4)), lineWidth: 0.5) + + // Pitch number + let fontSize: CGFloat = max(8 * dotScale, 7) + let numText = Text("\(pitch.pitchNumber ?? (index + 1))") + .font(.system(size: fontSize, weight: .bold, design: .rounded)) + .foregroundColor(.white) + context.draw(context.resolve(numText), at: CGPoint(x: cx, y: cy), anchor: .center) + } + } + .frame(width: size, height: size * 1.33) + } +} diff --git a/mlbTVOS/Views/DashboardView.swift b/mlbTVOS/Views/DashboardView.swift index 69eee6b..4cf6c00 100644 --- a/mlbTVOS/Views/DashboardView.swift +++ b/mlbTVOS/Views/DashboardView.swift @@ -12,8 +12,8 @@ enum SpecialPlaybackChannelConfig { static let werkoutNSFWStreamID = "WKNSFW" static let werkoutNSFWTitle = "Werkout NSFW" static let werkoutNSFWSubtitle = "Authenticated OF media feed" - static let werkoutNSFWFeedURLString = "https://ofapp.treytartt.com/api/media?users=tabatachang,werkout&type=video" - static let werkoutNSFWCookie = "ofapp_token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjEsImlhdCI6MTc3NDY0MjEyNiwiZXhwIjoxNzc1MjQ2OTI2fQ.rDvZzLQ70MM9drHwA8hRxmqcTTgBGBHXxv1Cc55HSqc" + static let werkoutNSFWFeedURLString = "https://ofapp.treytartt.com/api/media?users=tabatachang,werkout,kayla-lauren,ray-mattos,josie-hamming-2,dani-speegle-2&type=video" + static let werkoutNSFWCookie = "ofapp_token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjEsImlhdCI6MTc3NDY0MjEyNiwiZXhwIjoxNzc3MjM0MTI2fQ.RdYGvyBPbR6tmK0kaEVhSnIVW6TZlm3Ef42Lxpvl_oQ" static let werkoutNSFWTeamCode = "WK" static var werkoutNSFWHeaders: [String: String] { @@ -56,6 +56,7 @@ struct DashboardView: View { @State private var pendingFullScreenBroadcast: BroadcastSelection? @State private var showMLBNetworkSheet = false @State private var showWerkoutNSFWSheet = false + @State private var isPiPActive = false private var horizontalPadding: CGFloat { #if os(iOS) @@ -218,7 +219,11 @@ struct DashboardView: View { await resolveFullScreenSource(for: selection) }, resolveNextSource: nextFullScreenSourceResolver(for: selection), - tickerGames: tickerGames(for: selection) + tickerGames: tickerGames(for: selection), + game: selection.game, + onPiPActiveChanged: { active in + isPiPActive = active + } ) .ignoresSafeArea() .onAppear { diff --git a/mlbTVOS/Views/GameCenterView.swift b/mlbTVOS/Views/GameCenterView.swift index 198e52e..72da1d3 100644 --- a/mlbTVOS/Views/GameCenterView.swift +++ b/mlbTVOS/Views/GameCenterView.swift @@ -1,9 +1,11 @@ import SwiftUI +import AVKit struct GameCenterView: View { let game: Game @State private var viewModel = GameCenterViewModel() + @State private var highlightPlayer: AVPlayer? var body: some View { VStack(alignment: .leading, spacing: 18) { @@ -15,6 +17,14 @@ struct GameCenterView: View { situationStrip(feed: feed) matchupPanel(feed: feed) + if !feed.currentAtBatPitches.isEmpty { + atBatPanel(feed: feed) + } + + if let wpHome = viewModel.winProbabilityHome, let wpAway = viewModel.winProbabilityAway { + winProbabilityPanel(home: wpHome, away: wpAway) + } + if !feed.decisionsSummary.isEmpty || !feed.officialSummary.isEmpty || feed.weatherSummary != nil { contextPanel(feed: feed) } @@ -23,6 +33,14 @@ struct GameCenterView: View { lineupPanel(feed: feed) } + if !viewModel.highlights.isEmpty { + highlightsPanel + } + + if !feed.allGameHits.isEmpty { + sprayChartPanel(feed: feed) + } + if !feed.scoringPlays.isEmpty { timelineSection( title: "Scoring Plays", @@ -43,6 +61,16 @@ struct GameCenterView: View { .task(id: game.id) { await viewModel.watch(game: game) } + .fullScreenCover(isPresented: Binding( + get: { highlightPlayer != nil }, + set: { if !$0 { highlightPlayer?.pause(); highlightPlayer = nil } } + )) { + if let player = highlightPlayer { + VideoPlayer(player: player) + .ignoresSafeArea() + .onAppear { player.play() } + } + } } private var header: some View { @@ -126,46 +154,175 @@ struct GameCenterView: View { 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)) + HStack(spacing: 14) { + PitcherHeadshotView( + url: feed.currentBatter?.headshotURL, + teamCode: game.status.isLive ? nil : game.awayTeam.code, + size: 46 + ) - Text(feed.currentBatter?.displayName ?? "Awaiting matchup") - .font(.system(size: 24, weight: .bold, design: .rounded)) - .foregroundStyle(.white) + VStack(alignment: .leading, spacing: 5) { + Text("At Bat") + .font(.system(size: 13, weight: .bold, design: .rounded)) + .foregroundStyle(.white.opacity(0.5)) - if let onDeck = feed.onDeckBatter?.displayName { - Text("On deck: \(onDeck)") - .font(.system(size: 15, weight: .medium)) - .foregroundStyle(.white.opacity(0.55)) - } + Text(feed.currentBatter?.displayName ?? "Awaiting matchup") + .font(.system(size: 24, weight: .bold, design: .rounded)) + .foregroundStyle(.white) - if let inHole = feed.inHoleBatter?.displayName { - Text("In hole: \(inHole)") - .font(.system(size: 15, weight: .medium)) - .foregroundStyle(.white.opacity(0.46)) + 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)) + HStack(spacing: 14) { + 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) + 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) + } + } + + PitcherHeadshotView( + url: feed.currentPitcher?.headshotURL, + teamCode: nil, + size: 46 + ) + } + } + } + .padding(22) + .background(panelBackground) + } + + private func atBatPanel(feed: LiveGameFeed) -> some View { + let pitches = feed.currentAtBatPitches + return VStack(alignment: .leading, spacing: 16) { + HStack(alignment: .top, spacing: 24) { + StrikeZoneView(pitches: pitches, size: 160) + + VStack(alignment: .leading, spacing: 16) { + AtBatTimelineView(pitches: pitches) + PitchSequenceView(pitches: pitches) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + } + .padding(22) + .background(panelBackground) + } + + private func sprayChartPanel(feed: LiveGameFeed) -> some View { + let hits = feed.allGameHits.map { item in + SprayChartHit( + id: item.play.id, + coordX: item.hitData.coordinates?.coordX ?? 0, + coordY: item.hitData.coordinates?.coordY ?? 0, + event: item.play.result?.eventType ?? item.play.result?.event + ) + } + + return VStack(alignment: .leading, spacing: 16) { + VStack(alignment: .leading, spacing: 4) { + Text("Spray Chart") + .font(.system(size: 24, weight: .bold, design: .rounded)) + .foregroundStyle(.white) + + Text("Batted ball locations this game.") + .font(.system(size: 15, weight: .medium)) + .foregroundStyle(.white.opacity(0.55)) + } + + HStack(spacing: 24) { + SprayChartView(hits: hits, size: 220) + + VStack(alignment: .leading, spacing: 8) { + sprayChartLegendItem(color: .red, label: "Home Run") + sprayChartLegendItem(color: .orange, label: "Triple") + sprayChartLegendItem(color: .green, label: "Double") + sprayChartLegendItem(color: .blue, label: "Single") + sprayChartLegendItem(color: .white.opacity(0.4), label: "Out") + } + } + } + .padding(22) + .background(panelBackground) + } + + private func sprayChartLegendItem(color: Color, label: String) -> some View { + HStack(spacing: 8) { + Circle() + .fill(color) + .frame(width: 10, height: 10) + Text(label) + .font(.system(size: 13, weight: .medium, design: .rounded)) + .foregroundStyle(.white.opacity(0.7)) + } + } + + private var highlightsPanel: some View { + VStack(alignment: .leading, spacing: 14) { + VStack(alignment: .leading, spacing: 4) { + Text("Highlights") + .font(.system(size: 24, weight: .bold, design: .rounded)) + .foregroundStyle(.white) + + Text("Key moments and plays from this game.") + .font(.system(size: 15, weight: .medium)) + .foregroundStyle(.white.opacity(0.55)) + } + + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 12) { + ForEach(viewModel.highlights) { highlight in + Button { + if let urlStr = highlight.hlsURL ?? highlight.mp4URL, + let url = URL(string: urlStr) { + highlightPlayer = AVPlayer(url: url) + } + } label: { + HStack(spacing: 10) { + Image(systemName: "play.fill") + .font(.system(size: 14, weight: .bold)) + .foregroundStyle(.purple) + + Text(highlight.headline ?? "Highlight") + .font(.system(size: 14, weight: .semibold, design: .rounded)) + .foregroundStyle(.white) + .lineLimit(2) + .multilineTextAlignment(.leading) + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + .background( + RoundedRectangle(cornerRadius: 14, style: .continuous) + .fill(.white.opacity(0.06)) + ) + } + .platformCardStyle() } } } @@ -174,6 +331,60 @@ struct GameCenterView: View { .background(panelBackground) } + private func winProbabilityPanel(home: Double, away: Double) -> some View { + let homeColor = TeamAssets.color(for: game.homeTeam.code) + let awayColor = TeamAssets.color(for: game.awayTeam.code) + let homePct = home / 100.0 + let awayPct = away / 100.0 + + return VStack(alignment: .leading, spacing: 14) { + Text("WIN PROBABILITY") + .font(.system(size: 12, weight: .bold, design: .rounded)) + .foregroundStyle(.white.opacity(0.5)) + .kerning(1.2) + + HStack(spacing: 16) { + VStack(alignment: .leading, spacing: 6) { + Text(game.awayTeam.code) + .font(.system(size: 16, weight: .black, design: .rounded)) + .foregroundStyle(.white) + + Text("\(Int(away))%") + .font(.system(size: 28, weight: .bold).monospacedDigit()) + .foregroundStyle(awayColor) + } + .frame(width: 80) + + GeometryReader { geo in + HStack(spacing: 2) { + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(awayColor) + .frame(width: max(geo.size.width * awayPct, 4)) + + RoundedRectangle(cornerRadius: 6, style: .continuous) + .fill(homeColor) + .frame(width: max(geo.size.width * homePct, 4)) + } + .frame(height: 24) + } + .frame(height: 24) + + VStack(alignment: .trailing, spacing: 6) { + Text(game.homeTeam.code) + .font(.system(size: 16, weight: .black, design: .rounded)) + .foregroundStyle(.white) + + Text("\(Int(home))%") + .font(.system(size: 28, weight: .bold).monospacedDigit()) + .foregroundStyle(homeColor) + } + .frame(width: 80) + } + } + .padding(22) + .background(panelBackground) + } + private func contextPanel(feed: LiveGameFeed) -> some View { VStack(alignment: .leading, spacing: 16) { if let weatherSummary = feed.weatherSummary { @@ -256,6 +467,11 @@ struct GameCenterView: View { .foregroundStyle(.white.opacity(0.6)) .frame(width: 18, alignment: .leading) + PitcherHeadshotView( + url: player.person.headshotURL, + size: 28 + ) + Text(player.person.displayName) .font(.system(size: 15, weight: .semibold)) .foregroundStyle(.white.opacity(0.88)) diff --git a/mlbTVOS/Views/GameListView.swift b/mlbTVOS/Views/GameListView.swift index 3f62b44..0ae55af 100644 --- a/mlbTVOS/Views/GameListView.swift +++ b/mlbTVOS/Views/GameListView.swift @@ -77,6 +77,8 @@ struct StreamOptionsSheet: View { .padding(.vertical, 18) .background(panelBackground) } + + GameCenterView(game: game) } .padding(.horizontal, horizontalPadding) .padding(.vertical, verticalPadding) @@ -129,8 +131,6 @@ struct StreamOptionsSheet: View { .padding(22) .background(panelBackground) } - - GameCenterView(game: game) } } diff --git a/mlbTVOS/Views/MultiStreamView.swift b/mlbTVOS/Views/MultiStreamView.swift index a010978..8a5f163 100644 --- a/mlbTVOS/Views/MultiStreamView.swift +++ b/mlbTVOS/Views/MultiStreamView.swift @@ -353,8 +353,13 @@ private struct MultiStreamTile: View { @State private var hasError = false @State private var startupPlaybackTask: Task? @State private var qualityUpgradeTask: Task? + @State private var clipTimeLimitObserver: Any? + @State private var isAdvancingClip = false @StateObject private var playbackDiagnostics = MultiStreamPlaybackDiagnostics() + private static let maxClipDuration: Double = 15.0 + private static var audioSessionConfigured = false + var body: some View { ZStack { videoLayer @@ -442,6 +447,7 @@ private struct MultiStreamTile: View { startupPlaybackTask = nil qualityUpgradeTask?.cancel() qualityUpgradeTask = nil + if let player { removeClipTimeLimit(from: player) } playbackDiagnostics.clear(streamID: stream.id, reason: "tile disappeared") } #if os(tvOS) @@ -536,7 +542,6 @@ private struct MultiStreamTile: View { ) if let player { - player.isMuted = stream.forceMuteAudio || viewModel.audioFocusStreamID != stream.id playbackDiagnostics.attach( to: player, streamID: stream.id, @@ -545,20 +550,23 @@ private struct MultiStreamTile: View { ) scheduleStartupPlaybackRecovery(for: player) scheduleQualityUpgrade(for: player) + installClipTimeLimit(on: 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 !Self.audioSessionConfigured { + do { + try AVAudioSession.sharedInstance().setCategory(.playback) + try AVAudioSession.sharedInstance().setActive(true) + Self.audioSessionConfigured = 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 = stream.forceMuteAudio || viewModel.audioFocusStreamID != stream.id self.player = existingPlayer hasError = false playbackDiagnostics.attach( @@ -569,6 +577,7 @@ private struct MultiStreamTile: View { ) scheduleStartupPlaybackRecovery(for: existingPlayer) scheduleQualityUpgrade(for: existingPlayer) + installClipTimeLimit(on: existingPlayer) logMultiView("startStream reused shared player id=\(stream.id) muted=\(existingPlayer.isMuted)") return } @@ -606,6 +615,7 @@ private struct MultiStreamTile: View { 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) + installClipTimeLimit(on: avPlayer) } private func makePlayer(url: URL, headers: [String: String]?) -> AVPlayer { @@ -754,28 +764,73 @@ private struct MultiStreamTile: View { .value } + private func installClipTimeLimit(on player: AVPlayer) { + removeClipTimeLimit(from: player) + guard stream.id == SpecialPlaybackChannelConfig.werkoutNSFWStreamID else { return } + let limit = CMTime(seconds: Self.maxClipDuration, preferredTimescale: 600) + logMultiView("installClipTimeLimit id=\(stream.id) limit=\(Self.maxClipDuration)s") + clipTimeLimitObserver = player.addBoundaryTimeObserver( + forTimes: [NSValue(time: limit)], + queue: .main + ) { [weak player] in + guard let player else { + logMultiView("clipTimeLimit STOPPED id=\(stream.id) reason=player-deallocated") + return + } + let currentTime = CMTimeGetSeconds(player.currentTime()) + logMultiView("clipTimeLimit fired id=\(stream.id) currentTime=\(String(format: "%.1f", currentTime))s rate=\(player.rate) — advancing") + Task { @MainActor in + await playNextWerkoutClip(on: player) + } + } + } + + private func removeClipTimeLimit(from player: AVPlayer) { + if let observer = clipTimeLimitObserver { + player.removeTimeObserver(observer) + clipTimeLimitObserver = nil + } + } + private func playbackEndedHandler(for player: AVPlayer) -> (@MainActor @Sendable () async -> Void)? { guard stream.id == SpecialPlaybackChannelConfig.werkoutNSFWStreamID else { return nil } return { + let currentTime = CMTimeGetSeconds(player.currentTime()) + logMultiView("playbackEnded (didPlayToEnd) id=\(stream.id) currentTime=\(String(format: "%.1f", currentTime))s rate=\(player.rate)") await playNextWerkoutClip(on: player) } } private func playNextWerkoutClip(on player: AVPlayer) async { + guard !isAdvancingClip else { + logMultiView("playNextWerkoutClip SKIPPED id=\(stream.id) reason=already-advancing") + return + } + isAdvancingClip = true + defer { isAdvancingClip = false } + let currentURL = currentStreamURL(for: player) + let playerRate = player.rate + let playerStatus = player.status.rawValue + let itemStatus = player.currentItem?.status.rawValue ?? -1 + let timeControl = player.timeControlStatus.rawValue logMultiView( - "playNextWerkoutClip begin id=\(stream.id) currentURL=\(currentURL?.absoluteString ?? "nil")" + "playNextWerkoutClip begin id=\(stream.id) currentURL=\(currentURL?.absoluteString ?? "nil") playerRate=\(playerRate) playerStatus=\(playerStatus) itemStatus=\(itemStatus) timeControl=\(timeControl)" ) + let resolveStart = Date() guard let nextURL = await viewModel.resolveNextAuthenticatedFeedURLForActiveStream( id: stream.id, feedURL: SpecialPlaybackChannelConfig.werkoutNSFWFeedURL, headers: SpecialPlaybackChannelConfig.werkoutNSFWHeaders, maxRetries: 3 ) else { - logMultiView("playNextWerkoutClip failed id=\(stream.id) reason=resolve-nil-after-retries") + let elapsedMs = Int(Date().timeIntervalSince(resolveStart) * 1000) + logMultiView("playNextWerkoutClip STOPPED id=\(stream.id) reason=resolve-nil-after-retries elapsedMs=\(elapsedMs)") return } + let resolveMs = Int(Date().timeIntervalSince(resolveStart) * 1000) + logMultiView("playNextWerkoutClip resolved id=\(stream.id) resolveMs=\(resolveMs) nextURL=\(nextURL.lastPathComponent)") let nextItem = makePlayerItem( url: nextURL, @@ -790,10 +845,27 @@ private struct MultiStreamTile: View { label: stream.label, onPlaybackEnded: playbackEndedHandler(for: player) ) - viewModel.attachPlayer(player, to: stream.id) scheduleStartupPlaybackRecovery(for: player) - logMultiView("playNextWerkoutClip replay id=\(stream.id) url=\(nextURL.absoluteString)") + logMultiView("playNextWerkoutClip replay id=\(stream.id) url=\(nextURL.lastPathComponent)") player.playImmediately(atRate: 1.0) + installClipTimeLimit(on: player) + + // Monitor for failure and auto-skip to next clip + Task { @MainActor in + for checkDelay in [1.0, 3.0] { + try? await Task.sleep(for: .seconds(checkDelay)) + let postItemStatus = player.currentItem?.status + let error = player.currentItem?.error?.localizedDescription ?? "nil" + logMultiView( + "playNextWerkoutClip postCheck id=\(stream.id) delay=\(checkDelay)s rate=\(player.rate) itemStatus=\(postItemStatus?.rawValue ?? -1) error=\(error)" + ) + if postItemStatus == .failed { + logMultiView("playNextWerkoutClip AUTO-SKIP id=\(stream.id) reason=item-failed error=\(error)") + await playNextWerkoutClip(on: player) + return + } + } + } } } diff --git a/mlbTVOS/Views/SingleStreamPlayerView.swift b/mlbTVOS/Views/SingleStreamPlayerView.swift index 8377130..dcfa7ef 100644 --- a/mlbTVOS/Views/SingleStreamPlayerView.swift +++ b/mlbTVOS/Views/SingleStreamPlayerView.swift @@ -80,28 +80,104 @@ struct SingleStreamPlaybackScreen: View { let resolveSource: @Sendable () async -> SingleStreamPlaybackSource? var resolveNextSource: (@Sendable (URL?) async -> SingleStreamPlaybackSource?)? = nil let tickerGames: [Game] + var game: Game? = nil + var onPiPActiveChanged: ((Bool) -> Void)? = nil + + @State private var showGameCenter = false + @State private var showPitchInfo = false + @State private var pitchViewModel = GameCenterViewModel() + @State private var isPiPActive = false var body: some View { ZStack(alignment: .bottom) { - SingleStreamPlayerView(resolveSource: resolveSource, resolveNextSource: resolveNextSource) - .ignoresSafeArea() + SingleStreamPlayerView( + resolveSource: resolveSource, + resolveNextSource: resolveNextSource, + hasGamePk: game?.gamePk != nil, + onTogglePitchInfo: { + showPitchInfo.toggle() + if showPitchInfo { showGameCenter = false } + }, + onToggleGameCenter: { + showGameCenter.toggle() + if showGameCenter { showPitchInfo = false } + }, + onPiPStateChanged: { active in + isPiPActive = active + onPiPActiveChanged?(active) + }, + showPitchInfo: showPitchInfo, + showGameCenter: showGameCenter + ) + .ignoresSafeArea() - SingleStreamScoreStripView(games: tickerGames) - .allowsHitTesting(false) - .padding(.horizontal, 18) - .padding(.bottom, 14) - } - .overlay(alignment: .topTrailing) { - #if os(iOS) - Button { - dismiss() - } label: { - Image(systemName: "xmark.circle.fill") - .font(.system(size: 28, weight: .bold)) - .foregroundStyle(.white.opacity(0.9)) - .padding(20) + if !showGameCenter && !showPitchInfo { + SingleStreamScoreStripView(games: tickerGames) + .allowsHitTesting(false) + .padding(.horizontal, 18) + .padding(.bottom, 14) + .transition(.opacity) + } + + if showGameCenter, let game { + gameCenterOverlay(game: game) + .transition(.move(edge: .bottom).combined(with: .opacity)) + } + } + .animation(.easeInOut(duration: 0.3), value: showGameCenter) + .animation(.easeInOut(duration: 0.3), value: showPitchInfo) + .overlay(alignment: .bottomLeading) { + if showPitchInfo, let feed = pitchViewModel.feed { + pitchInfoBox(feed: feed) + .transition(.move(edge: .leading).combined(with: .opacity)) + } + } + #if os(iOS) + .overlay(alignment: .topTrailing) { + HStack(spacing: 12) { + if game?.gamePk != nil { + Button { + showPitchInfo.toggle() + if showPitchInfo { showGameCenter = false } + } label: { + Image(systemName: showPitchInfo ? "xmark.circle.fill" : "baseball.fill") + .font(.system(size: 28, weight: .bold)) + .foregroundStyle(.white.opacity(0.9)) + .padding(6) + .background(.black.opacity(0.5)) + .clipShape(Circle()) + } + + Button { + showGameCenter.toggle() + if showGameCenter { showPitchInfo = false } + } label: { + Image(systemName: showGameCenter ? "xmark.circle.fill" : "chart.bar.fill") + .font(.system(size: 28, weight: .bold)) + .foregroundStyle(.white.opacity(0.9)) + .padding(6) + .background(.black.opacity(0.5)) + .clipShape(Circle()) + } + + Button { + dismiss() + } label: { + Image(systemName: "xmark.circle.fill") + .font(.system(size: 28, weight: .bold)) + .foregroundStyle(.white.opacity(0.9)) + } + } + } + .padding(20) + } + #endif + .task(id: game?.gamePk) { + guard let gamePk = game?.gamePk else { return } + while !Task.isCancelled { + await pitchViewModel.refresh(gamePk: gamePk) + try? await Task.sleep(for: .seconds(5)) } - #endif } .ignoresSafeArea() .onAppear { @@ -111,6 +187,135 @@ struct SingleStreamPlaybackScreen: View { logSingleStream("SingleStreamPlaybackScreen disappeared") } } + + private func gameCenterOverlay(game: Game) -> some View { + ScrollView { + GameCenterView(game: game) + .padding(.horizontal, 20) + .padding(.top, 60) + .padding(.bottom, 40) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(.black.opacity(0.82)) + } + + private func pitchInfoBox(feed: LiveGameFeed) -> some View { + let pitches = feed.currentAtBatPitches + let batter = feed.currentBatter?.displayName ?? "—" + let pitcher = feed.currentPitcher?.displayName ?? "—" + let countText = feed.currentCountText ?? "" + + return VStack(alignment: .leading, spacing: 8) { + // Matchup header — use last name only to save space + HStack(spacing: 8) { + VStack(alignment: .leading, spacing: 2) { + Text("AB") + .font(.system(size: 10, weight: .bold, design: .rounded)) + .foregroundStyle(.white.opacity(0.4)) + Text(batter) + .font(.system(size: 14, weight: .bold, design: .rounded)) + .foregroundStyle(.white) + .lineLimit(1) + .minimumScaleFactor(0.7) + } + + Spacer() + + VStack(alignment: .trailing, spacing: 2) { + Text("P") + .font(.system(size: 10, weight: .bold, design: .rounded)) + .foregroundStyle(.white.opacity(0.4)) + Text(pitcher) + .font(.system(size: 14, weight: .bold, design: .rounded)) + .foregroundStyle(.white) + .lineLimit(1) + .minimumScaleFactor(0.7) + } + } + + if !countText.isEmpty { + Text(countText) + .font(.system(size: 13, weight: .semibold, design: .rounded)) + .foregroundStyle(.white.opacity(0.7)) + } + + if !pitches.isEmpty { + // Latest pitch — bold and prominent + if let last = pitches.last { + let color = pitchCallColor(last.callCode) + HStack(spacing: 6) { + if let speed = last.speedMPH { + Text("\(speed, specifier: "%.1f")") + .font(.system(size: 24, weight: .black).monospacedDigit()) + .foregroundStyle(.white) + Text("mph") + .font(.system(size: 12, weight: .bold, design: .rounded)) + .foregroundStyle(.white.opacity(0.5)) + } + Spacer() + VStack(alignment: .trailing, spacing: 2) { + Text(last.pitchTypeDescription) + .font(.system(size: 14, weight: .bold, design: .rounded)) + .foregroundStyle(.white) + .lineLimit(1) + Text(last.callDescription) + .font(.system(size: 12, weight: .bold, design: .rounded)) + .foregroundStyle(color) + } + } + } + + // Strike zone + previous pitches side by side + HStack(alignment: .top, spacing: 14) { + StrikeZoneView(pitches: pitches, size: 120) + + // Previous pitches — compact rows + if pitches.count > 1 { + VStack(alignment: .leading, spacing: 3) { + ForEach(Array(pitches.dropLast().reversed().prefix(8).enumerated()), id: \.offset) { _, pitch in + let color = pitchCallColor(pitch.callCode) + HStack(spacing: 4) { + Text("\(pitch.pitchNumber ?? 0)") + .font(.system(size: 10, weight: .bold).monospacedDigit()) + .foregroundStyle(.white.opacity(0.35)) + .frame(width: 14, alignment: .trailing) + Text(shortPitchType(pitch.pitchTypeCode)) + .font(.system(size: 11, weight: .semibold, design: .rounded)) + .foregroundStyle(.white.opacity(0.7)) + if let speed = pitch.speedMPH { + Text("\(speed, specifier: "%.0f")") + .font(.system(size: 11, weight: .bold).monospacedDigit()) + .foregroundStyle(.white.opacity(0.45)) + } + Spacer(minLength: 0) + Circle() + .fill(color) + .frame(width: 6, height: 6) + } + } + } + .frame(maxWidth: .infinity, alignment: .leading) + } + } + } else { + Text("Waiting for pitch data...") + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(.white.opacity(0.5)) + } + } + .frame(width: 300) + .padding(16) + .background( + RoundedRectangle(cornerRadius: 18, style: .continuous) + .fill(.black.opacity(0.78)) + .overlay { + RoundedRectangle(cornerRadius: 18, style: .continuous) + .strokeBorder(.white.opacity(0.1), lineWidth: 1) + } + ) + .padding(.leading, 24) + .padding(.bottom, 50) + } } struct SingleStreamPlaybackSource: Sendable { @@ -290,6 +495,12 @@ private final class SingleStreamMarqueeContainerView: UIView { struct SingleStreamPlayerView: UIViewControllerRepresentable { let resolveSource: @Sendable () async -> SingleStreamPlaybackSource? var resolveNextSource: (@Sendable (URL?) async -> SingleStreamPlaybackSource?)? = nil + var hasGamePk: Bool = false + var onTogglePitchInfo: (() -> Void)? = nil + var onToggleGameCenter: (() -> Void)? = nil + var onPiPStateChanged: ((Bool) -> Void)? = nil + var showPitchInfo: Bool = false + var showGameCenter: Bool = false func makeCoordinator() -> Coordinator { Coordinator() @@ -300,10 +511,24 @@ struct SingleStreamPlayerView: UIViewControllerRepresentable { let controller = AVPlayerViewController() controller.allowsPictureInPicturePlayback = true controller.showsPlaybackControls = true + context.coordinator.onPiPStateChanged = onPiPStateChanged + controller.delegate = context.coordinator #if os(iOS) controller.canStartPictureInPictureAutomaticallyFromInline = true #endif - logSingleStream("AVPlayerViewController configured without contentOverlayView ticker") + + #if os(tvOS) + if hasGamePk { + context.coordinator.onTogglePitchInfo = onTogglePitchInfo + context.coordinator.onToggleGameCenter = onToggleGameCenter + controller.transportBarCustomMenuItems = context.coordinator.buildTransportBarItems( + showPitchInfo: showPitchInfo, + showGameCenter: showGameCenter + ) + } + #endif + + logSingleStream("AVPlayerViewController configured") Task { @MainActor in let resolveStartedAt = Date() @@ -335,26 +560,102 @@ struct SingleStreamPlayerView: UIViewControllerRepresentable { controller.player = player logSingleStream("AVPlayer assigned to controller; calling playImmediately(atRate: 1.0)") player.playImmediately(atRate: 1.0) + context.coordinator.installClipTimeLimit(on: player, resolveNextSource: resolveNextSource) context.coordinator.scheduleStartupRecovery(for: player) } return controller } - func updateUIViewController(_ uiViewController: AVPlayerViewController, context: Context) {} + func updateUIViewController(_ uiViewController: AVPlayerViewController, context: Context) { + context.coordinator.onPiPStateChanged = onPiPStateChanged + #if os(tvOS) + if hasGamePk { + context.coordinator.onTogglePitchInfo = onTogglePitchInfo + context.coordinator.onToggleGameCenter = onToggleGameCenter + uiViewController.transportBarCustomMenuItems = context.coordinator.buildTransportBarItems( + showPitchInfo: showPitchInfo, + showGameCenter: showGameCenter + ) + } + #endif + } static func dismantleUIViewController(_ uiViewController: AVPlayerViewController, coordinator: Coordinator) { - logSingleStream("dismantleUIViewController start") + logSingleStream("dismantleUIViewController start isPiPActive=\(coordinator.isPiPActive)") + if coordinator.isPiPActive { + logSingleStream("dismantleUIViewController skipped — PiP is active") + return + } coordinator.clearDebugObservers() uiViewController.player?.pause() uiViewController.player = nil logSingleStream("dismantleUIViewController complete") } - final class Coordinator: NSObject, @unchecked Sendable { + final class Coordinator: NSObject, @unchecked Sendable, AVPlayerViewControllerDelegate { private var playerObservations: [NSKeyValueObservation] = [] private var notificationTokens: [NSObjectProtocol] = [] private var startupRecoveryTask: Task? + private var clipTimeLimitObserver: Any? + private static let maxClipDuration: Double = 15.0 + var onTogglePitchInfo: (() -> Void)? + var onToggleGameCenter: (() -> Void)? + var isPiPActive = false + var onPiPStateChanged: ((Bool) -> Void)? + + func playerViewControllerShouldAutomaticallyDismissAtPictureInPictureStart(_ playerViewController: AVPlayerViewController) -> Bool { + logSingleStream("PiP: shouldAutomaticallyDismiss returning false") + return false + } + + func playerViewControllerWillStartPictureInPicture(_ playerViewController: AVPlayerViewController) { + logSingleStream("PiP: willStart") + isPiPActive = true + onPiPStateChanged?(true) + } + + func playerViewControllerDidStartPictureInPicture(_ playerViewController: AVPlayerViewController) { + logSingleStream("PiP: didStart") + } + + func playerViewControllerWillStopPictureInPicture(_ playerViewController: AVPlayerViewController) { + logSingleStream("PiP: willStop") + } + + func playerViewControllerDidStopPictureInPicture(_ playerViewController: AVPlayerViewController) { + logSingleStream("PiP: didStop") + isPiPActive = false + onPiPStateChanged?(false) + } + + func playerViewController( + _ playerViewController: AVPlayerViewController, + restoreUserInterfaceForPictureInPictureStopWithCompletionHandler completionHandler: @escaping (Bool) -> Void + ) { + logSingleStream("PiP: restoreUserInterface") + completionHandler(true) + } + + #if os(tvOS) + func buildTransportBarItems(showPitchInfo: Bool, showGameCenter: Bool) -> [UIAction] { + let pitchAction = UIAction( + title: showPitchInfo ? "Hide Pitch Info" : "Pitch Info", + image: UIImage(systemName: showPitchInfo ? "xmark.circle.fill" : "baseball.fill") + ) { [weak self] _ in + self?.onTogglePitchInfo?() + } + + let gcAction = UIAction( + title: showGameCenter ? "Hide Game Center" : "Game Center", + image: UIImage(systemName: showGameCenter ? "xmark.circle.fill" : "chart.bar.fill") + ) { [weak self] _ in + self?.onToggleGameCenter?() + } + + return [pitchAction, gcAction] + } + #endif func attachDebugObservers( to player: AVPlayer, @@ -456,6 +757,7 @@ struct SingleStreamPlayerView: UIViewControllerRepresentable { self.attachDebugObservers(to: player, url: nextSource.url, resolveNextSource: resolveNextSource) logSingleStream("Autoplay replacing item and replaying url=\(singleStreamDebugURLDescription(nextSource.url))") player.playImmediately(atRate: 1.0) + self.installClipTimeLimit(on: player, resolveNextSource: resolveNextSource) self.scheduleStartupRecovery(for: player) } } @@ -522,6 +824,45 @@ struct SingleStreamPlayerView: UIViewControllerRepresentable { } } + func installClipTimeLimit( + on player: AVPlayer, + resolveNextSource: (@Sendable (URL?) async -> SingleStreamPlaybackSource?)? + ) { + removeClipTimeLimit(from: player) + guard resolveNextSource != nil else { return } + let limit = CMTime(seconds: Self.maxClipDuration, preferredTimescale: 600) + clipTimeLimitObserver = player.addBoundaryTimeObserver( + forTimes: [NSValue(time: limit)], + queue: .main + ) { [weak self, weak player] in + guard let self, let player, let resolveNextSource else { return } + logSingleStream("clipTimeLimit hit \(Self.maxClipDuration)s — advancing to next clip") + Task { @MainActor [weak self] in + guard let self else { return } + let currentURL = (player.currentItem?.asset as? AVURLAsset)?.url + guard let nextSource = await resolveNextSource(currentURL) else { + logSingleStream("clipTimeLimit next source nil") + return + } + let nextItem = makeSingleStreamPlayerItem(from: nextSource) + player.replaceCurrentItem(with: nextItem) + player.automaticallyWaitsToMinimizeStalling = false + player.isMuted = nextSource.forceMuteAudio + self.attachDebugObservers(to: player, url: nextSource.url, resolveNextSource: resolveNextSource) + player.playImmediately(atRate: 1.0) + self.installClipTimeLimit(on: player, resolveNextSource: resolveNextSource) + self.scheduleStartupRecovery(for: player) + } + } + } + + private func removeClipTimeLimit(from player: AVPlayer) { + if let observer = clipTimeLimitObserver { + player.removeTimeObserver(observer) + clipTimeLimitObserver = nil + } + } + func clearDebugObservers() { startupRecoveryTask?.cancel() startupRecoveryTask = nil diff --git a/mlbTVOSTests/VideoShuffleTests.swift b/mlbTVOSTests/VideoShuffleTests.swift new file mode 100644 index 0000000..d885f60 --- /dev/null +++ b/mlbTVOSTests/VideoShuffleTests.swift @@ -0,0 +1,252 @@ +import Foundation +import Testing +@testable import mlbTVOS + +struct VideoShuffleTests { + // MARK: - Helpers + + private struct Clip: Equatable, Hashable { + let model: String + let id: Int + } + + private static func makeClips(_ counts: [String: Int]) -> [Clip] { + var result: [Clip] = [] + var idCounter = 0 + for (model, count) in counts.sorted(by: { $0.key < $1.key }) { + for _ in 0.. Int { + guard !clips.isEmpty else { return 0 } + var maxRun = 1 + var currentRun = 1 + for i in 1.. UInt64 { + state ^= state << 13 + state ^= state >> 7 + state ^= state << 17 + return state + } + } + + // MARK: - groupByModel + + @Test func groupByModelBucketsCorrectly() { + let clips = Self.makeClips(["a": 3, "b": 2, "c": 1]) + var rng = SeededRNG(seed: 1) + let groups = VideoShuffle.groupByModel( + clips, + keyFor: { $0.model }, + using: &rng + ) + #expect(groups["a"]?.count == 3) + #expect(groups["b"]?.count == 2) + #expect(groups["c"]?.count == 1) + #expect(groups.count == 3) + } + + @Test func groupByModelHandlesEmptyInput() { + var rng = SeededRNG(seed: 1) + let groups = VideoShuffle.groupByModel( + [Clip](), + keyFor: { $0.model }, + using: &rng + ) + #expect(groups.isEmpty) + } + + @Test func groupByModelNilKeysGoToEmptyStringBucket() { + struct Item { let key: String?; let id: Int } + let items = [ + Item(key: "a", id: 0), + Item(key: nil, id: 1), + Item(key: nil, id: 2), + ] + var rng = SeededRNG(seed: 1) + let groups = VideoShuffle.groupByModel( + items, + keyFor: { $0.key }, + using: &rng + ) + #expect(groups["a"]?.count == 1) + #expect(groups[""]?.count == 2) + } + + // MARK: - pickRandomFromBuckets + + @Test func pickRandomFromBucketsReturnsNilWhenAllEmpty() { + let buckets: [String: [Clip]] = ["a": [], "b": []] + var rng = SeededRNG(seed: 1) + let result = VideoShuffle.pickRandomFromBuckets( + buckets, + using: &rng + ) + #expect(result == nil) + } + + @Test func pickRandomFromBucketsRemovesPickedItem() { + let buckets: [String: [Clip]] = [ + "a": [Clip(model: "a", id: 0), Clip(model: "a", id: 1)], + "b": [Clip(model: "b", id: 2)], + ] + var rng = SeededRNG(seed: 1) + let result = VideoShuffle.pickRandomFromBuckets( + buckets, + using: &rng + ) + #expect(result != nil) + guard let pick = result else { return } + let originalCount = buckets.values.reduce(0) { $0 + $1.count } + let remainingCount = pick.remaining.values.reduce(0) { $0 + $1.count } + #expect(remainingCount == originalCount - 1) + } + + @Test func pickRandomFromBucketsRespectsExclusion() { + // With two non-empty buckets, excluding "a" should always return "b". + let buckets: [String: [Clip]] = [ + "a": [Clip(model: "a", id: 0), Clip(model: "a", id: 1)], + "b": [Clip(model: "b", id: 2), Clip(model: "b", id: 3)], + ] + var rng = SeededRNG(seed: 1) + for _ in 0..<20 { + let result = VideoShuffle.pickRandomFromBuckets( + buckets, + excludingKey: "a", + using: &rng + ) + #expect(result?.key == "b") + } + } + + @Test func pickRandomFromBucketsFallsBackWhenOnlyExcludedIsNonEmpty() { + let buckets: [String: [Clip]] = [ + "a": [Clip(model: "a", id: 0)], + "b": [], + ] + var rng = SeededRNG(seed: 1) + let result = VideoShuffle.pickRandomFromBuckets( + buckets, + excludingKey: "a", + using: &rng + ) + #expect(result?.key == "a", "should fall back to 'a' since 'b' is empty") + } + + // MARK: - End-to-end: never same model back-to-back + + /// Simulates a full playback session on the real NSFW distribution and + /// verifies that no two consecutive picks share a model. This is the + /// invariant the user actually cares about. + @Test func realNSFWDistributionNeverBackToBack() { + let clips = Self.makeClips([ + "kayla-lauren": 502, + "tabatachang": 147, + "werkout": 137, + "ray-mattos": 108, + "dani-speegle-2": 49, + "josie-hamming-2": 13, + ]) + #expect(clips.count == 956) + + var rng = SeededRNG(seed: 42) + var buckets = VideoShuffle.groupByModel( + clips, + keyFor: { $0.model }, + using: &rng + ) + + // Simulate 2000 picks — more than the total clip count so we exercise + // the refill logic for the small buckets (josie-hamming-2 has only 13). + var sequence: [Clip] = [] + var lastModel: String? = nil + for _ in 0..<2000 { + // Refill any empty buckets by reshuffling the original clips for + // that model (mirrors the production logic in GamesViewModel). + for key in Array(buckets.keys) where buckets[key]?.isEmpty ?? true { + let modelClips = clips.filter { $0.model == key } + buckets[key] = modelClips.shuffled(using: &rng) + } + + guard let pick = VideoShuffle.pickRandomFromBuckets( + buckets, + excludingKey: lastModel, + using: &rng + ) else { + Issue.record("pick returned nil") + return + } + buckets = pick.remaining + sequence.append(pick.item) + lastModel = pick.key + } + + let run = Self.maxRunLength(sequence) + #expect(run == 1, "expected max run 1, got \(run)") + } + + @Test func modelDistributionIsRoughlyUniform() { + // Over a long session, each model should be picked roughly + // (totalPicks / modelCount) times — not weighted by bucket size. + let clips = Self.makeClips([ + "kayla-lauren": 502, + "tabatachang": 147, + "werkout": 137, + "ray-mattos": 108, + "dani-speegle-2": 49, + "josie-hamming-2": 13, + ]) + + var rng = SeededRNG(seed: 99) + var buckets = VideoShuffle.groupByModel( + clips, + keyFor: { $0.model }, + using: &rng + ) + + let totalPicks = 6000 + var modelCounts: [String: Int] = [:] + var lastModel: String? = nil + for _ in 0..= 750 && count <= 1250, "\(model) got \(count) picks") + } + } +} diff --git a/project.yml b/project.yml index cc4e972..a9ba1df 100644 --- a/project.yml +++ b/project.yml @@ -23,6 +23,17 @@ targets: CODE_SIGN_ENTITLEMENTS: mlbTVOS/mlbTVOS.entitlements PRODUCT_BUNDLE_IDENTIFIER: com.treyt.mlbTVOS SWIFT_STRICT_CONCURRENCY: complete + mlbTVOSTests: + type: bundle.unit-test + platform: tvOS + sources: [mlbTVOSTests] + dependencies: + - target: mlbTVOS + settings: + base: + PRODUCT_BUNDLE_IDENTIFIER: com.treyt.mlbTVOSTests + GENERATE_INFOPLIST_FILE: YES + SWIFT_STRICT_CONCURRENCY: complete mlbIOS: type: application platform: iOS