Add game center, per-model shuffle, audio focus fixes, README, tests

- README.md with build/architecture overview
- Game Center screen with at-bat timeline, pitch sequence, spray chart,
  and strike zone component views
- VideoShuffle service: per-model bucketed random selection with
  no-back-to-back guarantee; replaces flat shuffle-bag approach
- Refresh JWT token for authenticated NSFW feed; add josie-hamming-2
  and dani-speegle-2 to the user list
- MultiStreamView audio focus: remove redundant isMuted writes during
  startStream and playNextWerkoutClip so audio stops ducking during
  clip transitions; gate AVAudioSession.setCategory(.playback) behind
  a one-shot flag
- GamesViewModel.attachPlayer: skip mute recalculation when the same
  player is re-attached (prevents toggle flicker on item replace)
- mlbTVOSTests target wired through project.yml with
  GENERATE_INFOPLIST_FILE; VideoShuffleTests covers groupByModel,
  pickRandomFromBuckets, real-distribution no-back-to-back invariant,
  and uniform model distribution over 6000 picks

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-04-11 11:02:46 -05:00
parent 58e4c36963
commit 88308b46f5
17 changed files with 2069 additions and 313 deletions

91
README.md Normal file
View File

@@ -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`.

View File

@@ -7,147 +7,149 @@
objects = { objects = {
/* Begin PBXBuildFile section */ /* 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 */; }; 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 */; }; 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 */; }; 1E31C6F500CEEA5284081D22 /* MLBServerAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 70E610D24BA83FB2F7490B00 /* MLBServerAPI.swift */; };
1E5405E1C27A16971961091C /* ScoreOverlayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5F6A7B8C9D0E1F2A3B4C5D6 /* ScoreOverlayView.swift */; }; 20A122430083142C3C74AF5F /* TeamLogoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2E99F69727800D0CB795503 /* TeamLogoView.swift */; };
22B1A4EB0E8E279B054FEC3E /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E04B78C78BA9DA73B62AA3EE /* Foundation.framework */; }; 26AE6D91B829B233CEDAE7DF /* AtBatTimelineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3D1C29BF9DF2B8F811368BC /* AtBatTimelineView.swift */; };
24C6D5F4A3B291807F6E5D4C /* ScoresTickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 24C6D5F4A3B291807F6E5D4B /* ScoresTickerView.swift */; };
29627CA5EE85BA103B38D1F5 /* Game.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1ABC999C711B074CF0B536DD /* Game.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 */; }; 342EED600E1A5B4A3D083E50 /* TeamAssets.swift in Sources */ = {isa = PBXBuildFile; fileRef = 645E77DD8183D54EFC97CFB5 /* TeamAssets.swift */; };
37BFF8231552B49E4775AA59 /* GamesViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9BD2DEA6BBA55E86B2555099 /* GamesViewModel.swift */; }; 3679F9F4949C8B3B8D5D6E94 /* GameCenterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E80EF8B01452220FA6BCD9B2 /* GameCenterView.swift */; };
3FB9BCB07B6DBFE6D1D2C523 /* GameCenterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2BB22BB22BB22BB22BB22BB1 /* GameCenterView.swift */; }; 36E4F79F23EF867A100E924D /* PitchSequenceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7F13A88254E53AF5A4AAA2AA /* PitchSequenceView.swift */; };
4318098424F9B3FF3B256EED /* LeagueCenterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2BB22BB22BB22BB22BB22BB2 /* LeagueCenterView.swift */; }; 468EB7F6EC98D83099E2C352 /* PlatformUI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B4E350321B6673B42545860 /* PlatformUI.swift */; };
43AC37B4F957CDC3986F044A /* mlbIOSApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0209C78036DA08FA794D8D9 /* mlbIOSApp.swift */; }; 470E17740A5C54C6BF2C1C97 /* StrikeZoneView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E345FE2F997DC60C7A3E9158 /* StrikeZoneView.swift */; };
43B16E04582A874E87514C30 /* mlbIOSRootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F874FDD20F82AFEB475CD73 /* mlbIOSRootView.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 */; }; 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 */; }; 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 */; }; 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 */; }; 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 */; }; 80A690C52C9C8140E7C519E8 /* TeamLogoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2E99F69727800D0CB795503 /* TeamLogoView.swift */; };
820FB03D0E97C521BA239071 /* MLBNetworkSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5152ABCC9AACFD91BAC8C95B /* MLBNetworkSheet.swift */; }; 820FB03D0E97C521BA239071 /* MLBNetworkSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5152ABCC9AACFD91BAC8C95B /* MLBNetworkSheet.swift */; };
82DEB54FF3BFD51A18ED98A1 /* MLBStatsAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6067FE4F340FC0DCCFA62C67 /* MLBStatsAPI.swift */; }; 8649D4F513A663D2493D91C9 /* LeagueCenterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55E6F6797DA08FA657C04818 /* LeagueCenterView.swift */; };
8D5FE07166FAD17836A0AFF4 /* PitcherHeadshotView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4E5F6A7B8C9D0E1F2A3B4C5 /* PitcherHeadshotView.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 */; }; 9D29D3508CD05CB3F4975910 /* GamesViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9BD2DEA6BBA55E86B2555099 /* GamesViewModel.swift */; };
A1B2C3D4E5F60718091A2B3C /* PitcherHeadshotView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4E5F6A7B8C9D0E1F2A3B4C5 /* PitcherHeadshotView.swift */; }; A70F5B83F079279CF2B41FCE /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FB99FBEDA096B95C3D2E5B4 /* ContentView.swift */; };
A506FDC86C3A78F4A2C04C10 /* GameListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B5D51D6E4990515411D3BCD /* GameListView.swift */; }; A7195DD696E4C07218D74F19 /* PitcherHeadshotView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C6E303B753C604504C2762C7 /* PitcherHeadshotView.swift */; };
AFA76F6EBE7977914A849447 /* FeaturedGameCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 102E5E8674E188A04637D3EB /* FeaturedGameCard.swift */; }; AAD1562E79743AA0B09CB857 /* GameCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E21E3115B8A9B4C798ECE828 /* GameCardView.swift */; };
B215941F5A269C59C0939958 /* TeamLogoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2E99F69727800D0CB795503 /* TeamLogoView.swift */; }; ACEB18A23D20F79AA757DD63 /* Game.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1ABC999C711B074CF0B536DD /* Game.swift */; };
B2C3D4E5F6A70819A1B2C3D4 /* ScoreOverlayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5F6A7B8C9D0E1F2A3B4C5D6 /* ScoreOverlayView.swift */; }; AEE0045C962DDF789E61429B /* TeamAssets.swift in Sources */ = {isa = PBXBuildFile; fileRef = 645E77DD8183D54EFC97CFB5 /* TeamAssets.swift */; };
B921B07F43A13CBBDE9C499D /* GameCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E21E3115B8A9B4C798ECE828 /* GameCardView.swift */; }; AF6279D4B9E386009C27EC23 /* VideoShuffleTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A5568E639232B59AEB84FD4 /* VideoShuffleTests.swift */; };
BA1B3240360F79FE2DF42543 /* LinescoreView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 327A33F2676712C2B5B4AF2F /* LinescoreView.swift */; }; B0A9FFD5C20A80E16532CC91 /* LeagueCenterViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E9E572D932D96FE2C5BD7A4 /* LeagueCenterViewModel.swift */; };
BB48CC4C732672536CD5EF96 /* TeamAssets.swift in Sources */ = {isa = PBXBuildFile; fileRef = 645E77DD8183D54EFC97CFB5 /* TeamAssets.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 */; }; C1DA213471DC246B8DF3F840 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = AC5BCB2E1DA0EC63E422B113 /* Assets.xcassets */; };
C67442678865108796537BBC /* GameCenterViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2BB22BB22BB22BB22BB22BB3 /* GameCenterViewModel.swift */; }; D688F6B0680C29AECD289CA9 /* SingleStreamPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA4AF68CDF4FC8D07194B4D0 /* SingleStreamPlayerView.swift */; };
CA20CF293D75E6058E0CB78C /* SingleStreamPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA4AF68CDF4FC8D07194B4D0 /* SingleStreamPlayerView.swift */; }; D864A2844FD222A04B48F6EF /* MLBNetworkSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5152ABCC9AACFD91BAC8C95B /* MLBNetworkSheet.swift */; };
D199D622D64E3489E049B04A /* LiveIndicator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 81584770090BAFBEF3ABE6FD /* LiveIndicator.swift */; };
D48041F6CFBCEB2F3492747F /* MultiStreamView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D10F2D91602CE1FB6D7342E /* MultiStreamView.swift */; };
DA156E98ACD90B582F3BBAB2 /* PlatformUI.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBA78A938A07E07B064CEA54 /* PlatformUI.swift */; };
DB6E6A4890D0D22526DA5D96 /* ScoresTickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 24C6D5F4A3B291807F6E5D4B /* ScoresTickerView.swift */; };
DC37555DE8C367E929974484 /* MLBStatsAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6067FE4F340FC0DCCFA62C67 /* MLBStatsAPI.swift */; }; 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 */; }; 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 */; }; 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 */; }; FAFA9B29273C4D8A54CD5916 /* FeaturedGameCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 102E5E8674E188A04637D3EB /* FeaturedGameCard.swift */; };
FD31074216AC0115B3F1C057 /* mlbTVOSApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 45142562E644AF04F7719083 /* mlbTVOSApp.swift */; }; FD31074216AC0115B3F1C057 /* mlbTVOSApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 45142562E644AF04F7719083 /* mlbTVOSApp.swift */; };
FD9322BED3A2B827CB021163 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DE9E708FA75ABB9F49D2392 /* SettingsView.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 */ /* 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 */ /* Begin PBXFileReference section */
0B5D51D6E4990515411D3BCD /* GameListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameListView.swift; sourceTree = "<group>"; }; 0B5D51D6E4990515411D3BCD /* GameListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameListView.swift; sourceTree = "<group>"; };
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 = "<group>"; }; 102E5E8674E188A04637D3EB /* FeaturedGameCard.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FeaturedGameCard.swift; sourceTree = "<group>"; };
166FABBD2D739048BB1A86A6 /* mlbIOS.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = mlbIOS.entitlements; sourceTree = "<group>"; };
1ABC999C711B074CF0B536DD /* Game.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Game.swift; sourceTree = "<group>"; }; 1ABC999C711B074CF0B536DD /* Game.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Game.swift; sourceTree = "<group>"; };
24B58FDA7A8CA27BC512CBA6 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; }; 24B58FDA7A8CA27BC512CBA6 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
24C6D5F4A3B291807F6E5D4B /* ScoresTickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScoresTickerView.swift; sourceTree = "<group>"; }; 2CC4A28B180001C2A1B0BA5F /* mlbIOSApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = mlbIOSApp.swift; sourceTree = "<group>"; };
2BB22BB22BB22BB22BB22BB1 /* GameCenterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameCenterView.swift; sourceTree = "<group>"; };
2BB22BB22BB22BB22BB22BB2 /* LeagueCenterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LeagueCenterView.swift; sourceTree = "<group>"; };
2BB22BB22BB22BB22BB22BB3 /* GameCenterViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameCenterViewModel.swift; sourceTree = "<group>"; };
2BB22BB22BB22BB22BB22BB4 /* LeagueCenterViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LeagueCenterViewModel.swift; sourceTree = "<group>"; };
327A33F2676712C2B5B4AF2F /* LinescoreView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinescoreView.swift; sourceTree = "<group>"; }; 327A33F2676712C2B5B4AF2F /* LinescoreView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LinescoreView.swift; sourceTree = "<group>"; };
3F874FDD20F82AFEB475CD73 /* mlbIOSRootView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = mlbIOSRootView.swift; sourceTree = "<group>"; }; 339D5F94415E8C018D384CDE /* ScoresTickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScoresTickerView.swift; sourceTree = "<group>"; };
3FB99FBEDA096B95C3D2E5B4 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; }; 3FB99FBEDA096B95C3D2E5B4 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = "<group>"; };
45142562E644AF04F7719083 /* mlbTVOSApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = mlbTVOSApp.swift; sourceTree = "<group>"; }; 45142562E644AF04F7719083 /* mlbTVOSApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = mlbTVOSApp.swift; sourceTree = "<group>"; };
4DE9E708FA75ABB9F49D2392 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; }; 4DE9E708FA75ABB9F49D2392 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
5152ABCC9AACFD91BAC8C95B /* MLBNetworkSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MLBNetworkSheet.swift; sourceTree = "<group>"; }; 5152ABCC9AACFD91BAC8C95B /* MLBNetworkSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MLBNetworkSheet.swift; sourceTree = "<group>"; };
55A3DC0B5087BAD6BC59B35A /* mlbIOS.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = mlbIOS.app; sourceTree = BUILT_PRODUCTS_DIR; }; 55E6F6797DA08FA657C04818 /* LeagueCenterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LeagueCenterView.swift; sourceTree = "<group>"; };
56358D0D5DC5CC531034287F /* Assets.xcassets */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
5EA4C33F029665B80F1A6B6D /* DashboardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashboardView.swift; sourceTree = "<group>"; }; 5EA4C33F029665B80F1A6B6D /* DashboardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DashboardView.swift; sourceTree = "<group>"; };
6067FE4F340FC0DCCFA62C67 /* MLBStatsAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MLBStatsAPI.swift; sourceTree = "<group>"; }; 6067FE4F340FC0DCCFA62C67 /* MLBStatsAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MLBStatsAPI.swift; sourceTree = "<group>"; };
60B7F01667B2EBFBDFC1BBF6 /* ScoreOverlayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScoreOverlayView.swift; sourceTree = "<group>"; };
645E77DD8183D54EFC97CFB5 /* TeamAssets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeamAssets.swift; sourceTree = "<group>"; }; 645E77DD8183D54EFC97CFB5 /* TeamAssets.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeamAssets.swift; sourceTree = "<group>"; };
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 = "<group>"; }; 70E610D24BA83FB2F7490B00 /* MLBServerAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MLBServerAPI.swift; sourceTree = "<group>"; };
766441BC900073529EE93D69 /* mlbTVOS.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = mlbTVOS.app; sourceTree = BUILT_PRODUCTS_DIR; }; 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 = "<group>"; };
7E9E572D932D96FE2C5BD7A4 /* LeagueCenterViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LeagueCenterViewModel.swift; sourceTree = "<group>"; };
7F13A88254E53AF5A4AAA2AA /* PitchSequenceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PitchSequenceView.swift; sourceTree = "<group>"; };
81584770090BAFBEF3ABE6FD /* LiveIndicator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveIndicator.swift; sourceTree = "<group>"; }; 81584770090BAFBEF3ABE6FD /* LiveIndicator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LiveIndicator.swift; sourceTree = "<group>"; };
8A5568E639232B59AEB84FD4 /* VideoShuffleTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoShuffleTests.swift; sourceTree = "<group>"; };
8B4E350321B6673B42545860 /* PlatformUI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlatformUI.swift; sourceTree = "<group>"; };
8D10F2D91602CE1FB6D7342E /* MultiStreamView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultiStreamView.swift; sourceTree = "<group>"; }; 8D10F2D91602CE1FB6D7342E /* MultiStreamView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MultiStreamView.swift; sourceTree = "<group>"; };
95C4DEDEAA64C74AF3DB24F2 /* mlbIOS.entitlements */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.entitlements; path = mlbIOS.entitlements; sourceTree = "<group>"; };
9BD2DEA6BBA55E86B2555099 /* GamesViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GamesViewModel.swift; sourceTree = "<group>"; }; 9BD2DEA6BBA55E86B2555099 /* GamesViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GamesViewModel.swift; sourceTree = "<group>"; };
A6FE9AFE34BB8CE4F33B62D0 /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
AC5BCB2E1DA0EC63E422B113 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; }; AC5BCB2E1DA0EC63E422B113 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
BBA78A938A07E07B064CEA54 /* PlatformUI.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PlatformUI.swift; sourceTree = "<group>"; }; B14F1577DCCDBEF7858E6BE5 /* SprayChartView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SprayChartView.swift; sourceTree = "<group>"; };
B4B8E51B59B366184CDCCED5 /* mlbIOSRootView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = mlbIOSRootView.swift; sourceTree = "<group>"; };
C1F593C51BC11444A3D514D3 /* mlbTVOS.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = mlbTVOS.entitlements; sourceTree = "<group>"; }; C1F593C51BC11444A3D514D3 /* mlbTVOS.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = mlbTVOS.entitlements; sourceTree = "<group>"; };
C2E99F69727800D0CB795503 /* TeamLogoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeamLogoView.swift; sourceTree = "<group>"; }; C2E99F69727800D0CB795503 /* TeamLogoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeamLogoView.swift; sourceTree = "<group>"; };
D4E5F6A7B8C9D0E1F2A3B4C5 /* PitcherHeadshotView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PitcherHeadshotView.swift; sourceTree = "<group>"; }; C3A003EA560655E4B2200DBE /* VideoShuffle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoShuffle.swift; sourceTree = "<group>"; };
E04B78C78BA9DA73B62AA3EE /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS14.0.sdk/System/Library/Frameworks/Foundation.framework; sourceTree = DEVELOPER_DIR; }; C3D1C29BF9DF2B8F811368BC /* AtBatTimelineView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AtBatTimelineView.swift; sourceTree = "<group>"; };
C6E303B753C604504C2762C7 /* PitcherHeadshotView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PitcherHeadshotView.swift; sourceTree = "<group>"; };
E21E3115B8A9B4C798ECE828 /* GameCardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameCardView.swift; sourceTree = "<group>"; }; E21E3115B8A9B4C798ECE828 /* GameCardView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameCardView.swift; sourceTree = "<group>"; };
E5F6A7B8C9D0E1F2A3B4C5D6 /* ScoreOverlayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScoreOverlayView.swift; sourceTree = "<group>"; }; E345FE2F997DC60C7A3E9158 /* StrikeZoneView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StrikeZoneView.swift; sourceTree = "<group>"; };
F0209C78036DA08FA794D8D9 /* mlbIOSApp.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = mlbIOSApp.swift; sourceTree = "<group>"; }; E462EB259532ADD83A9534B5 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
E80EF8B01452220FA6BCD9B2 /* GameCenterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameCenterView.swift; sourceTree = "<group>"; };
F5DA48863884BB6B4983BEE6 /* GameCenterViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameCenterViewModel.swift; sourceTree = "<group>"; };
FA4AF68CDF4FC8D07194B4D0 /* SingleStreamPlayerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SingleStreamPlayerView.swift; sourceTree = "<group>"; }; FA4AF68CDF4FC8D07194B4D0 /* SingleStreamPlayerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SingleStreamPlayerView.swift; sourceTree = "<group>"; };
/* End PBXFileReference section */ /* 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 */ /* Begin PBXGroup section */
0B56828FEC5E5902FE078143 /* iOS */ = {
isa = PBXGroup;
children = (
E04B78C78BA9DA73B62AA3EE /* Foundation.framework */,
);
name = iOS;
sourceTree = "<group>";
};
1646E84AFB7C540A2D228B64 /* mlbIOS */ = {
isa = PBXGroup;
children = (
96E8DA91A0CDD10D8953C8BA /* Views */,
A6FE9AFE34BB8CE4F33B62D0 /* Info.plist */,
95C4DEDEAA64C74AF3DB24F2 /* mlbIOS.entitlements */,
56358D0D5DC5CC531034287F /* Assets.xcassets */,
F0209C78036DA08FA794D8D9 /* mlbIOSApp.swift */,
);
name = mlbIOS;
path = mlbIOS;
sourceTree = "<group>";
};
2C171EA64FFA962254F20583 /* ViewModels */ = { 2C171EA64FFA962254F20583 /* ViewModels */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
2BB22BB22BB22BB22BB22BB3 /* GameCenterViewModel.swift */, F5DA48863884BB6B4983BEE6 /* GameCenterViewModel.swift */,
9BD2DEA6BBA55E86B2555099 /* GamesViewModel.swift */, 9BD2DEA6BBA55E86B2555099 /* GamesViewModel.swift */,
2BB22BB22BB22BB22BB22BB4 /* LeagueCenterViewModel.swift */, 7E9E572D932D96FE2C5BD7A4 /* LeagueCenterViewModel.swift */,
); );
path = ViewModels; path = ViewModels;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
2C7BE69056BC157DD7B0EA69 /* Views */ = {
isa = PBXGroup;
children = (
B4B8E51B59B366184CDCCED5 /* mlbIOSRootView.swift */,
);
path = Views;
sourceTree = "<group>";
};
4FAE5C2D72CD78BF7C15C4FC /* mlbTVOS */ = { 4FAE5C2D72CD78BF7C15C4FC /* mlbTVOS */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
@@ -163,61 +165,70 @@
path = mlbTVOS; path = mlbTVOS;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
5AE491A6B2C1767EFEF5CE88 /* Frameworks */ = {
isa = PBXGroup;
children = (
0B56828FEC5E5902FE078143 /* iOS */,
);
name = Frameworks;
sourceTree = "<group>";
};
62F95C3E8B0F6AFE3BEDE9C3 /* Components */ = { 62F95C3E8B0F6AFE3BEDE9C3 /* Components */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
C3D1C29BF9DF2B8F811368BC /* AtBatTimelineView.swift */,
327A33F2676712C2B5B4AF2F /* LinescoreView.swift */, 327A33F2676712C2B5B4AF2F /* LinescoreView.swift */,
81584770090BAFBEF3ABE6FD /* LiveIndicator.swift */, 81584770090BAFBEF3ABE6FD /* LiveIndicator.swift */,
D4E5F6A7B8C9D0E1F2A3B4C5 /* PitcherHeadshotView.swift */, C6E303B753C604504C2762C7 /* PitcherHeadshotView.swift */,
24C6D5F4A3B291807F6E5D4B /* ScoresTickerView.swift */, 7F13A88254E53AF5A4AAA2AA /* PitchSequenceView.swift */,
E5F6A7B8C9D0E1F2A3B4C5D6 /* ScoreOverlayView.swift */, 8B4E350321B6673B42545860 /* PlatformUI.swift */,
60B7F01667B2EBFBDFC1BBF6 /* ScoreOverlayView.swift */,
339D5F94415E8C018D384CDE /* ScoresTickerView.swift */,
B14F1577DCCDBEF7858E6BE5 /* SprayChartView.swift */,
E345FE2F997DC60C7A3E9158 /* StrikeZoneView.swift */,
C2E99F69727800D0CB795503 /* TeamLogoView.swift */, C2E99F69727800D0CB795503 /* TeamLogoView.swift */,
BBA78A938A07E07B064CEA54 /* PlatformUI.swift */,
); );
path = Components; path = Components;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
6CCF8450F0096AAE94F8E0EF /* mlbIOS */ = {
isa = PBXGroup;
children = (
7B361C2076C86F2EE841B2AB /* Assets.xcassets */,
E462EB259532ADD83A9534B5 /* Info.plist */,
166FABBD2D739048BB1A86A6 /* mlbIOS.entitlements */,
2CC4A28B180001C2A1B0BA5F /* mlbIOSApp.swift */,
2C7BE69056BC157DD7B0EA69 /* Views */,
);
path = mlbIOS;
sourceTree = "<group>";
};
6DA200592E30D8963373A30C /* mlbTVOSTests */ = {
isa = PBXGroup;
children = (
8A5568E639232B59AEB84FD4 /* VideoShuffleTests.swift */,
);
path = mlbTVOSTests;
sourceTree = "<group>";
};
86C1117ADD32A1A57AA32A08 = { 86C1117ADD32A1A57AA32A08 = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
6CCF8450F0096AAE94F8E0EF /* mlbIOS */,
4FAE5C2D72CD78BF7C15C4FC /* mlbTVOS */, 4FAE5C2D72CD78BF7C15C4FC /* mlbTVOS */,
6DA200592E30D8963373A30C /* mlbTVOSTests */,
88274023705E1F09B25F86FF /* Products */, 88274023705E1F09B25F86FF /* Products */,
1646E84AFB7C540A2D228B64 /* mlbIOS */,
5AE491A6B2C1767EFEF5CE88 /* Frameworks */,
); );
sourceTree = "<group>"; sourceTree = "<group>";
}; };
88274023705E1F09B25F86FF /* Products */ = { 88274023705E1F09B25F86FF /* Products */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
0FE1BD4A8517243AFABE371C /* mlbIOS.app */,
766441BC900073529EE93D69 /* mlbTVOS.app */, 766441BC900073529EE93D69 /* mlbTVOS.app */,
55A3DC0B5087BAD6BC59B35A /* mlbIOS.app */, 6FE64B2FA917EF98B16A3286 /* mlbTVOSTests.xctest */,
); );
name = Products; name = Products;
sourceTree = "<group>"; sourceTree = "<group>";
}; };
96E8DA91A0CDD10D8953C8BA /* Views */ = {
isa = PBXGroup;
children = (
3F874FDD20F82AFEB475CD73 /* mlbIOSRootView.swift */,
);
name = Views;
path = Views;
sourceTree = "<group>";
};
C6DC4CC5AAB9AF84D3CB9F3B /* Services */ = { C6DC4CC5AAB9AF84D3CB9F3B /* Services */ = {
isa = PBXGroup; isa = PBXGroup;
children = ( children = (
70E610D24BA83FB2F7490B00 /* MLBServerAPI.swift */, 70E610D24BA83FB2F7490B00 /* MLBServerAPI.swift */,
6067FE4F340FC0DCCFA62C67 /* MLBStatsAPI.swift */, 6067FE4F340FC0DCCFA62C67 /* MLBStatsAPI.swift */,
C3A003EA560655E4B2200DBE /* VideoShuffle.swift */,
); );
path = Services; path = Services;
sourceTree = "<group>"; sourceTree = "<group>";
@@ -229,9 +240,9 @@
5EA4C33F029665B80F1A6B6D /* DashboardView.swift */, 5EA4C33F029665B80F1A6B6D /* DashboardView.swift */,
102E5E8674E188A04637D3EB /* FeaturedGameCard.swift */, 102E5E8674E188A04637D3EB /* FeaturedGameCard.swift */,
E21E3115B8A9B4C798ECE828 /* GameCardView.swift */, E21E3115B8A9B4C798ECE828 /* GameCardView.swift */,
2BB22BB22BB22BB22BB22BB1 /* GameCenterView.swift */, E80EF8B01452220FA6BCD9B2 /* GameCenterView.swift */,
0B5D51D6E4990515411D3BCD /* GameListView.swift */, 0B5D51D6E4990515411D3BCD /* GameListView.swift */,
2BB22BB22BB22BB22BB22BB2 /* LeagueCenterView.swift */, 55E6F6797DA08FA657C04818 /* LeagueCenterView.swift */,
5152ABCC9AACFD91BAC8C95B /* MLBNetworkSheet.swift */, 5152ABCC9AACFD91BAC8C95B /* MLBNetworkSheet.swift */,
8D10F2D91602CE1FB6D7342E /* MultiStreamView.swift */, 8D10F2D91602CE1FB6D7342E /* MultiStreamView.swift */,
4DE9E708FA75ABB9F49D2392 /* SettingsView.swift */, 4DE9E708FA75ABB9F49D2392 /* SettingsView.swift */,
@@ -253,6 +264,42 @@
/* End PBXGroup section */ /* End PBXGroup section */
/* Begin PBXNativeTarget 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 */ = { 6ACA532AEDED1D5945D6153A /* mlbTVOS */ = {
isa = PBXNativeTarget; isa = PBXNativeTarget;
buildConfigurationList = 36D4353993A0A22378BC4770 /* Build configuration list for PBXNativeTarget "mlbTVOS" */; buildConfigurationList = 36D4353993A0A22378BC4770 /* Build configuration list for PBXNativeTarget "mlbTVOS" */;
@@ -265,27 +312,12 @@
dependencies = ( dependencies = (
); );
name = mlbTVOS; name = mlbTVOS;
packageProductDependencies = (
);
productName = mlbTVOS; productName = mlbTVOS;
productReference = 766441BC900073529EE93D69 /* mlbTVOS.app */; productReference = 766441BC900073529EE93D69 /* mlbTVOS.app */;
productType = "com.apple.product-type.application"; 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 */ /* End PBXNativeTarget section */
/* Begin PBXProject section */ /* Begin PBXProject section */
@@ -294,6 +326,14 @@
attributes = { attributes = {
BuildIndependentTargetsInParallel = YES; BuildIndependentTargetsInParallel = YES;
LastUpgradeCheck = 2630; LastUpgradeCheck = 2630;
TargetAttributes = {
3A9B7B05A37413FD435D907D = {
DevelopmentTeam = "";
};
3B0B3D1BD19FB0B2F845FE00 = {
DevelopmentTeam = "";
};
};
}; };
buildConfigurationList = B8EE3C08200DF28CFF279E15 /* Build configuration list for PBXProject "mlbTVOS" */; buildConfigurationList = B8EE3C08200DF28CFF279E15 /* Build configuration list for PBXProject "mlbTVOS" */;
compatibilityVersion = "Xcode 14.0"; compatibilityVersion = "Xcode 14.0";
@@ -305,25 +345,17 @@
); );
mainGroup = 86C1117ADD32A1A57AA32A08; mainGroup = 86C1117ADD32A1A57AA32A08;
minimizedProjectReferenceProxies = 1; minimizedProjectReferenceProxies = 1;
productRefGroup = 88274023705E1F09B25F86FF /* Products */;
projectDirPath = ""; projectDirPath = "";
projectRoot = ""; projectRoot = "";
targets = ( targets = (
3A9B7B05A37413FD435D907D /* mlbIOS */,
6ACA532AEDED1D5945D6153A /* mlbTVOS */, 6ACA532AEDED1D5945D6153A /* mlbTVOS */,
9C5FE08A60985D23E089CEE5 /* mlbIOS */, 3B0B3D1BD19FB0B2F845FE00 /* mlbTVOSTests */,
); );
}; };
/* End PBXProject section */ /* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */ /* Begin PBXResourcesBuildPhase section */
3CEE2E3AB4AAF6D8CC0F12D7 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
FE80BDA15CC8CE2F18DB4883 /* Assets.xcassets in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
5C7F99E1451B54AD3CC382CB /* Resources */ = { 5C7F99E1451B54AD3CC382CB /* Resources */ = {
isa = PBXResourcesBuildPhase; isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
@@ -332,40 +364,61 @@
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
AF3410DF4206295AA0FDC9F7 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
58B5982C6D8DC6F41EAC5CCD /* Assets.xcassets in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */ /* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */
9386CC819A80738E79744F6A /* Sources */ = { 161A48F0EE45E1D49E1ADD61 /* Sources */ = {
isa = PBXSourcesBuildPhase; isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
02BA6240A20E957B5337A545 /* Game.swift in Sources */, 26AE6D91B829B233CEDAE7DF /* AtBatTimelineView.swift in Sources */,
BB48CC4C732672536CD5EF96 /* TeamAssets.swift in Sources */, A70F5B83F079279CF2B41FCE /* ContentView.swift in Sources */,
11640185C392B0406BBE619D /* MLBServerAPI.swift in Sources */, 512148AC1C1F35F5CB688F98 /* DashboardView.swift in Sources */,
82DEB54FF3BFD51A18ED98A1 /* MLBStatsAPI.swift in Sources */, 08A22DE07ECE074D17D8A74E /* FeaturedGameCard.swift in Sources */,
C67442678865108796537BBC /* GameCenterViewModel.swift in Sources */, ACEB18A23D20F79AA757DD63 /* Game.swift in Sources */,
37BFF8231552B49E4775AA59 /* GamesViewModel.swift in Sources */, AAD1562E79743AA0B09CB857 /* GameCardView.swift in Sources */,
7D2D21DAE73958578FE2E730 /* LeagueCenterViewModel.swift in Sources */, E96459544473FC4A3CD92B4F /* GameCenterView.swift in Sources */,
BA1B3240360F79FE2DF42543 /* LinescoreView.swift in Sources */, B345E89FC7B8C263B067E082 /* GameCenterViewModel.swift in Sources */,
D199D622D64E3489E049B04A /* LiveIndicator.swift in Sources */, 50D870AF95561129D706C411 /* GameListView.swift in Sources */,
8D5FE07166FAD17836A0AFF4 /* PitcherHeadshotView.swift in Sources */, 65ABD3574B88BDDBB206B1DC /* GamesViewModel.swift in Sources */,
DA156E98ACD90B582F3BBAB2 /* PlatformUI.swift in Sources */, 8649D4F513A663D2493D91C9 /* LeagueCenterView.swift in Sources */,
1E5405E1C27A16971961091C /* ScoreOverlayView.swift in Sources */, E42B27D852AB037246167C23 /* LeagueCenterViewModel.swift in Sources */,
DB6E6A4890D0D22526DA5D96 /* ScoresTickerView.swift in Sources */, 4CB947BC5B5ECF83EF84F7E4 /* LinescoreView.swift in Sources */,
B215941F5A269C59C0939958 /* TeamLogoView.swift in Sources */, 0653C2F5CC55F4234E8950E5 /* LiveIndicator.swift in Sources */,
1555D223058B858E4C6F419D /* ContentView.swift in Sources */, D864A2844FD222A04B48F6EF /* MLBNetworkSheet.swift in Sources */,
F60F24246B08223DC2BA5825 /* DashboardView.swift in Sources */, 8801888DFD48351B1D344D45 /* MLBServerAPI.swift in Sources */,
AFA76F6EBE7977914A849447 /* FeaturedGameCard.swift in Sources */, 5EBF83734395FCF7BCE1E26B /* MLBStatsAPI.swift in Sources */,
B921B07F43A13CBBDE9C499D /* GameCardView.swift in Sources */, 6CD8E1E4B6A4C5D0F0F9299E /* MultiStreamView.swift in Sources */,
3FB9BCB07B6DBFE6D1D2C523 /* GameCenterView.swift in Sources */, 36E4F79F23EF867A100E924D /* PitchSequenceView.swift in Sources */,
A506FDC86C3A78F4A2C04C10 /* GameListView.swift in Sources */, 8E2F3FAAE778FE6F44C0B5D5 /* PitcherHeadshotView.swift in Sources */,
4318098424F9B3FF3B256EED /* LeagueCenterView.swift in Sources */, 6E502A815BB3C150E56E7E38 /* PlatformUI.swift in Sources */,
F761076F826FEB86E972D516 /* MLBNetworkSheet.swift in Sources */, 49B79787C58CA43F2C1F9A9C /* ScoreOverlayView.swift in Sources */,
D48041F6CFBCEB2F3492747F /* MultiStreamView.swift in Sources */, 96250318C3F0491D095DDAA9 /* ScoresTickerView.swift in Sources */,
31353216A6288D986544F61E /* SettingsView.swift in Sources */, 2B52AC228AE12CC671348964 /* SettingsView.swift in Sources */,
CA20CF293D75E6058E0CB78C /* SingleStreamPlayerView.swift in Sources */, D688F6B0680C29AECD289CA9 /* SingleStreamPlayerView.swift in Sources */,
43AC37B4F957CDC3986F044A /* mlbIOSApp.swift in Sources */, 95046231282AA1B67F4B5A8D /* SprayChartView.swift in Sources */,
43B16E04582A874E87514C30 /* mlbIOSRootView.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; runOnlyForDeploymentPostprocessing = 0;
}; };
@@ -373,37 +426,50 @@
isa = PBXSourcesBuildPhase; isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647; buildActionMask = 2147483647;
files = ( files = (
769DF65BBCC400691B98C71F /* AtBatTimelineView.swift in Sources */,
4F67D44E9A36BED9CD8724CF /* ContentView.swift in Sources */, 4F67D44E9A36BED9CD8724CF /* ContentView.swift in Sources */,
F36A71E407BC707B8E03457D /* DashboardView.swift in Sources */, F36A71E407BC707B8E03457D /* DashboardView.swift in Sources */,
FAFA9B29273C4D8A54CD5916 /* FeaturedGameCard.swift in Sources */, FAFA9B29273C4D8A54CD5916 /* FeaturedGameCard.swift in Sources */,
29627CA5EE85BA103B38D1F5 /* Game.swift in Sources */, 29627CA5EE85BA103B38D1F5 /* Game.swift in Sources */,
63AF55498896C9BBCD1D4337 /* GameCardView.swift in Sources */, 63AF55498896C9BBCD1D4337 /* GameCardView.swift in Sources */,
1AA11AA11AA11AA11AA11AA1 /* GameCenterView.swift in Sources */, 3679F9F4949C8B3B8D5D6E94 /* GameCenterView.swift in Sources */,
1AA11AA11AA11AA11AA11AA3 /* GameCenterViewModel.swift in Sources */, 9525FA984562C90A677AD453 /* GameCenterViewModel.swift in Sources */,
52498A59FF923A1494A0139B /* GameListView.swift in Sources */, 52498A59FF923A1494A0139B /* GameListView.swift in Sources */,
9D29D3508CD05CB3F4975910 /* GamesViewModel.swift in Sources */, 9D29D3508CD05CB3F4975910 /* GamesViewModel.swift in Sources */,
1AA11AA11AA11AA11AA11AA2 /* LeagueCenterView.swift in Sources */, 07BBD0E7B7F122213708A406 /* LeagueCenterView.swift in Sources */,
1AA11AA11AA11AA11AA11AA4 /* LeagueCenterViewModel.swift in Sources */, B0A9FFD5C20A80E16532CC91 /* LeagueCenterViewModel.swift in Sources */,
051C3D14A06061D44E325FCC /* LinescoreView.swift in Sources */, 051C3D14A06061D44E325FCC /* LinescoreView.swift in Sources */,
E7FB366456557069990F550C /* LiveIndicator.swift in Sources */, E7FB366456557069990F550C /* LiveIndicator.swift in Sources */,
820FB03D0E97C521BA239071 /* MLBNetworkSheet.swift in Sources */, 820FB03D0E97C521BA239071 /* MLBNetworkSheet.swift in Sources */,
1E31C6F500CEEA5284081D22 /* MLBServerAPI.swift in Sources */, 1E31C6F500CEEA5284081D22 /* MLBServerAPI.swift in Sources */,
DC37555DE8C367E929974484 /* MLBStatsAPI.swift in Sources */, DC37555DE8C367E929974484 /* MLBStatsAPI.swift in Sources */,
0B1C43E83CA856543F58E16A /* MultiStreamView.swift in Sources */, 0B1C43E83CA856543F58E16A /* MultiStreamView.swift in Sources */,
A1B2C3D4E5F60718091A2B3C /* PitcherHeadshotView.swift in Sources */, F096F11642B32BF240C367A5 /* PitchSequenceView.swift in Sources */,
24C6D5F4A3B291807F6E5D4C /* ScoresTickerView.swift in Sources */, A7195DD696E4C07218D74F19 /* PitcherHeadshotView.swift in Sources */,
B2C3D4E5F6A70819A1B2C3D4 /* ScoreOverlayView.swift in Sources */, 468EB7F6EC98D83099E2C352 /* PlatformUI.swift in Sources */,
5F5AF9B045B8F2BA87CBECD3 /* ScoreOverlayView.swift in Sources */,
4FB2E354854C1408419634BB /* ScoresTickerView.swift in Sources */,
FD9322BED3A2B827CB021163 /* SettingsView.swift in Sources */, FD9322BED3A2B827CB021163 /* SettingsView.swift in Sources */,
5DFD3805A76DDE49B0DC1E2D /* SingleStreamPlayerView.swift in Sources */, 5DFD3805A76DDE49B0DC1E2D /* SingleStreamPlayerView.swift in Sources */,
49FB2101E46F2DAD10BC6201 /* SprayChartView.swift in Sources */,
470E17740A5C54C6BF2C1C97 /* StrikeZoneView.swift in Sources */,
342EED600E1A5B4A3D083E50 /* TeamAssets.swift in Sources */, 342EED600E1A5B4A3D083E50 /* TeamAssets.swift in Sources */,
80A690C52C9C8140E7C519E8 /* TeamLogoView.swift in Sources */, 80A690C52C9C8140E7C519E8 /* TeamLogoView.swift in Sources */,
2A6155FFEE85643ABDE4111A /* VideoShuffle.swift in Sources */,
FD31074216AC0115B3F1C057 /* mlbTVOSApp.swift in Sources */, FD31074216AC0115B3F1C057 /* mlbTVOSApp.swift in Sources */,
5A76A7BB97FFA089156A0ABA /* PlatformUI.swift in Sources */,
); );
runOnlyForDeploymentPostprocessing = 0; runOnlyForDeploymentPostprocessing = 0;
}; };
/* End PBXSourcesBuildPhase section */ /* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
540BB52ABCD2F688E02B1982 /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
target = 6ACA532AEDED1D5945D6153A /* mlbTVOS */;
targetProxy = BB037F390E873F3105CC93DC /* PBXContainerItemProxy */;
};
/* End PBXTargetDependency section */
/* Begin XCBuildConfiguration section */ /* Begin XCBuildConfiguration section */
0AEA011CF6D6485B876BC988 /* Debug */ = { 0AEA011CF6D6485B876BC988 /* Debug */ = {
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
@@ -457,11 +523,11 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES; GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES; MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES; ONLY_ACTIVE_ARCH = YES;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = appletvos;
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_OPTIMIZATION_LEVEL = "-Onone";
SWIFT_VERSION = 6.0; SWIFT_VERSION = 6.0;
@@ -469,53 +535,6 @@
}; };
name = Debug; 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 */ = { 1FE0846F10814B35388166A8 /* Debug */ = {
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
@@ -581,10 +600,10 @@
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES; GCC_WARN_UNUSED_VARIABLE = YES;
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
MTL_ENABLE_DEBUG_INFO = NO; MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES; MTL_FAST_MATH = YES;
PRODUCT_NAME = "$(TARGET_NAME)"; PRODUCT_NAME = "$(TARGET_NAME)";
SDKROOT = appletvos;
SWIFT_COMPILATION_MODE = wholemodule; SWIFT_COMPILATION_MODE = wholemodule;
SWIFT_OPTIMIZATION_LEVEL = "-O"; SWIFT_OPTIMIZATION_LEVEL = "-O";
SWIFT_VERSION = 6.0; SWIFT_VERSION = 6.0;
@@ -592,6 +611,78 @@
}; };
name = Release; 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 */ = { F266D26959D831BC0C0C782C /* Release */ = {
isa = XCBuildConfiguration; isa = XCBuildConfiguration;
buildSettings = { buildSettings = {
@@ -623,14 +714,14 @@
defaultConfigurationIsVisible = 0; defaultConfigurationIsVisible = 0;
defaultConfigurationName = Debug; defaultConfigurationName = Debug;
}; };
67C1F966BDD84456877DC373 /* Build configuration list for PBXNativeTarget "mlbIOS" */ = { 7E61CD36E6F461F6012C8AEC /* Build configuration list for PBXNativeTarget "mlbIOS" */ = {
isa = XCConfigurationList; isa = XCConfigurationList;
buildConfigurations = ( buildConfigurations = (
1FC82661BC31026C62934BD7 /* Release */, 75DEC3365E4FE5397DBA6D23 /* Debug */,
0D8A815004F8CC601240D2DB /* Debug */, D7E3D2E659BC9B3A584585FE /* Release */,
); );
defaultConfigurationIsVisible = 0; defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release; defaultConfigurationName = Debug;
}; };
B8EE3C08200DF28CFF279E15 /* Build configuration list for PBXProject "mlbTVOS" */ = { B8EE3C08200DF28CFF279E15 /* Build configuration list for PBXProject "mlbTVOS" */ = {
isa = XCConfigurationList; isa = XCConfigurationList;
@@ -641,6 +732,15 @@
defaultConfigurationIsVisible = 0; defaultConfigurationIsVisible = 0;
defaultConfigurationName = Debug; defaultConfigurationName = Debug;
}; };
E19144DDC167AFC338BA9481 /* Build configuration list for PBXNativeTarget "mlbTVOSTests" */ = {
isa = XCConfigurationList;
buildConfigurations = (
766EF2A8A8F5ABA868469554 /* Debug */,
91415A3F240ADBD891F74114 /* Release */,
);
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Debug;
};
/* End XCConfigurationList section */ /* End XCConfigurationList section */
}; };
rootObject = 89437C3F6A1DA0DA4ADEA3AB /* Project object */; rootObject = 89437C3F6A1DA0DA4ADEA3AB /* Project object */;

View File

@@ -42,6 +42,10 @@ actor MLBStatsAPI {
try await fetchJSON("\(baseURL).1/game/\(gamePk)/feed/live") 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] { func fetchLeagueTeams(season: String) async throws -> [LeagueTeamSummary] {
let response: TeamsResponse = try await fetchJSON( let response: TeamsResponse = try await fetchJSON(
"\(baseURL)/teams?sportId=1&season=\(season)&hydrate=league,division,venue,record" "\(baseURL)/teams?sportId=1&season=\(season)&hydrate=league,division,venue,record"
@@ -828,6 +832,19 @@ struct LiveGameFeed: Codable, Sendable {
var homeLineup: [LiveFeedBoxscorePlayer] { var homeLineup: [LiveFeedBoxscorePlayer] {
liveData.boxscore?.teams?.home.lineupPlayers ?? [] 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 { struct LiveFeedMetaData: Codable, Sendable {
@@ -890,7 +907,7 @@ struct LiveFeedVenue: Codable, Sendable {
struct LiveFeedWeather: Codable, Sendable { struct LiveFeedWeather: Codable, Sendable {
let condition: String? let condition: String?
let temp: Int? let temp: String?
let wind: String? let wind: String?
} }
@@ -911,6 +928,11 @@ struct LiveFeedPlay: Codable, Sendable, Identifiable {
let about: LiveFeedPlayAbout? let about: LiveFeedPlayAbout?
let count: LiveFeedPlayCount? let count: LiveFeedPlayCount?
let matchup: LiveFeedPlayMatchup? let matchup: LiveFeedPlayMatchup?
let playEvents: [LiveFeedPlayEvent]?
var pitches: [LiveFeedPlayEvent] {
playEvents?.filter { $0.isPitch == true } ?? []
}
var id: String { var id: String {
let inning = about?.inning ?? -1 let inning = about?.inning ?? -1
@@ -958,6 +980,11 @@ struct LiveFeedPlayerReference: Codable, Sendable, Identifiable {
var displayName: String { var displayName: String {
fullName ?? "TBD" 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 { struct LiveFeedLinescore: Codable, Sendable {
@@ -1054,3 +1081,87 @@ struct LiveFeedDecisions: Codable, Sendable {
let loser: LiveFeedPlayerReference? let loser: LiveFeedPlayerReference?
let save: 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?
}

View File

@@ -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<T>(
_ 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<T>(
_ 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<T>(
_ buckets: [String: [T]],
excludingKey: String? = nil
) -> (item: T, key: String, remaining: [String: [T]])? {
var rng = SystemRandomNumberGenerator()
return pickRandomFromBuckets(buckets, excludingKey: excludingKey, using: &rng)
}
}

View File

@@ -1,24 +1,41 @@
import Foundation import Foundation
import Observation 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 @Observable
@MainActor @MainActor
final class GameCenterViewModel { final class GameCenterViewModel {
var feed: LiveGameFeed? var feed: LiveGameFeed?
var highlights: [Highlight] = []
var winProbabilityHome: Double?
var winProbabilityAway: Double?
var isLoading = false var isLoading = false
var errorMessage: String? var errorMessage: String?
var lastUpdated: Date? var lastUpdated: Date?
private let statsAPI = MLBStatsAPI() private let statsAPI = MLBStatsAPI()
@ObservationIgnored
private var lastHighlightsFetch: Date?
func watch(game: Game) async { func watch(game: Game) async {
guard let gamePk = game.gamePk else { 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." errorMessage = "No live game feed is available for this matchup."
return return
} }
logGameCenter("watch: starting for gamePk=\(gamePk)")
while !Task.isCancelled { while !Task.isCancelled {
await refresh(gamePk: gamePk) await refresh(gamePk: gamePk)
await refreshHighlightsIfNeeded(gamePk: gamePk, gameDate: game.gameDate)
await refreshWinProbability(gamePk: gamePk)
let liveState = feed?.gameData.status?.abstractGameState == "Live" let liveState = feed?.gameData.status?.abstractGameState == "Live"
if !liveState { if !liveState {
@@ -34,12 +51,44 @@ final class GameCenterViewModel {
errorMessage = nil errorMessage = nil
do { do {
logGameCenter("refresh: fetching feed for gamePk=\(gamePk)")
feed = try await statsAPI.fetchGameFeed(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() lastUpdated = Date()
} catch { } catch {
logGameCenter("refresh: FAILED gamePk=\(gamePk) error=\(error)")
errorMessage = "Failed to load game center." errorMessage = "Failed to load game center."
} }
isLoading = false 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
}
}
} }

View File

@@ -55,7 +55,7 @@ final class GamesViewModel {
@ObservationIgnored @ObservationIgnored
private var authenticatedVideoFeedCache: [String: AuthenticatedVideoFeedCacheEntry] = [:] private var authenticatedVideoFeedCache: [String: AuthenticatedVideoFeedCacheEntry] = [:]
@ObservationIgnored @ObservationIgnored
private var videoShuffleBags: [String: [URL]] = [:] private var videoShuffleBagsByModel: [String: [String: [URL]]] = [:]
@ObservationIgnored @ObservationIgnored
private var cachedStandings: (date: String, standings: [Int: TeamStanding])? private var cachedStandings: (date: String, standings: [Int: TeamStanding])?
@@ -536,12 +536,15 @@ final class GamesViewModel {
func attachPlayer(_ player: AVPlayer, to streamID: String) { func attachPlayer(_ player: AVPlayer, to streamID: String) {
guard let index = activeStreams.firstIndex(where: { $0.id == streamID }) else { return } guard let index = activeStreams.firstIndex(where: { $0.id == streamID }) else { return }
let alreadyAttached = activeStreams[index].player === player
activeStreams[index].player = player activeStreams[index].player = player
activeStreams[index].isPlaying = true activeStreams[index].isPlaying = true
if !alreadyAttached {
let shouldMute = shouldMuteAudio(for: activeStreams[index]) let shouldMute = shouldMuteAudio(for: activeStreams[index])
activeStreams[index].isMuted = shouldMute activeStreams[index].isMuted = shouldMute
player.isMuted = shouldMute player.isMuted = shouldMute
} }
}
func updateStreamOverrideSource(id: String, url: URL, headers: [String: String] = [:]) { func updateStreamOverrideSource(id: String, url: URL, headers: [String: String] = [:]) {
guard let index = activeStreams.firstIndex(where: { $0.id == id }) else { return } guard let index = activeStreams.firstIndex(where: { $0.id == id }) else { return }
@@ -614,30 +617,79 @@ final class GamesViewModel {
excluding excludedURL: URL? = nil excluding excludedURL: URL? = nil
) async -> URL? { ) async -> URL? {
guard let urls = await fetchAuthenticatedVideoFeedURLs(feedURL: feedURL, headers: headers) else { guard let urls = await fetchAuthenticatedVideoFeedURLs(feedURL: feedURL, headers: headers) else {
logGamesViewModel("resolveAuthenticatedVideoFeedURL FAILED reason=fetchReturned-nil")
return nil return nil
} }
let cacheKey = authenticatedVideoFeedCacheKey(feedURL: feedURL, headers: headers) let cacheKey = authenticatedVideoFeedCacheKey(feedURL: feedURL, headers: headers)
var bag = videoShuffleBags[cacheKey] ?? [] var buckets = videoShuffleBagsByModel[cacheKey] ?? [:]
// Refill the bag when empty (or first time), shuffled // Refill ANY model bucket that is empty this keeps all models in
if bag.isEmpty { // rotation so small models cycle through their videos while large
bag = urls.shuffled() // models continue drawing from unplayed ones.
logGamesViewModel("resolveAuthenticatedVideoFeedURL reshuffled bag count=\(bag.count)") var refilledKeys: [String] = []
} if buckets.isEmpty {
// First access: build every bucket from scratch.
// If the next video is the one we just played, push it to the back var rng = SystemRandomNumberGenerator()
if let excludedURL, bag.count > 1, bag.first == excludedURL { buckets = VideoShuffle.groupByModel(
bag.append(bag.removeFirst()) urls,
} keyFor: Self.modelKey(from:),
using: &rng
let selectedURL = bag.removeFirst()
videoShuffleBags[cacheKey] = bag
logGamesViewModel(
"resolveAuthenticatedVideoFeedURL success resolvedURL=\(gamesViewModelDebugURLDescription(selectedURL)) remaining=\(bag.count) excludedURL=\(excludedURL.map(gamesViewModelDebugURLDescription) ?? "nil")"
) )
return selectedURL 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)]"
)
}
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
}
videoShuffleBagsByModel[cacheKey] = pick.remaining
let remainingTotal = pick.remaining.values.map(\.count).reduce(0, +)
logGamesViewModel(
"resolveAuthenticatedVideoFeedURL pop model=\(pick.key) remainingTotal=\(remainingTotal) excludedModel=\(excludedModel ?? "nil")"
)
return pick.item
}
/// Extracts the model/folder identifier from an authenticated feed URL.
/// Expected path shapes:
/// /api/hls/<model>/<file>/master.m3u8
/// /data/media/<model>/<file>.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( private func fetchAuthenticatedVideoFeedURLs(

View File

@@ -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
}
}

View File

@@ -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())
}
}

View File

@@ -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<Double> = 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)
}
}

View File

@@ -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)
}
}

View File

@@ -12,8 +12,8 @@ enum SpecialPlaybackChannelConfig {
static let werkoutNSFWStreamID = "WKNSFW" static let werkoutNSFWStreamID = "WKNSFW"
static let werkoutNSFWTitle = "Werkout NSFW" static let werkoutNSFWTitle = "Werkout NSFW"
static let werkoutNSFWSubtitle = "Authenticated OF media feed" static let werkoutNSFWSubtitle = "Authenticated OF media feed"
static let werkoutNSFWFeedURLString = "https://ofapp.treytartt.com/api/media?users=tabatachang,werkout&type=video" 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.eyJ1c2VySWQiOjEsImlhdCI6MTc3NDY0MjEyNiwiZXhwIjoxNzc1MjQ2OTI2fQ.rDvZzLQ70MM9drHwA8hRxmqcTTgBGBHXxv1Cc55HSqc" static let werkoutNSFWCookie = "ofapp_token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjEsImlhdCI6MTc3NDY0MjEyNiwiZXhwIjoxNzc3MjM0MTI2fQ.RdYGvyBPbR6tmK0kaEVhSnIVW6TZlm3Ef42Lxpvl_oQ"
static let werkoutNSFWTeamCode = "WK" static let werkoutNSFWTeamCode = "WK"
static var werkoutNSFWHeaders: [String: String] { static var werkoutNSFWHeaders: [String: String] {
@@ -56,6 +56,7 @@ struct DashboardView: View {
@State private var pendingFullScreenBroadcast: BroadcastSelection? @State private var pendingFullScreenBroadcast: BroadcastSelection?
@State private var showMLBNetworkSheet = false @State private var showMLBNetworkSheet = false
@State private var showWerkoutNSFWSheet = false @State private var showWerkoutNSFWSheet = false
@State private var isPiPActive = false
private var horizontalPadding: CGFloat { private var horizontalPadding: CGFloat {
#if os(iOS) #if os(iOS)
@@ -218,7 +219,11 @@ struct DashboardView: View {
await resolveFullScreenSource(for: selection) await resolveFullScreenSource(for: selection)
}, },
resolveNextSource: nextFullScreenSourceResolver(for: selection), resolveNextSource: nextFullScreenSourceResolver(for: selection),
tickerGames: tickerGames(for: selection) tickerGames: tickerGames(for: selection),
game: selection.game,
onPiPActiveChanged: { active in
isPiPActive = active
}
) )
.ignoresSafeArea() .ignoresSafeArea()
.onAppear { .onAppear {

View File

@@ -1,9 +1,11 @@
import SwiftUI import SwiftUI
import AVKit
struct GameCenterView: View { struct GameCenterView: View {
let game: Game let game: Game
@State private var viewModel = GameCenterViewModel() @State private var viewModel = GameCenterViewModel()
@State private var highlightPlayer: AVPlayer?
var body: some View { var body: some View {
VStack(alignment: .leading, spacing: 18) { VStack(alignment: .leading, spacing: 18) {
@@ -15,6 +17,14 @@ struct GameCenterView: View {
situationStrip(feed: feed) situationStrip(feed: feed)
matchupPanel(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 { if !feed.decisionsSummary.isEmpty || !feed.officialSummary.isEmpty || feed.weatherSummary != nil {
contextPanel(feed: feed) contextPanel(feed: feed)
} }
@@ -23,6 +33,14 @@ struct GameCenterView: View {
lineupPanel(feed: feed) lineupPanel(feed: feed)
} }
if !viewModel.highlights.isEmpty {
highlightsPanel
}
if !feed.allGameHits.isEmpty {
sprayChartPanel(feed: feed)
}
if !feed.scoringPlays.isEmpty { if !feed.scoringPlays.isEmpty {
timelineSection( timelineSection(
title: "Scoring Plays", title: "Scoring Plays",
@@ -43,6 +61,16 @@ struct GameCenterView: View {
.task(id: game.id) { .task(id: game.id) {
await viewModel.watch(game: game) 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 { private var header: some View {
@@ -126,6 +154,13 @@ struct GameCenterView: View {
private func matchupPanel(feed: LiveGameFeed) -> some View { private func matchupPanel(feed: LiveGameFeed) -> some View {
VStack(alignment: .leading, spacing: 16) { VStack(alignment: .leading, spacing: 16) {
HStack(alignment: .top, spacing: 18) { HStack(alignment: .top, spacing: 18) {
HStack(spacing: 14) {
PitcherHeadshotView(
url: feed.currentBatter?.headshotURL,
teamCode: game.status.isLive ? nil : game.awayTeam.code,
size: 46
)
VStack(alignment: .leading, spacing: 5) { VStack(alignment: .leading, spacing: 5) {
Text("At Bat") Text("At Bat")
.font(.system(size: 13, weight: .bold, design: .rounded)) .font(.system(size: 13, weight: .bold, design: .rounded))
@@ -147,9 +182,11 @@ struct GameCenterView: View {
.foregroundStyle(.white.opacity(0.46)) .foregroundStyle(.white.opacity(0.46))
} }
} }
}
Spacer() Spacer()
HStack(spacing: 14) {
VStack(alignment: .trailing, spacing: 5) { VStack(alignment: .trailing, spacing: 5) {
Text("Pitching") Text("Pitching")
.font(.system(size: 13, weight: .bold, design: .rounded)) .font(.system(size: 13, weight: .bold, design: .rounded))
@@ -168,6 +205,180 @@ struct GameCenterView: View {
.multilineTextAlignment(.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()
}
}
}
}
.padding(22)
.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) .padding(22)
@@ -256,6 +467,11 @@ struct GameCenterView: View {
.foregroundStyle(.white.opacity(0.6)) .foregroundStyle(.white.opacity(0.6))
.frame(width: 18, alignment: .leading) .frame(width: 18, alignment: .leading)
PitcherHeadshotView(
url: player.person.headshotURL,
size: 28
)
Text(player.person.displayName) Text(player.person.displayName)
.font(.system(size: 15, weight: .semibold)) .font(.system(size: 15, weight: .semibold))
.foregroundStyle(.white.opacity(0.88)) .foregroundStyle(.white.opacity(0.88))

View File

@@ -77,6 +77,8 @@ struct StreamOptionsSheet: View {
.padding(.vertical, 18) .padding(.vertical, 18)
.background(panelBackground) .background(panelBackground)
} }
GameCenterView(game: game)
} }
.padding(.horizontal, horizontalPadding) .padding(.horizontal, horizontalPadding)
.padding(.vertical, verticalPadding) .padding(.vertical, verticalPadding)
@@ -129,8 +131,6 @@ struct StreamOptionsSheet: View {
.padding(22) .padding(22)
.background(panelBackground) .background(panelBackground)
} }
GameCenterView(game: game)
} }
} }

View File

@@ -353,8 +353,13 @@ private struct MultiStreamTile: View {
@State private var hasError = false @State private var hasError = false
@State private var startupPlaybackTask: Task<Void, Never>? @State private var startupPlaybackTask: Task<Void, Never>?
@State private var qualityUpgradeTask: Task<Void, Never>? @State private var qualityUpgradeTask: Task<Void, Never>?
@State private var clipTimeLimitObserver: Any?
@State private var isAdvancingClip = false
@StateObject private var playbackDiagnostics = MultiStreamPlaybackDiagnostics() @StateObject private var playbackDiagnostics = MultiStreamPlaybackDiagnostics()
private static let maxClipDuration: Double = 15.0
private static var audioSessionConfigured = false
var body: some View { var body: some View {
ZStack { ZStack {
videoLayer videoLayer
@@ -442,6 +447,7 @@ private struct MultiStreamTile: View {
startupPlaybackTask = nil startupPlaybackTask = nil
qualityUpgradeTask?.cancel() qualityUpgradeTask?.cancel()
qualityUpgradeTask = nil qualityUpgradeTask = nil
if let player { removeClipTimeLimit(from: player) }
playbackDiagnostics.clear(streamID: stream.id, reason: "tile disappeared") playbackDiagnostics.clear(streamID: stream.id, reason: "tile disappeared")
} }
#if os(tvOS) #if os(tvOS)
@@ -536,7 +542,6 @@ private struct MultiStreamTile: View {
) )
if let player { if let player {
player.isMuted = stream.forceMuteAudio || viewModel.audioFocusStreamID != stream.id
playbackDiagnostics.attach( playbackDiagnostics.attach(
to: player, to: player,
streamID: stream.id, streamID: stream.id,
@@ -545,20 +550,23 @@ private struct MultiStreamTile: View {
) )
scheduleStartupPlaybackRecovery(for: player) scheduleStartupPlaybackRecovery(for: player)
scheduleQualityUpgrade(for: player) scheduleQualityUpgrade(for: player)
installClipTimeLimit(on: player)
logMultiView("startStream reused inline player id=\(stream.id) muted=\(player.isMuted)") logMultiView("startStream reused inline player id=\(stream.id) muted=\(player.isMuted)")
return return
} }
if !Self.audioSessionConfigured {
do { do {
try AVAudioSession.sharedInstance().setCategory(.playback) try AVAudioSession.sharedInstance().setCategory(.playback)
try AVAudioSession.sharedInstance().setActive(true) try AVAudioSession.sharedInstance().setActive(true)
Self.audioSessionConfigured = true
logMultiView("startStream audio session configured id=\(stream.id)") logMultiView("startStream audio session configured id=\(stream.id)")
} catch { } catch {
logMultiView("startStream audio session failed id=\(stream.id) error=\(error.localizedDescription)") logMultiView("startStream audio session failed id=\(stream.id) error=\(error.localizedDescription)")
} }
}
if let existingPlayer = stream.player { if let existingPlayer = stream.player {
existingPlayer.isMuted = stream.forceMuteAudio || viewModel.audioFocusStreamID != stream.id
self.player = existingPlayer self.player = existingPlayer
hasError = false hasError = false
playbackDiagnostics.attach( playbackDiagnostics.attach(
@@ -569,6 +577,7 @@ private struct MultiStreamTile: View {
) )
scheduleStartupPlaybackRecovery(for: existingPlayer) scheduleStartupPlaybackRecovery(for: existingPlayer)
scheduleQualityUpgrade(for: existingPlayer) scheduleQualityUpgrade(for: existingPlayer)
installClipTimeLimit(on: existingPlayer)
logMultiView("startStream reused shared player id=\(stream.id) muted=\(existingPlayer.isMuted)") logMultiView("startStream reused shared player id=\(stream.id) muted=\(existingPlayer.isMuted)")
return return
} }
@@ -606,6 +615,7 @@ private struct MultiStreamTile: View {
scheduleQualityUpgrade(for: avPlayer) scheduleQualityUpgrade(for: avPlayer)
logMultiView("startStream attached player id=\(stream.id) muted=\(avPlayer.isMuted) startupResolution=\(multiViewStartupResolution) fastStart=true calling playImmediately(atRate: 1.0)") logMultiView("startStream attached player id=\(stream.id) muted=\(avPlayer.isMuted) startupResolution=\(multiViewStartupResolution) fastStart=true calling playImmediately(atRate: 1.0)")
avPlayer.playImmediately(atRate: 1.0) avPlayer.playImmediately(atRate: 1.0)
installClipTimeLimit(on: avPlayer)
} }
private func makePlayer(url: URL, headers: [String: String]?) -> AVPlayer { private func makePlayer(url: URL, headers: [String: String]?) -> AVPlayer {
@@ -754,28 +764,73 @@ private struct MultiStreamTile: View {
.value .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)? { private func playbackEndedHandler(for player: AVPlayer) -> (@MainActor @Sendable () async -> Void)? {
guard stream.id == SpecialPlaybackChannelConfig.werkoutNSFWStreamID else { return nil } guard stream.id == SpecialPlaybackChannelConfig.werkoutNSFWStreamID else { return nil }
return { 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) await playNextWerkoutClip(on: player)
} }
} }
private func playNextWerkoutClip(on player: AVPlayer) async { 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 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( 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( guard let nextURL = await viewModel.resolveNextAuthenticatedFeedURLForActiveStream(
id: stream.id, id: stream.id,
feedURL: SpecialPlaybackChannelConfig.werkoutNSFWFeedURL, feedURL: SpecialPlaybackChannelConfig.werkoutNSFWFeedURL,
headers: SpecialPlaybackChannelConfig.werkoutNSFWHeaders, headers: SpecialPlaybackChannelConfig.werkoutNSFWHeaders,
maxRetries: 3 maxRetries: 3
) else { ) 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 return
} }
let resolveMs = Int(Date().timeIntervalSince(resolveStart) * 1000)
logMultiView("playNextWerkoutClip resolved id=\(stream.id) resolveMs=\(resolveMs) nextURL=\(nextURL.lastPathComponent)")
let nextItem = makePlayerItem( let nextItem = makePlayerItem(
url: nextURL, url: nextURL,
@@ -790,10 +845,27 @@ private struct MultiStreamTile: View {
label: stream.label, label: stream.label,
onPlaybackEnded: playbackEndedHandler(for: player) onPlaybackEnded: playbackEndedHandler(for: player)
) )
viewModel.attachPlayer(player, to: stream.id)
scheduleStartupPlaybackRecovery(for: player) 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) 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
}
}
}
} }
} }

View File

@@ -80,28 +80,104 @@ struct SingleStreamPlaybackScreen: View {
let resolveSource: @Sendable () async -> SingleStreamPlaybackSource? let resolveSource: @Sendable () async -> SingleStreamPlaybackSource?
var resolveNextSource: (@Sendable (URL?) async -> SingleStreamPlaybackSource?)? = nil var resolveNextSource: (@Sendable (URL?) async -> SingleStreamPlaybackSource?)? = nil
let tickerGames: [Game] 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 { var body: some View {
ZStack(alignment: .bottom) { ZStack(alignment: .bottom) {
SingleStreamPlayerView(resolveSource: resolveSource, resolveNextSource: resolveNextSource) 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() .ignoresSafeArea()
if !showGameCenter && !showPitchInfo {
SingleStreamScoreStripView(games: tickerGames) SingleStreamScoreStripView(games: tickerGames)
.allowsHitTesting(false) .allowsHitTesting(false)
.padding(.horizontal, 18) .padding(.horizontal, 18)
.padding(.bottom, 14) .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))
}
} }
.overlay(alignment: .topTrailing) {
#if os(iOS) #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 { Button {
dismiss() dismiss()
} label: { } label: {
Image(systemName: "xmark.circle.fill") Image(systemName: "xmark.circle.fill")
.font(.system(size: 28, weight: .bold)) .font(.system(size: 28, weight: .bold))
.foregroundStyle(.white.opacity(0.9)) .foregroundStyle(.white.opacity(0.9))
}
}
}
.padding(20) .padding(20)
} }
#endif #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))
}
} }
.ignoresSafeArea() .ignoresSafeArea()
.onAppear { .onAppear {
@@ -111,6 +187,135 @@ struct SingleStreamPlaybackScreen: View {
logSingleStream("SingleStreamPlaybackScreen disappeared") 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 { struct SingleStreamPlaybackSource: Sendable {
@@ -290,6 +495,12 @@ private final class SingleStreamMarqueeContainerView: UIView {
struct SingleStreamPlayerView: UIViewControllerRepresentable { struct SingleStreamPlayerView: UIViewControllerRepresentable {
let resolveSource: @Sendable () async -> SingleStreamPlaybackSource? let resolveSource: @Sendable () async -> SingleStreamPlaybackSource?
var resolveNextSource: (@Sendable (URL?) async -> SingleStreamPlaybackSource?)? = nil 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 { func makeCoordinator() -> Coordinator {
Coordinator() Coordinator()
@@ -300,10 +511,24 @@ struct SingleStreamPlayerView: UIViewControllerRepresentable {
let controller = AVPlayerViewController() let controller = AVPlayerViewController()
controller.allowsPictureInPicturePlayback = true controller.allowsPictureInPicturePlayback = true
controller.showsPlaybackControls = true controller.showsPlaybackControls = true
context.coordinator.onPiPStateChanged = onPiPStateChanged
controller.delegate = context.coordinator
#if os(iOS) #if os(iOS)
controller.canStartPictureInPictureAutomaticallyFromInline = true controller.canStartPictureInPictureAutomaticallyFromInline = true
#endif #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 Task { @MainActor in
let resolveStartedAt = Date() let resolveStartedAt = Date()
@@ -335,26 +560,102 @@ struct SingleStreamPlayerView: UIViewControllerRepresentable {
controller.player = player controller.player = player
logSingleStream("AVPlayer assigned to controller; calling playImmediately(atRate: 1.0)") logSingleStream("AVPlayer assigned to controller; calling playImmediately(atRate: 1.0)")
player.playImmediately(atRate: 1.0) player.playImmediately(atRate: 1.0)
context.coordinator.installClipTimeLimit(on: player, resolveNextSource: resolveNextSource)
context.coordinator.scheduleStartupRecovery(for: player) context.coordinator.scheduleStartupRecovery(for: player)
} }
return controller 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) { 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() coordinator.clearDebugObservers()
uiViewController.player?.pause() uiViewController.player?.pause()
uiViewController.player = nil uiViewController.player = nil
logSingleStream("dismantleUIViewController complete") logSingleStream("dismantleUIViewController complete")
} }
final class Coordinator: NSObject, @unchecked Sendable { final class Coordinator: NSObject, @unchecked Sendable, AVPlayerViewControllerDelegate {
private var playerObservations: [NSKeyValueObservation] = [] private var playerObservations: [NSKeyValueObservation] = []
private var notificationTokens: [NSObjectProtocol] = [] private var notificationTokens: [NSObjectProtocol] = []
private var startupRecoveryTask: Task<Void, Never>? private var startupRecoveryTask: Task<Void, Never>?
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( func attachDebugObservers(
to player: AVPlayer, to player: AVPlayer,
@@ -456,6 +757,7 @@ struct SingleStreamPlayerView: UIViewControllerRepresentable {
self.attachDebugObservers(to: player, url: nextSource.url, resolveNextSource: resolveNextSource) self.attachDebugObservers(to: player, url: nextSource.url, resolveNextSource: resolveNextSource)
logSingleStream("Autoplay replacing item and replaying url=\(singleStreamDebugURLDescription(nextSource.url))") logSingleStream("Autoplay replacing item and replaying url=\(singleStreamDebugURLDescription(nextSource.url))")
player.playImmediately(atRate: 1.0) player.playImmediately(atRate: 1.0)
self.installClipTimeLimit(on: player, resolveNextSource: resolveNextSource)
self.scheduleStartupRecovery(for: player) 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() { func clearDebugObservers() {
startupRecoveryTask?.cancel() startupRecoveryTask?.cancel()
startupRecoveryTask = nil startupRecoveryTask = nil

View File

@@ -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..<count {
result.append(Clip(model: model, id: idCounter))
idCounter += 1
}
}
return result
}
/// Returns the length of the longest contiguous run of same-model clips.
private static func maxRunLength(_ clips: [Clip]) -> Int {
guard !clips.isEmpty else { return 0 }
var maxRun = 1
var currentRun = 1
for i in 1..<clips.count {
if clips[i].model == clips[i - 1].model {
currentRun += 1
maxRun = max(maxRun, currentRun)
} else {
currentRun = 1
}
}
return maxRun
}
/// A deterministic RNG so tests that depend on randomness are reproducible.
private struct SeededRNG: RandomNumberGenerator {
private var state: UInt64
init(seed: UInt64) { self.state = seed == 0 ? 0xdeadbeef : seed }
mutating func next() -> 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..<totalPicks {
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 { continue }
buckets = pick.remaining
modelCounts[pick.key, default: 0] += 1
lastModel = pick.key
}
// With 6 models and 6000 picks, expected per model 1000.
// Allow ±25% (750 to 1250) since the exclusion rule introduces
// slight bias against whichever model was just picked.
for (model, count) in modelCounts {
#expect(count >= 750 && count <= 1250, "\(model) got \(count) picks")
}
}
}

View File

@@ -23,6 +23,17 @@ targets:
CODE_SIGN_ENTITLEMENTS: mlbTVOS/mlbTVOS.entitlements CODE_SIGN_ENTITLEMENTS: mlbTVOS/mlbTVOS.entitlements
PRODUCT_BUNDLE_IDENTIFIER: com.treyt.mlbTVOS PRODUCT_BUNDLE_IDENTIFIER: com.treyt.mlbTVOS
SWIFT_STRICT_CONCURRENCY: complete 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: mlbIOS:
type: application type: application
platform: iOS platform: iOS