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:
91
README.md
Normal file
91
README.md
Normal 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`.
|
||||
@@ -7,147 +7,149 @@
|
||||
objects = {
|
||||
|
||||
/* Begin PBXBuildFile section */
|
||||
02BA6240A20E957B5337A545 /* Game.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1ABC999C711B074CF0B536DD /* Game.swift */; };
|
||||
051C3D14A06061D44E325FCC /* LinescoreView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 327A33F2676712C2B5B4AF2F /* LinescoreView.swift */; };
|
||||
0653C2F5CC55F4234E8950E5 /* LiveIndicator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 81584770090BAFBEF3ABE6FD /* LiveIndicator.swift */; };
|
||||
07BBD0E7B7F122213708A406 /* LeagueCenterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55E6F6797DA08FA657C04818 /* LeagueCenterView.swift */; };
|
||||
08A22DE07ECE074D17D8A74E /* FeaturedGameCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 102E5E8674E188A04637D3EB /* FeaturedGameCard.swift */; };
|
||||
0B1C43E83CA856543F58E16A /* MultiStreamView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D10F2D91602CE1FB6D7342E /* MultiStreamView.swift */; };
|
||||
11640185C392B0406BBE619D /* MLBServerAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 70E610D24BA83FB2F7490B00 /* MLBServerAPI.swift */; };
|
||||
1555D223058B858E4C6F419D /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FB99FBEDA096B95C3D2E5B4 /* ContentView.swift */; };
|
||||
1AA11AA11AA11AA11AA11AA1 /* GameCenterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2BB22BB22BB22BB22BB22BB1 /* GameCenterView.swift */; };
|
||||
1AA11AA11AA11AA11AA11AA2 /* LeagueCenterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2BB22BB22BB22BB22BB22BB2 /* LeagueCenterView.swift */; };
|
||||
1AA11AA11AA11AA11AA11AA3 /* GameCenterViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2BB22BB22BB22BB22BB22BB3 /* GameCenterViewModel.swift */; };
|
||||
1AA11AA11AA11AA11AA11AA4 /* LeagueCenterViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2BB22BB22BB22BB22BB22BB4 /* LeagueCenterViewModel.swift */; };
|
||||
1E31C6F500CEEA5284081D22 /* MLBServerAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 70E610D24BA83FB2F7490B00 /* MLBServerAPI.swift */; };
|
||||
1E5405E1C27A16971961091C /* ScoreOverlayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5F6A7B8C9D0E1F2A3B4C5D6 /* ScoreOverlayView.swift */; };
|
||||
22B1A4EB0E8E279B054FEC3E /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = E04B78C78BA9DA73B62AA3EE /* Foundation.framework */; };
|
||||
24C6D5F4A3B291807F6E5D4C /* ScoresTickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 24C6D5F4A3B291807F6E5D4B /* ScoresTickerView.swift */; };
|
||||
20A122430083142C3C74AF5F /* TeamLogoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2E99F69727800D0CB795503 /* TeamLogoView.swift */; };
|
||||
26AE6D91B829B233CEDAE7DF /* AtBatTimelineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3D1C29BF9DF2B8F811368BC /* AtBatTimelineView.swift */; };
|
||||
29627CA5EE85BA103B38D1F5 /* Game.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1ABC999C711B074CF0B536DD /* Game.swift */; };
|
||||
31353216A6288D986544F61E /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DE9E708FA75ABB9F49D2392 /* SettingsView.swift */; };
|
||||
2A6155FFEE85643ABDE4111A /* VideoShuffle.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3A003EA560655E4B2200DBE /* VideoShuffle.swift */; };
|
||||
2B52AC228AE12CC671348964 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DE9E708FA75ABB9F49D2392 /* SettingsView.swift */; };
|
||||
342EED600E1A5B4A3D083E50 /* TeamAssets.swift in Sources */ = {isa = PBXBuildFile; fileRef = 645E77DD8183D54EFC97CFB5 /* TeamAssets.swift */; };
|
||||
37BFF8231552B49E4775AA59 /* GamesViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9BD2DEA6BBA55E86B2555099 /* GamesViewModel.swift */; };
|
||||
3FB9BCB07B6DBFE6D1D2C523 /* GameCenterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2BB22BB22BB22BB22BB22BB1 /* GameCenterView.swift */; };
|
||||
4318098424F9B3FF3B256EED /* LeagueCenterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2BB22BB22BB22BB22BB22BB2 /* LeagueCenterView.swift */; };
|
||||
43AC37B4F957CDC3986F044A /* mlbIOSApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = F0209C78036DA08FA794D8D9 /* mlbIOSApp.swift */; };
|
||||
43B16E04582A874E87514C30 /* mlbIOSRootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3F874FDD20F82AFEB475CD73 /* mlbIOSRootView.swift */; };
|
||||
3679F9F4949C8B3B8D5D6E94 /* GameCenterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E80EF8B01452220FA6BCD9B2 /* GameCenterView.swift */; };
|
||||
36E4F79F23EF867A100E924D /* PitchSequenceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7F13A88254E53AF5A4AAA2AA /* PitchSequenceView.swift */; };
|
||||
468EB7F6EC98D83099E2C352 /* PlatformUI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B4E350321B6673B42545860 /* PlatformUI.swift */; };
|
||||
470E17740A5C54C6BF2C1C97 /* StrikeZoneView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E345FE2F997DC60C7A3E9158 /* StrikeZoneView.swift */; };
|
||||
49B79787C58CA43F2C1F9A9C /* ScoreOverlayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 60B7F01667B2EBFBDFC1BBF6 /* ScoreOverlayView.swift */; };
|
||||
49FB2101E46F2DAD10BC6201 /* SprayChartView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B14F1577DCCDBEF7858E6BE5 /* SprayChartView.swift */; };
|
||||
4CB947BC5B5ECF83EF84F7E4 /* LinescoreView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 327A33F2676712C2B5B4AF2F /* LinescoreView.swift */; };
|
||||
4F67D44E9A36BED9CD8724CF /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FB99FBEDA096B95C3D2E5B4 /* ContentView.swift */; };
|
||||
4FB2E354854C1408419634BB /* ScoresTickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 339D5F94415E8C018D384CDE /* ScoresTickerView.swift */; };
|
||||
50D870AF95561129D706C411 /* GameListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B5D51D6E4990515411D3BCD /* GameListView.swift */; };
|
||||
512148AC1C1F35F5CB688F98 /* DashboardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5EA4C33F029665B80F1A6B6D /* DashboardView.swift */; };
|
||||
52498A59FF923A1494A0139B /* GameListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B5D51D6E4990515411D3BCD /* GameListView.swift */; };
|
||||
5A76A7BB97FFA089156A0ABA /* PlatformUI.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBA78A938A07E07B064CEA54 /* PlatformUI.swift */; };
|
||||
58B5982C6D8DC6F41EAC5CCD /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 7B361C2076C86F2EE841B2AB /* Assets.xcassets */; };
|
||||
5DFD3805A76DDE49B0DC1E2D /* SingleStreamPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA4AF68CDF4FC8D07194B4D0 /* SingleStreamPlayerView.swift */; };
|
||||
5EBF83734395FCF7BCE1E26B /* MLBStatsAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6067FE4F340FC0DCCFA62C67 /* MLBStatsAPI.swift */; };
|
||||
5ED9CAF1722802A4AD561617 /* mlbIOSRootView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4B8E51B59B366184CDCCED5 /* mlbIOSRootView.swift */; };
|
||||
5F5AF9B045B8F2BA87CBECD3 /* ScoreOverlayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 60B7F01667B2EBFBDFC1BBF6 /* ScoreOverlayView.swift */; };
|
||||
63AF55498896C9BBCD1D4337 /* GameCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E21E3115B8A9B4C798ECE828 /* GameCardView.swift */; };
|
||||
7D2D21DAE73958578FE2E730 /* LeagueCenterViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2BB22BB22BB22BB22BB22BB4 /* LeagueCenterViewModel.swift */; };
|
||||
65ABD3574B88BDDBB206B1DC /* GamesViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9BD2DEA6BBA55E86B2555099 /* GamesViewModel.swift */; };
|
||||
6CD8E1E4B6A4C5D0F0F9299E /* MultiStreamView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D10F2D91602CE1FB6D7342E /* MultiStreamView.swift */; };
|
||||
6E502A815BB3C150E56E7E38 /* PlatformUI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8B4E350321B6673B42545860 /* PlatformUI.swift */; };
|
||||
718E7200B5BBB22CB48C2EF8 /* mlbIOSApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2CC4A28B180001C2A1B0BA5F /* mlbIOSApp.swift */; };
|
||||
769DF65BBCC400691B98C71F /* AtBatTimelineView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3D1C29BF9DF2B8F811368BC /* AtBatTimelineView.swift */; };
|
||||
80A690C52C9C8140E7C519E8 /* TeamLogoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2E99F69727800D0CB795503 /* TeamLogoView.swift */; };
|
||||
820FB03D0E97C521BA239071 /* MLBNetworkSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5152ABCC9AACFD91BAC8C95B /* MLBNetworkSheet.swift */; };
|
||||
82DEB54FF3BFD51A18ED98A1 /* MLBStatsAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6067FE4F340FC0DCCFA62C67 /* MLBStatsAPI.swift */; };
|
||||
8D5FE07166FAD17836A0AFF4 /* PitcherHeadshotView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4E5F6A7B8C9D0E1F2A3B4C5 /* PitcherHeadshotView.swift */; };
|
||||
8649D4F513A663D2493D91C9 /* LeagueCenterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 55E6F6797DA08FA657C04818 /* LeagueCenterView.swift */; };
|
||||
8801888DFD48351B1D344D45 /* MLBServerAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 70E610D24BA83FB2F7490B00 /* MLBServerAPI.swift */; };
|
||||
8E2F3FAAE778FE6F44C0B5D5 /* PitcherHeadshotView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C6E303B753C604504C2762C7 /* PitcherHeadshotView.swift */; };
|
||||
95046231282AA1B67F4B5A8D /* SprayChartView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B14F1577DCCDBEF7858E6BE5 /* SprayChartView.swift */; };
|
||||
9525FA984562C90A677AD453 /* GameCenterViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5DA48863884BB6B4983BEE6 /* GameCenterViewModel.swift */; };
|
||||
96250318C3F0491D095DDAA9 /* ScoresTickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 339D5F94415E8C018D384CDE /* ScoresTickerView.swift */; };
|
||||
9D29D3508CD05CB3F4975910 /* GamesViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9BD2DEA6BBA55E86B2555099 /* GamesViewModel.swift */; };
|
||||
A1B2C3D4E5F60718091A2B3C /* PitcherHeadshotView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D4E5F6A7B8C9D0E1F2A3B4C5 /* PitcherHeadshotView.swift */; };
|
||||
A506FDC86C3A78F4A2C04C10 /* GameListView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0B5D51D6E4990515411D3BCD /* GameListView.swift */; };
|
||||
AFA76F6EBE7977914A849447 /* FeaturedGameCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 102E5E8674E188A04637D3EB /* FeaturedGameCard.swift */; };
|
||||
B215941F5A269C59C0939958 /* TeamLogoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C2E99F69727800D0CB795503 /* TeamLogoView.swift */; };
|
||||
B2C3D4E5F6A70819A1B2C3D4 /* ScoreOverlayView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E5F6A7B8C9D0E1F2A3B4C5D6 /* ScoreOverlayView.swift */; };
|
||||
B921B07F43A13CBBDE9C499D /* GameCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E21E3115B8A9B4C798ECE828 /* GameCardView.swift */; };
|
||||
BA1B3240360F79FE2DF42543 /* LinescoreView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 327A33F2676712C2B5B4AF2F /* LinescoreView.swift */; };
|
||||
BB48CC4C732672536CD5EF96 /* TeamAssets.swift in Sources */ = {isa = PBXBuildFile; fileRef = 645E77DD8183D54EFC97CFB5 /* TeamAssets.swift */; };
|
||||
A70F5B83F079279CF2B41FCE /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 3FB99FBEDA096B95C3D2E5B4 /* ContentView.swift */; };
|
||||
A7195DD696E4C07218D74F19 /* PitcherHeadshotView.swift in Sources */ = {isa = PBXBuildFile; fileRef = C6E303B753C604504C2762C7 /* PitcherHeadshotView.swift */; };
|
||||
AAD1562E79743AA0B09CB857 /* GameCardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E21E3115B8A9B4C798ECE828 /* GameCardView.swift */; };
|
||||
ACEB18A23D20F79AA757DD63 /* Game.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1ABC999C711B074CF0B536DD /* Game.swift */; };
|
||||
AEE0045C962DDF789E61429B /* TeamAssets.swift in Sources */ = {isa = PBXBuildFile; fileRef = 645E77DD8183D54EFC97CFB5 /* TeamAssets.swift */; };
|
||||
AF6279D4B9E386009C27EC23 /* VideoShuffleTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8A5568E639232B59AEB84FD4 /* VideoShuffleTests.swift */; };
|
||||
B0A9FFD5C20A80E16532CC91 /* LeagueCenterViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E9E572D932D96FE2C5BD7A4 /* LeagueCenterViewModel.swift */; };
|
||||
B345E89FC7B8C263B067E082 /* GameCenterViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F5DA48863884BB6B4983BEE6 /* GameCenterViewModel.swift */; };
|
||||
B730E81141D5686CD7C6DE4F /* VideoShuffle.swift in Sources */ = {isa = PBXBuildFile; fileRef = C3A003EA560655E4B2200DBE /* VideoShuffle.swift */; };
|
||||
C1DA213471DC246B8DF3F840 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = AC5BCB2E1DA0EC63E422B113 /* Assets.xcassets */; };
|
||||
C67442678865108796537BBC /* GameCenterViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2BB22BB22BB22BB22BB22BB3 /* GameCenterViewModel.swift */; };
|
||||
CA20CF293D75E6058E0CB78C /* SingleStreamPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA4AF68CDF4FC8D07194B4D0 /* SingleStreamPlayerView.swift */; };
|
||||
D199D622D64E3489E049B04A /* LiveIndicator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 81584770090BAFBEF3ABE6FD /* LiveIndicator.swift */; };
|
||||
D48041F6CFBCEB2F3492747F /* MultiStreamView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8D10F2D91602CE1FB6D7342E /* MultiStreamView.swift */; };
|
||||
DA156E98ACD90B582F3BBAB2 /* PlatformUI.swift in Sources */ = {isa = PBXBuildFile; fileRef = BBA78A938A07E07B064CEA54 /* PlatformUI.swift */; };
|
||||
DB6E6A4890D0D22526DA5D96 /* ScoresTickerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 24C6D5F4A3B291807F6E5D4B /* ScoresTickerView.swift */; };
|
||||
D688F6B0680C29AECD289CA9 /* SingleStreamPlayerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = FA4AF68CDF4FC8D07194B4D0 /* SingleStreamPlayerView.swift */; };
|
||||
D864A2844FD222A04B48F6EF /* MLBNetworkSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5152ABCC9AACFD91BAC8C95B /* MLBNetworkSheet.swift */; };
|
||||
DC37555DE8C367E929974484 /* MLBStatsAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6067FE4F340FC0DCCFA62C67 /* MLBStatsAPI.swift */; };
|
||||
E42B27D852AB037246167C23 /* LeagueCenterViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7E9E572D932D96FE2C5BD7A4 /* LeagueCenterViewModel.swift */; };
|
||||
E6A3D6E740DBE329B8EAD531 /* StrikeZoneView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E345FE2F997DC60C7A3E9158 /* StrikeZoneView.swift */; };
|
||||
E7FB366456557069990F550C /* LiveIndicator.swift in Sources */ = {isa = PBXBuildFile; fileRef = 81584770090BAFBEF3ABE6FD /* LiveIndicator.swift */; };
|
||||
E96459544473FC4A3CD92B4F /* GameCenterView.swift in Sources */ = {isa = PBXBuildFile; fileRef = E80EF8B01452220FA6BCD9B2 /* GameCenterView.swift */; };
|
||||
F096F11642B32BF240C367A5 /* PitchSequenceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7F13A88254E53AF5A4AAA2AA /* PitchSequenceView.swift */; };
|
||||
F36A71E407BC707B8E03457D /* DashboardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5EA4C33F029665B80F1A6B6D /* DashboardView.swift */; };
|
||||
F60F24246B08223DC2BA5825 /* DashboardView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5EA4C33F029665B80F1A6B6D /* DashboardView.swift */; };
|
||||
F761076F826FEB86E972D516 /* MLBNetworkSheet.swift in Sources */ = {isa = PBXBuildFile; fileRef = 5152ABCC9AACFD91BAC8C95B /* MLBNetworkSheet.swift */; };
|
||||
FAFA9B29273C4D8A54CD5916 /* FeaturedGameCard.swift in Sources */ = {isa = PBXBuildFile; fileRef = 102E5E8674E188A04637D3EB /* FeaturedGameCard.swift */; };
|
||||
FD31074216AC0115B3F1C057 /* mlbTVOSApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 45142562E644AF04F7719083 /* mlbTVOSApp.swift */; };
|
||||
FD9322BED3A2B827CB021163 /* SettingsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4DE9E708FA75ABB9F49D2392 /* SettingsView.swift */; };
|
||||
FE80BDA15CC8CE2F18DB4883 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 56358D0D5DC5CC531034287F /* Assets.xcassets */; };
|
||||
/* End PBXBuildFile section */
|
||||
|
||||
/* Begin PBXContainerItemProxy section */
|
||||
BB037F390E873F3105CC93DC /* PBXContainerItemProxy */ = {
|
||||
isa = PBXContainerItemProxy;
|
||||
containerPortal = 89437C3F6A1DA0DA4ADEA3AB /* Project object */;
|
||||
proxyType = 1;
|
||||
remoteGlobalIDString = 6ACA532AEDED1D5945D6153A;
|
||||
remoteInfo = mlbTVOS;
|
||||
};
|
||||
/* End PBXContainerItemProxy section */
|
||||
|
||||
/* Begin PBXFileReference section */
|
||||
0B5D51D6E4990515411D3BCD /* GameListView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameListView.swift; sourceTree = "<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>"; };
|
||||
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>"; };
|
||||
24B58FDA7A8CA27BC512CBA6 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
|
||||
24C6D5F4A3B291807F6E5D4B /* ScoresTickerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScoresTickerView.swift; sourceTree = "<group>"; };
|
||||
2BB22BB22BB22BB22BB22BB1 /* GameCenterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameCenterView.swift; sourceTree = "<group>"; };
|
||||
2BB22BB22BB22BB22BB22BB2 /* LeagueCenterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LeagueCenterView.swift; sourceTree = "<group>"; };
|
||||
2BB22BB22BB22BB22BB22BB3 /* GameCenterViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GameCenterViewModel.swift; sourceTree = "<group>"; };
|
||||
2BB22BB22BB22BB22BB22BB4 /* LeagueCenterViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LeagueCenterViewModel.swift; sourceTree = "<group>"; };
|
||||
2CC4A28B180001C2A1B0BA5F /* mlbIOSApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = mlbIOSApp.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>"; };
|
||||
45142562E644AF04F7719083 /* mlbTVOSApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = mlbTVOSApp.swift; sourceTree = "<group>"; };
|
||||
4DE9E708FA75ABB9F49D2392 /* SettingsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SettingsView.swift; sourceTree = "<group>"; };
|
||||
5152ABCC9AACFD91BAC8C95B /* MLBNetworkSheet.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MLBNetworkSheet.swift; sourceTree = "<group>"; };
|
||||
55A3DC0B5087BAD6BC59B35A /* mlbIOS.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = mlbIOS.app; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
56358D0D5DC5CC531034287F /* Assets.xcassets */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||
55E6F6797DA08FA657C04818 /* LeagueCenterView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LeagueCenterView.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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
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>"; };
|
||||
95C4DEDEAA64C74AF3DB24F2 /* mlbIOS.entitlements */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.entitlements; path = mlbIOS.entitlements; sourceTree = "<group>"; };
|
||||
9BD2DEA6BBA55E86B2555099 /* GamesViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GamesViewModel.swift; sourceTree = "<group>"; };
|
||||
A6FE9AFE34BB8CE4F33B62D0 /* Info.plist */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
AC5BCB2E1DA0EC63E422B113 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = "<group>"; };
|
||||
BBA78A938A07E07B064CEA54 /* PlatformUI.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PlatformUI.swift; sourceTree = "<group>"; };
|
||||
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>"; };
|
||||
C2E99F69727800D0CB795503 /* TeamLogoView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TeamLogoView.swift; sourceTree = "<group>"; };
|
||||
D4E5F6A7B8C9D0E1F2A3B4C5 /* PitcherHeadshotView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PitcherHeadshotView.swift; sourceTree = "<group>"; };
|
||||
E04B78C78BA9DA73B62AA3EE /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = Platforms/iPhoneOS.platform/Developer/SDKs/iPhoneOS14.0.sdk/System/Library/Frameworks/Foundation.framework; sourceTree = DEVELOPER_DIR; };
|
||||
C3A003EA560655E4B2200DBE /* VideoShuffle.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VideoShuffle.swift; sourceTree = "<group>"; };
|
||||
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>"; };
|
||||
E5F6A7B8C9D0E1F2A3B4C5D6 /* ScoreOverlayView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ScoreOverlayView.swift; sourceTree = "<group>"; };
|
||||
F0209C78036DA08FA794D8D9 /* mlbIOSApp.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = mlbIOSApp.swift; sourceTree = "<group>"; };
|
||||
E345FE2F997DC60C7A3E9158 /* StrikeZoneView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StrikeZoneView.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>"; };
|
||||
/* End PBXFileReference section */
|
||||
|
||||
/* Begin PBXFrameworksBuildPhase section */
|
||||
EF6C5375C48E5C8359230651 /* Frameworks */ = {
|
||||
isa = PBXFrameworksBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
22B1A4EB0E8E279B054FEC3E /* Foundation.framework in Frameworks */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXFrameworksBuildPhase section */
|
||||
|
||||
/* Begin PBXGroup section */
|
||||
0B56828FEC5E5902FE078143 /* iOS */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
E04B78C78BA9DA73B62AA3EE /* Foundation.framework */,
|
||||
);
|
||||
name = iOS;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
1646E84AFB7C540A2D228B64 /* mlbIOS */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
96E8DA91A0CDD10D8953C8BA /* Views */,
|
||||
A6FE9AFE34BB8CE4F33B62D0 /* Info.plist */,
|
||||
95C4DEDEAA64C74AF3DB24F2 /* mlbIOS.entitlements */,
|
||||
56358D0D5DC5CC531034287F /* Assets.xcassets */,
|
||||
F0209C78036DA08FA794D8D9 /* mlbIOSApp.swift */,
|
||||
);
|
||||
name = mlbIOS;
|
||||
path = mlbIOS;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
2C171EA64FFA962254F20583 /* ViewModels */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
2BB22BB22BB22BB22BB22BB3 /* GameCenterViewModel.swift */,
|
||||
F5DA48863884BB6B4983BEE6 /* GameCenterViewModel.swift */,
|
||||
9BD2DEA6BBA55E86B2555099 /* GamesViewModel.swift */,
|
||||
2BB22BB22BB22BB22BB22BB4 /* LeagueCenterViewModel.swift */,
|
||||
7E9E572D932D96FE2C5BD7A4 /* LeagueCenterViewModel.swift */,
|
||||
);
|
||||
path = ViewModels;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
2C7BE69056BC157DD7B0EA69 /* Views */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
B4B8E51B59B366184CDCCED5 /* mlbIOSRootView.swift */,
|
||||
);
|
||||
path = Views;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
4FAE5C2D72CD78BF7C15C4FC /* mlbTVOS */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
@@ -163,61 +165,70 @@
|
||||
path = mlbTVOS;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
5AE491A6B2C1767EFEF5CE88 /* Frameworks */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
0B56828FEC5E5902FE078143 /* iOS */,
|
||||
);
|
||||
name = Frameworks;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
62F95C3E8B0F6AFE3BEDE9C3 /* Components */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
C3D1C29BF9DF2B8F811368BC /* AtBatTimelineView.swift */,
|
||||
327A33F2676712C2B5B4AF2F /* LinescoreView.swift */,
|
||||
81584770090BAFBEF3ABE6FD /* LiveIndicator.swift */,
|
||||
D4E5F6A7B8C9D0E1F2A3B4C5 /* PitcherHeadshotView.swift */,
|
||||
24C6D5F4A3B291807F6E5D4B /* ScoresTickerView.swift */,
|
||||
E5F6A7B8C9D0E1F2A3B4C5D6 /* ScoreOverlayView.swift */,
|
||||
C6E303B753C604504C2762C7 /* PitcherHeadshotView.swift */,
|
||||
7F13A88254E53AF5A4AAA2AA /* PitchSequenceView.swift */,
|
||||
8B4E350321B6673B42545860 /* PlatformUI.swift */,
|
||||
60B7F01667B2EBFBDFC1BBF6 /* ScoreOverlayView.swift */,
|
||||
339D5F94415E8C018D384CDE /* ScoresTickerView.swift */,
|
||||
B14F1577DCCDBEF7858E6BE5 /* SprayChartView.swift */,
|
||||
E345FE2F997DC60C7A3E9158 /* StrikeZoneView.swift */,
|
||||
C2E99F69727800D0CB795503 /* TeamLogoView.swift */,
|
||||
BBA78A938A07E07B064CEA54 /* PlatformUI.swift */,
|
||||
);
|
||||
path = Components;
|
||||
sourceTree = "<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 = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
6CCF8450F0096AAE94F8E0EF /* mlbIOS */,
|
||||
4FAE5C2D72CD78BF7C15C4FC /* mlbTVOS */,
|
||||
6DA200592E30D8963373A30C /* mlbTVOSTests */,
|
||||
88274023705E1F09B25F86FF /* Products */,
|
||||
1646E84AFB7C540A2D228B64 /* mlbIOS */,
|
||||
5AE491A6B2C1767EFEF5CE88 /* Frameworks */,
|
||||
);
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
88274023705E1F09B25F86FF /* Products */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
0FE1BD4A8517243AFABE371C /* mlbIOS.app */,
|
||||
766441BC900073529EE93D69 /* mlbTVOS.app */,
|
||||
55A3DC0B5087BAD6BC59B35A /* mlbIOS.app */,
|
||||
6FE64B2FA917EF98B16A3286 /* mlbTVOSTests.xctest */,
|
||||
);
|
||||
name = Products;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
96E8DA91A0CDD10D8953C8BA /* Views */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
3F874FDD20F82AFEB475CD73 /* mlbIOSRootView.swift */,
|
||||
);
|
||||
name = Views;
|
||||
path = Views;
|
||||
sourceTree = "<group>";
|
||||
};
|
||||
C6DC4CC5AAB9AF84D3CB9F3B /* Services */ = {
|
||||
isa = PBXGroup;
|
||||
children = (
|
||||
70E610D24BA83FB2F7490B00 /* MLBServerAPI.swift */,
|
||||
6067FE4F340FC0DCCFA62C67 /* MLBStatsAPI.swift */,
|
||||
C3A003EA560655E4B2200DBE /* VideoShuffle.swift */,
|
||||
);
|
||||
path = Services;
|
||||
sourceTree = "<group>";
|
||||
@@ -229,9 +240,9 @@
|
||||
5EA4C33F029665B80F1A6B6D /* DashboardView.swift */,
|
||||
102E5E8674E188A04637D3EB /* FeaturedGameCard.swift */,
|
||||
E21E3115B8A9B4C798ECE828 /* GameCardView.swift */,
|
||||
2BB22BB22BB22BB22BB22BB1 /* GameCenterView.swift */,
|
||||
E80EF8B01452220FA6BCD9B2 /* GameCenterView.swift */,
|
||||
0B5D51D6E4990515411D3BCD /* GameListView.swift */,
|
||||
2BB22BB22BB22BB22BB22BB2 /* LeagueCenterView.swift */,
|
||||
55E6F6797DA08FA657C04818 /* LeagueCenterView.swift */,
|
||||
5152ABCC9AACFD91BAC8C95B /* MLBNetworkSheet.swift */,
|
||||
8D10F2D91602CE1FB6D7342E /* MultiStreamView.swift */,
|
||||
4DE9E708FA75ABB9F49D2392 /* SettingsView.swift */,
|
||||
@@ -253,6 +264,42 @@
|
||||
/* End PBXGroup section */
|
||||
|
||||
/* Begin PBXNativeTarget section */
|
||||
3A9B7B05A37413FD435D907D /* mlbIOS */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 7E61CD36E6F461F6012C8AEC /* Build configuration list for PBXNativeTarget "mlbIOS" */;
|
||||
buildPhases = (
|
||||
161A48F0EE45E1D49E1ADD61 /* Sources */,
|
||||
AF3410DF4206295AA0FDC9F7 /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
);
|
||||
name = mlbIOS;
|
||||
packageProductDependencies = (
|
||||
);
|
||||
productName = mlbIOS;
|
||||
productReference = 0FE1BD4A8517243AFABE371C /* mlbIOS.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
3B0B3D1BD19FB0B2F845FE00 /* mlbTVOSTests */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = E19144DDC167AFC338BA9481 /* Build configuration list for PBXNativeTarget "mlbTVOSTests" */;
|
||||
buildPhases = (
|
||||
ABB29E457F4A3E72130EE536 /* Sources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
540BB52ABCD2F688E02B1982 /* PBXTargetDependency */,
|
||||
);
|
||||
name = mlbTVOSTests;
|
||||
packageProductDependencies = (
|
||||
);
|
||||
productName = mlbTVOSTests;
|
||||
productReference = 6FE64B2FA917EF98B16A3286 /* mlbTVOSTests.xctest */;
|
||||
productType = "com.apple.product-type.bundle.unit-test";
|
||||
};
|
||||
6ACA532AEDED1D5945D6153A /* mlbTVOS */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 36D4353993A0A22378BC4770 /* Build configuration list for PBXNativeTarget "mlbTVOS" */;
|
||||
@@ -265,27 +312,12 @@
|
||||
dependencies = (
|
||||
);
|
||||
name = mlbTVOS;
|
||||
packageProductDependencies = (
|
||||
);
|
||||
productName = mlbTVOS;
|
||||
productReference = 766441BC900073529EE93D69 /* mlbTVOS.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
9C5FE08A60985D23E089CEE5 /* mlbIOS */ = {
|
||||
isa = PBXNativeTarget;
|
||||
buildConfigurationList = 67C1F966BDD84456877DC373 /* Build configuration list for PBXNativeTarget "mlbIOS" */;
|
||||
buildPhases = (
|
||||
9386CC819A80738E79744F6A /* Sources */,
|
||||
EF6C5375C48E5C8359230651 /* Frameworks */,
|
||||
3CEE2E3AB4AAF6D8CC0F12D7 /* Resources */,
|
||||
);
|
||||
buildRules = (
|
||||
);
|
||||
dependencies = (
|
||||
);
|
||||
name = mlbIOS;
|
||||
productName = mlbIOS;
|
||||
productReference = 55A3DC0B5087BAD6BC59B35A /* mlbIOS.app */;
|
||||
productType = "com.apple.product-type.application";
|
||||
};
|
||||
/* End PBXNativeTarget section */
|
||||
|
||||
/* Begin PBXProject section */
|
||||
@@ -294,6 +326,14 @@
|
||||
attributes = {
|
||||
BuildIndependentTargetsInParallel = YES;
|
||||
LastUpgradeCheck = 2630;
|
||||
TargetAttributes = {
|
||||
3A9B7B05A37413FD435D907D = {
|
||||
DevelopmentTeam = "";
|
||||
};
|
||||
3B0B3D1BD19FB0B2F845FE00 = {
|
||||
DevelopmentTeam = "";
|
||||
};
|
||||
};
|
||||
};
|
||||
buildConfigurationList = B8EE3C08200DF28CFF279E15 /* Build configuration list for PBXProject "mlbTVOS" */;
|
||||
compatibilityVersion = "Xcode 14.0";
|
||||
@@ -305,25 +345,17 @@
|
||||
);
|
||||
mainGroup = 86C1117ADD32A1A57AA32A08;
|
||||
minimizedProjectReferenceProxies = 1;
|
||||
productRefGroup = 88274023705E1F09B25F86FF /* Products */;
|
||||
projectDirPath = "";
|
||||
projectRoot = "";
|
||||
targets = (
|
||||
3A9B7B05A37413FD435D907D /* mlbIOS */,
|
||||
6ACA532AEDED1D5945D6153A /* mlbTVOS */,
|
||||
9C5FE08A60985D23E089CEE5 /* mlbIOS */,
|
||||
3B0B3D1BD19FB0B2F845FE00 /* mlbTVOSTests */,
|
||||
);
|
||||
};
|
||||
/* End PBXProject section */
|
||||
|
||||
/* Begin PBXResourcesBuildPhase section */
|
||||
3CEE2E3AB4AAF6D8CC0F12D7 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
FE80BDA15CC8CE2F18DB4883 /* Assets.xcassets in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
5C7F99E1451B54AD3CC382CB /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
@@ -332,40 +364,61 @@
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
AF3410DF4206295AA0FDC9F7 /* Resources */ = {
|
||||
isa = PBXResourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
58B5982C6D8DC6F41EAC5CCD /* Assets.xcassets in Resources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXResourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXSourcesBuildPhase section */
|
||||
9386CC819A80738E79744F6A /* Sources */ = {
|
||||
161A48F0EE45E1D49E1ADD61 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
02BA6240A20E957B5337A545 /* Game.swift in Sources */,
|
||||
BB48CC4C732672536CD5EF96 /* TeamAssets.swift in Sources */,
|
||||
11640185C392B0406BBE619D /* MLBServerAPI.swift in Sources */,
|
||||
82DEB54FF3BFD51A18ED98A1 /* MLBStatsAPI.swift in Sources */,
|
||||
C67442678865108796537BBC /* GameCenterViewModel.swift in Sources */,
|
||||
37BFF8231552B49E4775AA59 /* GamesViewModel.swift in Sources */,
|
||||
7D2D21DAE73958578FE2E730 /* LeagueCenterViewModel.swift in Sources */,
|
||||
BA1B3240360F79FE2DF42543 /* LinescoreView.swift in Sources */,
|
||||
D199D622D64E3489E049B04A /* LiveIndicator.swift in Sources */,
|
||||
8D5FE07166FAD17836A0AFF4 /* PitcherHeadshotView.swift in Sources */,
|
||||
DA156E98ACD90B582F3BBAB2 /* PlatformUI.swift in Sources */,
|
||||
1E5405E1C27A16971961091C /* ScoreOverlayView.swift in Sources */,
|
||||
DB6E6A4890D0D22526DA5D96 /* ScoresTickerView.swift in Sources */,
|
||||
B215941F5A269C59C0939958 /* TeamLogoView.swift in Sources */,
|
||||
1555D223058B858E4C6F419D /* ContentView.swift in Sources */,
|
||||
F60F24246B08223DC2BA5825 /* DashboardView.swift in Sources */,
|
||||
AFA76F6EBE7977914A849447 /* FeaturedGameCard.swift in Sources */,
|
||||
B921B07F43A13CBBDE9C499D /* GameCardView.swift in Sources */,
|
||||
3FB9BCB07B6DBFE6D1D2C523 /* GameCenterView.swift in Sources */,
|
||||
A506FDC86C3A78F4A2C04C10 /* GameListView.swift in Sources */,
|
||||
4318098424F9B3FF3B256EED /* LeagueCenterView.swift in Sources */,
|
||||
F761076F826FEB86E972D516 /* MLBNetworkSheet.swift in Sources */,
|
||||
D48041F6CFBCEB2F3492747F /* MultiStreamView.swift in Sources */,
|
||||
31353216A6288D986544F61E /* SettingsView.swift in Sources */,
|
||||
CA20CF293D75E6058E0CB78C /* SingleStreamPlayerView.swift in Sources */,
|
||||
43AC37B4F957CDC3986F044A /* mlbIOSApp.swift in Sources */,
|
||||
43B16E04582A874E87514C30 /* mlbIOSRootView.swift in Sources */,
|
||||
26AE6D91B829B233CEDAE7DF /* AtBatTimelineView.swift in Sources */,
|
||||
A70F5B83F079279CF2B41FCE /* ContentView.swift in Sources */,
|
||||
512148AC1C1F35F5CB688F98 /* DashboardView.swift in Sources */,
|
||||
08A22DE07ECE074D17D8A74E /* FeaturedGameCard.swift in Sources */,
|
||||
ACEB18A23D20F79AA757DD63 /* Game.swift in Sources */,
|
||||
AAD1562E79743AA0B09CB857 /* GameCardView.swift in Sources */,
|
||||
E96459544473FC4A3CD92B4F /* GameCenterView.swift in Sources */,
|
||||
B345E89FC7B8C263B067E082 /* GameCenterViewModel.swift in Sources */,
|
||||
50D870AF95561129D706C411 /* GameListView.swift in Sources */,
|
||||
65ABD3574B88BDDBB206B1DC /* GamesViewModel.swift in Sources */,
|
||||
8649D4F513A663D2493D91C9 /* LeagueCenterView.swift in Sources */,
|
||||
E42B27D852AB037246167C23 /* LeagueCenterViewModel.swift in Sources */,
|
||||
4CB947BC5B5ECF83EF84F7E4 /* LinescoreView.swift in Sources */,
|
||||
0653C2F5CC55F4234E8950E5 /* LiveIndicator.swift in Sources */,
|
||||
D864A2844FD222A04B48F6EF /* MLBNetworkSheet.swift in Sources */,
|
||||
8801888DFD48351B1D344D45 /* MLBServerAPI.swift in Sources */,
|
||||
5EBF83734395FCF7BCE1E26B /* MLBStatsAPI.swift in Sources */,
|
||||
6CD8E1E4B6A4C5D0F0F9299E /* MultiStreamView.swift in Sources */,
|
||||
36E4F79F23EF867A100E924D /* PitchSequenceView.swift in Sources */,
|
||||
8E2F3FAAE778FE6F44C0B5D5 /* PitcherHeadshotView.swift in Sources */,
|
||||
6E502A815BB3C150E56E7E38 /* PlatformUI.swift in Sources */,
|
||||
49B79787C58CA43F2C1F9A9C /* ScoreOverlayView.swift in Sources */,
|
||||
96250318C3F0491D095DDAA9 /* ScoresTickerView.swift in Sources */,
|
||||
2B52AC228AE12CC671348964 /* SettingsView.swift in Sources */,
|
||||
D688F6B0680C29AECD289CA9 /* SingleStreamPlayerView.swift in Sources */,
|
||||
95046231282AA1B67F4B5A8D /* SprayChartView.swift in Sources */,
|
||||
E6A3D6E740DBE329B8EAD531 /* StrikeZoneView.swift in Sources */,
|
||||
AEE0045C962DDF789E61429B /* TeamAssets.swift in Sources */,
|
||||
20A122430083142C3C74AF5F /* TeamLogoView.swift in Sources */,
|
||||
B730E81141D5686CD7C6DE4F /* VideoShuffle.swift in Sources */,
|
||||
718E7200B5BBB22CB48C2EF8 /* mlbIOSApp.swift in Sources */,
|
||||
5ED9CAF1722802A4AD561617 /* mlbIOSRootView.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
ABB29E457F4A3E72130EE536 /* Sources */ = {
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
AF6279D4B9E386009C27EC23 /* VideoShuffleTests.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
@@ -373,37 +426,50 @@
|
||||
isa = PBXSourcesBuildPhase;
|
||||
buildActionMask = 2147483647;
|
||||
files = (
|
||||
769DF65BBCC400691B98C71F /* AtBatTimelineView.swift in Sources */,
|
||||
4F67D44E9A36BED9CD8724CF /* ContentView.swift in Sources */,
|
||||
F36A71E407BC707B8E03457D /* DashboardView.swift in Sources */,
|
||||
FAFA9B29273C4D8A54CD5916 /* FeaturedGameCard.swift in Sources */,
|
||||
29627CA5EE85BA103B38D1F5 /* Game.swift in Sources */,
|
||||
63AF55498896C9BBCD1D4337 /* GameCardView.swift in Sources */,
|
||||
1AA11AA11AA11AA11AA11AA1 /* GameCenterView.swift in Sources */,
|
||||
1AA11AA11AA11AA11AA11AA3 /* GameCenterViewModel.swift in Sources */,
|
||||
3679F9F4949C8B3B8D5D6E94 /* GameCenterView.swift in Sources */,
|
||||
9525FA984562C90A677AD453 /* GameCenterViewModel.swift in Sources */,
|
||||
52498A59FF923A1494A0139B /* GameListView.swift in Sources */,
|
||||
9D29D3508CD05CB3F4975910 /* GamesViewModel.swift in Sources */,
|
||||
1AA11AA11AA11AA11AA11AA2 /* LeagueCenterView.swift in Sources */,
|
||||
1AA11AA11AA11AA11AA11AA4 /* LeagueCenterViewModel.swift in Sources */,
|
||||
07BBD0E7B7F122213708A406 /* LeagueCenterView.swift in Sources */,
|
||||
B0A9FFD5C20A80E16532CC91 /* LeagueCenterViewModel.swift in Sources */,
|
||||
051C3D14A06061D44E325FCC /* LinescoreView.swift in Sources */,
|
||||
E7FB366456557069990F550C /* LiveIndicator.swift in Sources */,
|
||||
820FB03D0E97C521BA239071 /* MLBNetworkSheet.swift in Sources */,
|
||||
1E31C6F500CEEA5284081D22 /* MLBServerAPI.swift in Sources */,
|
||||
DC37555DE8C367E929974484 /* MLBStatsAPI.swift in Sources */,
|
||||
0B1C43E83CA856543F58E16A /* MultiStreamView.swift in Sources */,
|
||||
A1B2C3D4E5F60718091A2B3C /* PitcherHeadshotView.swift in Sources */,
|
||||
24C6D5F4A3B291807F6E5D4C /* ScoresTickerView.swift in Sources */,
|
||||
B2C3D4E5F6A70819A1B2C3D4 /* ScoreOverlayView.swift in Sources */,
|
||||
F096F11642B32BF240C367A5 /* PitchSequenceView.swift in Sources */,
|
||||
A7195DD696E4C07218D74F19 /* PitcherHeadshotView.swift in Sources */,
|
||||
468EB7F6EC98D83099E2C352 /* PlatformUI.swift in Sources */,
|
||||
5F5AF9B045B8F2BA87CBECD3 /* ScoreOverlayView.swift in Sources */,
|
||||
4FB2E354854C1408419634BB /* ScoresTickerView.swift in Sources */,
|
||||
FD9322BED3A2B827CB021163 /* SettingsView.swift in Sources */,
|
||||
5DFD3805A76DDE49B0DC1E2D /* SingleStreamPlayerView.swift in Sources */,
|
||||
49FB2101E46F2DAD10BC6201 /* SprayChartView.swift in Sources */,
|
||||
470E17740A5C54C6BF2C1C97 /* StrikeZoneView.swift in Sources */,
|
||||
342EED600E1A5B4A3D083E50 /* TeamAssets.swift in Sources */,
|
||||
80A690C52C9C8140E7C519E8 /* TeamLogoView.swift in Sources */,
|
||||
2A6155FFEE85643ABDE4111A /* VideoShuffle.swift in Sources */,
|
||||
FD31074216AC0115B3F1C057 /* mlbTVOSApp.swift in Sources */,
|
||||
5A76A7BB97FFA089156A0ABA /* PlatformUI.swift in Sources */,
|
||||
);
|
||||
runOnlyForDeploymentPostprocessing = 0;
|
||||
};
|
||||
/* End PBXSourcesBuildPhase section */
|
||||
|
||||
/* Begin PBXTargetDependency section */
|
||||
540BB52ABCD2F688E02B1982 /* PBXTargetDependency */ = {
|
||||
isa = PBXTargetDependency;
|
||||
target = 6ACA532AEDED1D5945D6153A /* mlbTVOS */;
|
||||
targetProxy = BB037F390E873F3105CC93DC /* PBXContainerItemProxy */;
|
||||
};
|
||||
/* End PBXTargetDependency section */
|
||||
|
||||
/* Begin XCBuildConfiguration section */
|
||||
0AEA011CF6D6485B876BC988 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
@@ -457,11 +523,11 @@
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
|
||||
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
|
||||
MTL_FAST_MATH = YES;
|
||||
ONLY_ACTIVE_ARCH = YES;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = appletvos;
|
||||
SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-Onone";
|
||||
SWIFT_VERSION = 6.0;
|
||||
@@ -469,53 +535,6 @@
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
0D8A815004F8CC601240D2DB /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CLANG_ENABLE_OBJC_WEAK = NO;
|
||||
CODE_SIGN_ENTITLEMENTS = mlbIOS/mlbIOS.entitlements;
|
||||
DEVELOPMENT_TEAM = "";
|
||||
INFOPLIST_FILE = mlbIOS/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.treyt.mlbIOS;
|
||||
PRODUCT_NAME = mlbIOS;
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_STRICT_CONCURRENCY = complete;
|
||||
SWIFT_VERSION = 6.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
1FC82661BC31026C62934BD7 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor;
|
||||
CLANG_ENABLE_OBJC_WEAK = NO;
|
||||
CODE_SIGN_ENTITLEMENTS = mlbIOS/mlbIOS.entitlements;
|
||||
DEVELOPMENT_TEAM = "";
|
||||
INFOPLIST_FILE = mlbIOS/Info.plist;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.treyt.mlbIOS;
|
||||
PRODUCT_NAME = mlbIOS;
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_STRICT_CONCURRENCY = complete;
|
||||
SWIFT_VERSION = 6.0;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
VALIDATE_PRODUCT = YES;
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
1FE0846F10814B35388166A8 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
@@ -581,10 +600,10 @@
|
||||
GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
|
||||
GCC_WARN_UNUSED_FUNCTION = YES;
|
||||
GCC_WARN_UNUSED_VARIABLE = YES;
|
||||
IPHONEOS_DEPLOYMENT_TARGET = 18.0;
|
||||
MTL_ENABLE_DEBUG_INFO = NO;
|
||||
MTL_FAST_MATH = YES;
|
||||
PRODUCT_NAME = "$(TARGET_NAME)";
|
||||
SDKROOT = appletvos;
|
||||
SWIFT_COMPILATION_MODE = wholemodule;
|
||||
SWIFT_OPTIMIZATION_LEVEL = "-O";
|
||||
SWIFT_VERSION = 6.0;
|
||||
@@ -592,6 +611,78 @@
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
75DEC3365E4FE5397DBA6D23 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CODE_SIGN_ENTITLEMENTS = mlbIOS/mlbIOS.entitlements;
|
||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||
INFOPLIST_FILE = mlbIOS/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.treyt.mlbIOS;
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_STRICT_CONCURRENCY = complete;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
766EF2A8A8F5ABA868469554 /* Debug */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
"@loader_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.treyt.mlbTVOSTests;
|
||||
SDKROOT = appletvos;
|
||||
SWIFT_STRICT_CONCURRENCY = complete;
|
||||
TARGETED_DEVICE_FAMILY = 3;
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/mlbTVOS.app/mlbTVOS";
|
||||
};
|
||||
name = Debug;
|
||||
};
|
||||
91415A3F240ADBD891F74114 /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
BUNDLE_LOADER = "$(TEST_HOST)";
|
||||
GENERATE_INFOPLIST_FILE = YES;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
"@loader_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.treyt.mlbTVOSTests;
|
||||
SDKROOT = appletvos;
|
||||
SWIFT_STRICT_CONCURRENCY = complete;
|
||||
TARGETED_DEVICE_FAMILY = 3;
|
||||
TEST_HOST = "$(BUILT_PRODUCTS_DIR)/mlbTVOS.app/mlbTVOS";
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
D7E3D2E659BC9B3A584585FE /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
|
||||
CODE_SIGN_ENTITLEMENTS = mlbIOS/mlbIOS.entitlements;
|
||||
CODE_SIGN_IDENTITY = "iPhone Developer";
|
||||
INFOPLIST_FILE = mlbIOS/Info.plist;
|
||||
LD_RUNPATH_SEARCH_PATHS = (
|
||||
"$(inherited)",
|
||||
"@executable_path/Frameworks",
|
||||
);
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.treyt.mlbIOS;
|
||||
SDKROOT = iphoneos;
|
||||
SWIFT_STRICT_CONCURRENCY = complete;
|
||||
TARGETED_DEVICE_FAMILY = "1,2";
|
||||
};
|
||||
name = Release;
|
||||
};
|
||||
F266D26959D831BC0C0C782C /* Release */ = {
|
||||
isa = XCBuildConfiguration;
|
||||
buildSettings = {
|
||||
@@ -623,14 +714,14 @@
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Debug;
|
||||
};
|
||||
67C1F966BDD84456877DC373 /* Build configuration list for PBXNativeTarget "mlbIOS" */ = {
|
||||
7E61CD36E6F461F6012C8AEC /* Build configuration list for PBXNativeTarget "mlbIOS" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
1FC82661BC31026C62934BD7 /* Release */,
|
||||
0D8A815004F8CC601240D2DB /* Debug */,
|
||||
75DEC3365E4FE5397DBA6D23 /* Debug */,
|
||||
D7E3D2E659BC9B3A584585FE /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Release;
|
||||
defaultConfigurationName = Debug;
|
||||
};
|
||||
B8EE3C08200DF28CFF279E15 /* Build configuration list for PBXProject "mlbTVOS" */ = {
|
||||
isa = XCConfigurationList;
|
||||
@@ -641,6 +732,15 @@
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Debug;
|
||||
};
|
||||
E19144DDC167AFC338BA9481 /* Build configuration list for PBXNativeTarget "mlbTVOSTests" */ = {
|
||||
isa = XCConfigurationList;
|
||||
buildConfigurations = (
|
||||
766EF2A8A8F5ABA868469554 /* Debug */,
|
||||
91415A3F240ADBD891F74114 /* Release */,
|
||||
);
|
||||
defaultConfigurationIsVisible = 0;
|
||||
defaultConfigurationName = Debug;
|
||||
};
|
||||
/* End XCConfigurationList section */
|
||||
};
|
||||
rootObject = 89437C3F6A1DA0DA4ADEA3AB /* Project object */;
|
||||
|
||||
@@ -42,6 +42,10 @@ actor MLBStatsAPI {
|
||||
try await fetchJSON("\(baseURL).1/game/\(gamePk)/feed/live")
|
||||
}
|
||||
|
||||
func fetchWinProbability(gamePk: String) async throws -> [WinProbabilityEntry] {
|
||||
try await fetchJSON("\(baseURL).1/game/\(gamePk)/winProbability")
|
||||
}
|
||||
|
||||
func fetchLeagueTeams(season: String) async throws -> [LeagueTeamSummary] {
|
||||
let response: TeamsResponse = try await fetchJSON(
|
||||
"\(baseURL)/teams?sportId=1&season=\(season)&hydrate=league,division,venue,record"
|
||||
@@ -828,6 +832,19 @@ struct LiveGameFeed: Codable, Sendable {
|
||||
var homeLineup: [LiveFeedBoxscorePlayer] {
|
||||
liveData.boxscore?.teams?.home.lineupPlayers ?? []
|
||||
}
|
||||
|
||||
var currentAtBatPitches: [LiveFeedPlayEvent] {
|
||||
currentPlay?.pitches ?? []
|
||||
}
|
||||
|
||||
var allGameHits: [(play: LiveFeedPlay, hitData: LiveFeedHitData)] {
|
||||
liveData.plays.allPlays.compactMap { play in
|
||||
guard let lastEvent = play.playEvents?.last,
|
||||
let hitData = lastEvent.hitData,
|
||||
hitData.coordinates?.coordX != nil else { return nil }
|
||||
return (play: play, hitData: hitData)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct LiveFeedMetaData: Codable, Sendable {
|
||||
@@ -890,7 +907,7 @@ struct LiveFeedVenue: Codable, Sendable {
|
||||
|
||||
struct LiveFeedWeather: Codable, Sendable {
|
||||
let condition: String?
|
||||
let temp: Int?
|
||||
let temp: String?
|
||||
let wind: String?
|
||||
}
|
||||
|
||||
@@ -911,6 +928,11 @@ struct LiveFeedPlay: Codable, Sendable, Identifiable {
|
||||
let about: LiveFeedPlayAbout?
|
||||
let count: LiveFeedPlayCount?
|
||||
let matchup: LiveFeedPlayMatchup?
|
||||
let playEvents: [LiveFeedPlayEvent]?
|
||||
|
||||
var pitches: [LiveFeedPlayEvent] {
|
||||
playEvents?.filter { $0.isPitch == true } ?? []
|
||||
}
|
||||
|
||||
var id: String {
|
||||
let inning = about?.inning ?? -1
|
||||
@@ -958,6 +980,11 @@ struct LiveFeedPlayerReference: Codable, Sendable, Identifiable {
|
||||
var displayName: String {
|
||||
fullName ?? "TBD"
|
||||
}
|
||||
|
||||
var headshotURL: URL? {
|
||||
guard let id else { return nil }
|
||||
return URL(string: "https://img.mlbstatic.com/mlb-photos/image/upload/w_213,q_auto:best/v1/people/\(id)/headshot/67/current")
|
||||
}
|
||||
}
|
||||
|
||||
struct LiveFeedLinescore: Codable, Sendable {
|
||||
@@ -1054,3 +1081,87 @@ struct LiveFeedDecisions: Codable, Sendable {
|
||||
let loser: LiveFeedPlayerReference?
|
||||
let save: LiveFeedPlayerReference?
|
||||
}
|
||||
|
||||
// MARK: - Play Event / Pitch Data Models
|
||||
|
||||
struct LiveFeedPlayEvent: Codable, Sendable, Identifiable {
|
||||
let details: LiveFeedPlayEventDetails?
|
||||
let count: LiveFeedPlayCount?
|
||||
let pitchData: LiveFeedPitchData?
|
||||
let hitData: LiveFeedHitData?
|
||||
let isPitch: Bool?
|
||||
let pitchNumber: Int?
|
||||
let index: Int?
|
||||
|
||||
var id: Int { index ?? pitchNumber ?? 0 }
|
||||
|
||||
var callCode: String {
|
||||
details?.call?.code ?? ""
|
||||
}
|
||||
|
||||
var callDescription: String {
|
||||
details?.call?.description ?? details?.description ?? ""
|
||||
}
|
||||
|
||||
var pitchTypeDescription: String {
|
||||
details?.type?.description ?? "Unknown"
|
||||
}
|
||||
|
||||
var pitchTypeCode: String {
|
||||
details?.type?.code ?? ""
|
||||
}
|
||||
|
||||
var speedMPH: Double? {
|
||||
pitchData?.startSpeed
|
||||
}
|
||||
}
|
||||
|
||||
struct LiveFeedPlayEventDetails: Codable, Sendable {
|
||||
let call: LiveFeedCallInfo?
|
||||
let description: String?
|
||||
let type: LiveFeedPitchType?
|
||||
}
|
||||
|
||||
struct LiveFeedCallInfo: Codable, Sendable {
|
||||
let code: String?
|
||||
let description: String?
|
||||
}
|
||||
|
||||
struct LiveFeedPitchType: Codable, Sendable {
|
||||
let code: String?
|
||||
let description: String?
|
||||
}
|
||||
|
||||
struct LiveFeedPitchData: Codable, Sendable {
|
||||
let startSpeed: Double?
|
||||
let endSpeed: Double?
|
||||
let strikeZoneTop: Double?
|
||||
let strikeZoneBottom: Double?
|
||||
let coordinates: LiveFeedPitchCoordinates?
|
||||
let zone: Int?
|
||||
}
|
||||
|
||||
struct LiveFeedPitchCoordinates: Codable, Sendable {
|
||||
let pX: Double?
|
||||
let pZ: Double?
|
||||
}
|
||||
|
||||
struct LiveFeedHitData: Codable, Sendable {
|
||||
let launchSpeed: Double?
|
||||
let launchAngle: Double?
|
||||
let totalDistance: Double?
|
||||
let coordinates: LiveFeedHitCoordinates?
|
||||
}
|
||||
|
||||
struct LiveFeedHitCoordinates: Codable, Sendable {
|
||||
let coordX: Double?
|
||||
let coordY: Double?
|
||||
}
|
||||
|
||||
// MARK: - Win Probability
|
||||
|
||||
struct WinProbabilityEntry: Codable, Sendable {
|
||||
let atBatIndex: Int?
|
||||
let homeTeamWinProbability: Double?
|
||||
let awayTeamWinProbability: Double?
|
||||
}
|
||||
|
||||
78
mlbTVOS/Services/VideoShuffle.swift
Normal file
78
mlbTVOS/Services/VideoShuffle.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -1,24 +1,41 @@
|
||||
import Foundation
|
||||
import Observation
|
||||
import OSLog
|
||||
|
||||
private let gameCenterLogger = Logger(subsystem: "com.treyt.mlbTVOS", category: "GameCenter")
|
||||
|
||||
private func logGameCenter(_ message: String) {
|
||||
gameCenterLogger.debug("\(message, privacy: .public)")
|
||||
print("[GameCenter] \(message)")
|
||||
}
|
||||
|
||||
@Observable
|
||||
@MainActor
|
||||
final class GameCenterViewModel {
|
||||
var feed: LiveGameFeed?
|
||||
var highlights: [Highlight] = []
|
||||
var winProbabilityHome: Double?
|
||||
var winProbabilityAway: Double?
|
||||
var isLoading = false
|
||||
var errorMessage: String?
|
||||
var lastUpdated: Date?
|
||||
|
||||
private let statsAPI = MLBStatsAPI()
|
||||
@ObservationIgnored
|
||||
private var lastHighlightsFetch: Date?
|
||||
|
||||
func watch(game: Game) async {
|
||||
guard let gamePk = game.gamePk else {
|
||||
logGameCenter("watch: no gamePk for game id=\(game.id)")
|
||||
errorMessage = "No live game feed is available for this matchup."
|
||||
return
|
||||
}
|
||||
logGameCenter("watch: starting for gamePk=\(gamePk)")
|
||||
|
||||
while !Task.isCancelled {
|
||||
await refresh(gamePk: gamePk)
|
||||
await refreshHighlightsIfNeeded(gamePk: gamePk, gameDate: game.gameDate)
|
||||
await refreshWinProbability(gamePk: gamePk)
|
||||
|
||||
let liveState = feed?.gameData.status?.abstractGameState == "Live"
|
||||
if !liveState {
|
||||
@@ -34,12 +51,44 @@ final class GameCenterViewModel {
|
||||
errorMessage = nil
|
||||
|
||||
do {
|
||||
logGameCenter("refresh: fetching feed for gamePk=\(gamePk)")
|
||||
feed = try await statsAPI.fetchGameFeed(gamePk: gamePk)
|
||||
logGameCenter("refresh: success playEvents=\(feed?.currentPlay?.playEvents?.count ?? 0) allPlays=\(feed?.liveData.plays.allPlays.count ?? 0)")
|
||||
lastUpdated = Date()
|
||||
} catch {
|
||||
logGameCenter("refresh: FAILED gamePk=\(gamePk) error=\(error)")
|
||||
errorMessage = "Failed to load game center."
|
||||
}
|
||||
|
||||
isLoading = false
|
||||
}
|
||||
|
||||
private func refreshHighlightsIfNeeded(gamePk: String, gameDate: String) async {
|
||||
// Only fetch highlights every 60 seconds
|
||||
if let last = lastHighlightsFetch, Date().timeIntervalSince(last) < 60 {
|
||||
return
|
||||
}
|
||||
|
||||
let serverAPI = MLBServerAPI()
|
||||
do {
|
||||
highlights = try await serverAPI.fetchHighlights(gamePk: gamePk, gameDate: gameDate)
|
||||
lastHighlightsFetch = Date()
|
||||
} catch {
|
||||
// Highlights are supplementary — don't surface errors
|
||||
}
|
||||
}
|
||||
|
||||
private func refreshWinProbability(gamePk: String) async {
|
||||
do {
|
||||
let entries = try await statsAPI.fetchWinProbability(gamePk: gamePk)
|
||||
if let latest = entries.last,
|
||||
let home = latest.homeTeamWinProbability,
|
||||
let away = latest.awayTeamWinProbability {
|
||||
winProbabilityHome = home
|
||||
winProbabilityAway = away
|
||||
}
|
||||
} catch {
|
||||
// Win probability is supplementary — don't surface errors
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -55,7 +55,7 @@ final class GamesViewModel {
|
||||
@ObservationIgnored
|
||||
private var authenticatedVideoFeedCache: [String: AuthenticatedVideoFeedCacheEntry] = [:]
|
||||
@ObservationIgnored
|
||||
private var videoShuffleBags: [String: [URL]] = [:]
|
||||
private var videoShuffleBagsByModel: [String: [String: [URL]]] = [:]
|
||||
@ObservationIgnored
|
||||
private var cachedStandings: (date: String, standings: [Int: TeamStanding])?
|
||||
|
||||
@@ -536,12 +536,15 @@ final class GamesViewModel {
|
||||
|
||||
func attachPlayer(_ player: AVPlayer, to streamID: String) {
|
||||
guard let index = activeStreams.firstIndex(where: { $0.id == streamID }) else { return }
|
||||
let alreadyAttached = activeStreams[index].player === player
|
||||
activeStreams[index].player = player
|
||||
activeStreams[index].isPlaying = true
|
||||
if !alreadyAttached {
|
||||
let shouldMute = shouldMuteAudio(for: activeStreams[index])
|
||||
activeStreams[index].isMuted = shouldMute
|
||||
player.isMuted = shouldMute
|
||||
}
|
||||
}
|
||||
|
||||
func updateStreamOverrideSource(id: String, url: URL, headers: [String: String] = [:]) {
|
||||
guard let index = activeStreams.firstIndex(where: { $0.id == id }) else { return }
|
||||
@@ -614,30 +617,79 @@ final class GamesViewModel {
|
||||
excluding excludedURL: URL? = nil
|
||||
) async -> URL? {
|
||||
guard let urls = await fetchAuthenticatedVideoFeedURLs(feedURL: feedURL, headers: headers) else {
|
||||
logGamesViewModel("resolveAuthenticatedVideoFeedURL FAILED reason=fetchReturned-nil")
|
||||
return nil
|
||||
}
|
||||
|
||||
let cacheKey = authenticatedVideoFeedCacheKey(feedURL: feedURL, headers: headers)
|
||||
var bag = videoShuffleBags[cacheKey] ?? []
|
||||
var buckets = videoShuffleBagsByModel[cacheKey] ?? [:]
|
||||
|
||||
// Refill the bag when empty (or first time), shuffled
|
||||
if bag.isEmpty {
|
||||
bag = urls.shuffled()
|
||||
logGamesViewModel("resolveAuthenticatedVideoFeedURL reshuffled bag count=\(bag.count)")
|
||||
}
|
||||
|
||||
// If the next video is the one we just played, push it to the back
|
||||
if let excludedURL, bag.count > 1, bag.first == excludedURL {
|
||||
bag.append(bag.removeFirst())
|
||||
}
|
||||
|
||||
let selectedURL = bag.removeFirst()
|
||||
videoShuffleBags[cacheKey] = bag
|
||||
|
||||
logGamesViewModel(
|
||||
"resolveAuthenticatedVideoFeedURL success resolvedURL=\(gamesViewModelDebugURLDescription(selectedURL)) remaining=\(bag.count) excludedURL=\(excludedURL.map(gamesViewModelDebugURLDescription) ?? "nil")"
|
||||
// Refill ANY model bucket that is empty — this keeps all models in
|
||||
// rotation so small models cycle through their videos while large
|
||||
// models continue drawing from unplayed ones.
|
||||
var refilledKeys: [String] = []
|
||||
if buckets.isEmpty {
|
||||
// First access: build every bucket from scratch.
|
||||
var rng = SystemRandomNumberGenerator()
|
||||
buckets = VideoShuffle.groupByModel(
|
||||
urls,
|
||||
keyFor: Self.modelKey(from:),
|
||||
using: &rng
|
||||
)
|
||||
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(
|
||||
|
||||
80
mlbTVOS/Views/Components/AtBatTimelineView.swift
Normal file
80
mlbTVOS/Views/Components/AtBatTimelineView.swift
Normal 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
|
||||
}
|
||||
}
|
||||
68
mlbTVOS/Views/Components/PitchSequenceView.swift
Normal file
68
mlbTVOS/Views/Components/PitchSequenceView.swift
Normal 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())
|
||||
}
|
||||
}
|
||||
126
mlbTVOS/Views/Components/SprayChartView.swift
Normal file
126
mlbTVOS/Views/Components/SprayChartView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
104
mlbTVOS/Views/Components/StrikeZoneView.swift
Normal file
104
mlbTVOS/Views/Components/StrikeZoneView.swift
Normal 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)
|
||||
}
|
||||
}
|
||||
@@ -12,8 +12,8 @@ enum SpecialPlaybackChannelConfig {
|
||||
static let werkoutNSFWStreamID = "WKNSFW"
|
||||
static let werkoutNSFWTitle = "Werkout NSFW"
|
||||
static let werkoutNSFWSubtitle = "Authenticated OF media feed"
|
||||
static let werkoutNSFWFeedURLString = "https://ofapp.treytartt.com/api/media?users=tabatachang,werkout&type=video"
|
||||
static let werkoutNSFWCookie = "ofapp_token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjEsImlhdCI6MTc3NDY0MjEyNiwiZXhwIjoxNzc1MjQ2OTI2fQ.rDvZzLQ70MM9drHwA8hRxmqcTTgBGBHXxv1Cc55HSqc"
|
||||
static let werkoutNSFWFeedURLString = "https://ofapp.treytartt.com/api/media?users=tabatachang,werkout,kayla-lauren,ray-mattos,josie-hamming-2,dani-speegle-2&type=video"
|
||||
static let werkoutNSFWCookie = "ofapp_token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjEsImlhdCI6MTc3NDY0MjEyNiwiZXhwIjoxNzc3MjM0MTI2fQ.RdYGvyBPbR6tmK0kaEVhSnIVW6TZlm3Ef42Lxpvl_oQ"
|
||||
static let werkoutNSFWTeamCode = "WK"
|
||||
|
||||
static var werkoutNSFWHeaders: [String: String] {
|
||||
@@ -56,6 +56,7 @@ struct DashboardView: View {
|
||||
@State private var pendingFullScreenBroadcast: BroadcastSelection?
|
||||
@State private var showMLBNetworkSheet = false
|
||||
@State private var showWerkoutNSFWSheet = false
|
||||
@State private var isPiPActive = false
|
||||
|
||||
private var horizontalPadding: CGFloat {
|
||||
#if os(iOS)
|
||||
@@ -218,7 +219,11 @@ struct DashboardView: View {
|
||||
await resolveFullScreenSource(for: selection)
|
||||
},
|
||||
resolveNextSource: nextFullScreenSourceResolver(for: selection),
|
||||
tickerGames: tickerGames(for: selection)
|
||||
tickerGames: tickerGames(for: selection),
|
||||
game: selection.game,
|
||||
onPiPActiveChanged: { active in
|
||||
isPiPActive = active
|
||||
}
|
||||
)
|
||||
.ignoresSafeArea()
|
||||
.onAppear {
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
import SwiftUI
|
||||
import AVKit
|
||||
|
||||
struct GameCenterView: View {
|
||||
let game: Game
|
||||
|
||||
@State private var viewModel = GameCenterViewModel()
|
||||
@State private var highlightPlayer: AVPlayer?
|
||||
|
||||
var body: some View {
|
||||
VStack(alignment: .leading, spacing: 18) {
|
||||
@@ -15,6 +17,14 @@ struct GameCenterView: View {
|
||||
situationStrip(feed: feed)
|
||||
matchupPanel(feed: feed)
|
||||
|
||||
if !feed.currentAtBatPitches.isEmpty {
|
||||
atBatPanel(feed: feed)
|
||||
}
|
||||
|
||||
if let wpHome = viewModel.winProbabilityHome, let wpAway = viewModel.winProbabilityAway {
|
||||
winProbabilityPanel(home: wpHome, away: wpAway)
|
||||
}
|
||||
|
||||
if !feed.decisionsSummary.isEmpty || !feed.officialSummary.isEmpty || feed.weatherSummary != nil {
|
||||
contextPanel(feed: feed)
|
||||
}
|
||||
@@ -23,6 +33,14 @@ struct GameCenterView: View {
|
||||
lineupPanel(feed: feed)
|
||||
}
|
||||
|
||||
if !viewModel.highlights.isEmpty {
|
||||
highlightsPanel
|
||||
}
|
||||
|
||||
if !feed.allGameHits.isEmpty {
|
||||
sprayChartPanel(feed: feed)
|
||||
}
|
||||
|
||||
if !feed.scoringPlays.isEmpty {
|
||||
timelineSection(
|
||||
title: "Scoring Plays",
|
||||
@@ -43,6 +61,16 @@ struct GameCenterView: View {
|
||||
.task(id: game.id) {
|
||||
await viewModel.watch(game: game)
|
||||
}
|
||||
.fullScreenCover(isPresented: Binding(
|
||||
get: { highlightPlayer != nil },
|
||||
set: { if !$0 { highlightPlayer?.pause(); highlightPlayer = nil } }
|
||||
)) {
|
||||
if let player = highlightPlayer {
|
||||
VideoPlayer(player: player)
|
||||
.ignoresSafeArea()
|
||||
.onAppear { player.play() }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private var header: some View {
|
||||
@@ -126,6 +154,13 @@ struct GameCenterView: View {
|
||||
private func matchupPanel(feed: LiveGameFeed) -> some View {
|
||||
VStack(alignment: .leading, spacing: 16) {
|
||||
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) {
|
||||
Text("At Bat")
|
||||
.font(.system(size: 13, weight: .bold, design: .rounded))
|
||||
@@ -147,9 +182,11 @@ struct GameCenterView: View {
|
||||
.foregroundStyle(.white.opacity(0.46))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
HStack(spacing: 14) {
|
||||
VStack(alignment: .trailing, spacing: 5) {
|
||||
Text("Pitching")
|
||||
.font(.system(size: 13, weight: .bold, design: .rounded))
|
||||
@@ -168,6 +205,180 @@ struct GameCenterView: View {
|
||||
.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)
|
||||
@@ -256,6 +467,11 @@ struct GameCenterView: View {
|
||||
.foregroundStyle(.white.opacity(0.6))
|
||||
.frame(width: 18, alignment: .leading)
|
||||
|
||||
PitcherHeadshotView(
|
||||
url: player.person.headshotURL,
|
||||
size: 28
|
||||
)
|
||||
|
||||
Text(player.person.displayName)
|
||||
.font(.system(size: 15, weight: .semibold))
|
||||
.foregroundStyle(.white.opacity(0.88))
|
||||
|
||||
@@ -77,6 +77,8 @@ struct StreamOptionsSheet: View {
|
||||
.padding(.vertical, 18)
|
||||
.background(panelBackground)
|
||||
}
|
||||
|
||||
GameCenterView(game: game)
|
||||
}
|
||||
.padding(.horizontal, horizontalPadding)
|
||||
.padding(.vertical, verticalPadding)
|
||||
@@ -129,8 +131,6 @@ struct StreamOptionsSheet: View {
|
||||
.padding(22)
|
||||
.background(panelBackground)
|
||||
}
|
||||
|
||||
GameCenterView(game: game)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -353,8 +353,13 @@ private struct MultiStreamTile: View {
|
||||
@State private var hasError = false
|
||||
@State private var startupPlaybackTask: 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()
|
||||
|
||||
private static let maxClipDuration: Double = 15.0
|
||||
private static var audioSessionConfigured = false
|
||||
|
||||
var body: some View {
|
||||
ZStack {
|
||||
videoLayer
|
||||
@@ -442,6 +447,7 @@ private struct MultiStreamTile: View {
|
||||
startupPlaybackTask = nil
|
||||
qualityUpgradeTask?.cancel()
|
||||
qualityUpgradeTask = nil
|
||||
if let player { removeClipTimeLimit(from: player) }
|
||||
playbackDiagnostics.clear(streamID: stream.id, reason: "tile disappeared")
|
||||
}
|
||||
#if os(tvOS)
|
||||
@@ -536,7 +542,6 @@ private struct MultiStreamTile: View {
|
||||
)
|
||||
|
||||
if let player {
|
||||
player.isMuted = stream.forceMuteAudio || viewModel.audioFocusStreamID != stream.id
|
||||
playbackDiagnostics.attach(
|
||||
to: player,
|
||||
streamID: stream.id,
|
||||
@@ -545,20 +550,23 @@ private struct MultiStreamTile: View {
|
||||
)
|
||||
scheduleStartupPlaybackRecovery(for: player)
|
||||
scheduleQualityUpgrade(for: player)
|
||||
installClipTimeLimit(on: player)
|
||||
logMultiView("startStream reused inline player id=\(stream.id) muted=\(player.isMuted)")
|
||||
return
|
||||
}
|
||||
|
||||
if !Self.audioSessionConfigured {
|
||||
do {
|
||||
try AVAudioSession.sharedInstance().setCategory(.playback)
|
||||
try AVAudioSession.sharedInstance().setActive(true)
|
||||
Self.audioSessionConfigured = true
|
||||
logMultiView("startStream audio session configured id=\(stream.id)")
|
||||
} catch {
|
||||
logMultiView("startStream audio session failed id=\(stream.id) error=\(error.localizedDescription)")
|
||||
}
|
||||
}
|
||||
|
||||
if let existingPlayer = stream.player {
|
||||
existingPlayer.isMuted = stream.forceMuteAudio || viewModel.audioFocusStreamID != stream.id
|
||||
self.player = existingPlayer
|
||||
hasError = false
|
||||
playbackDiagnostics.attach(
|
||||
@@ -569,6 +577,7 @@ private struct MultiStreamTile: View {
|
||||
)
|
||||
scheduleStartupPlaybackRecovery(for: existingPlayer)
|
||||
scheduleQualityUpgrade(for: existingPlayer)
|
||||
installClipTimeLimit(on: existingPlayer)
|
||||
logMultiView("startStream reused shared player id=\(stream.id) muted=\(existingPlayer.isMuted)")
|
||||
return
|
||||
}
|
||||
@@ -606,6 +615,7 @@ private struct MultiStreamTile: View {
|
||||
scheduleQualityUpgrade(for: avPlayer)
|
||||
logMultiView("startStream attached player id=\(stream.id) muted=\(avPlayer.isMuted) startupResolution=\(multiViewStartupResolution) fastStart=true calling playImmediately(atRate: 1.0)")
|
||||
avPlayer.playImmediately(atRate: 1.0)
|
||||
installClipTimeLimit(on: avPlayer)
|
||||
}
|
||||
|
||||
private func makePlayer(url: URL, headers: [String: String]?) -> AVPlayer {
|
||||
@@ -754,28 +764,73 @@ private struct MultiStreamTile: View {
|
||||
.value
|
||||
}
|
||||
|
||||
private func installClipTimeLimit(on player: AVPlayer) {
|
||||
removeClipTimeLimit(from: player)
|
||||
guard stream.id == SpecialPlaybackChannelConfig.werkoutNSFWStreamID else { return }
|
||||
let limit = CMTime(seconds: Self.maxClipDuration, preferredTimescale: 600)
|
||||
logMultiView("installClipTimeLimit id=\(stream.id) limit=\(Self.maxClipDuration)s")
|
||||
clipTimeLimitObserver = player.addBoundaryTimeObserver(
|
||||
forTimes: [NSValue(time: limit)],
|
||||
queue: .main
|
||||
) { [weak player] in
|
||||
guard let player else {
|
||||
logMultiView("clipTimeLimit STOPPED id=\(stream.id) reason=player-deallocated")
|
||||
return
|
||||
}
|
||||
let currentTime = CMTimeGetSeconds(player.currentTime())
|
||||
logMultiView("clipTimeLimit fired id=\(stream.id) currentTime=\(String(format: "%.1f", currentTime))s rate=\(player.rate) — advancing")
|
||||
Task { @MainActor in
|
||||
await playNextWerkoutClip(on: player)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func removeClipTimeLimit(from player: AVPlayer) {
|
||||
if let observer = clipTimeLimitObserver {
|
||||
player.removeTimeObserver(observer)
|
||||
clipTimeLimitObserver = nil
|
||||
}
|
||||
}
|
||||
|
||||
private func playbackEndedHandler(for player: AVPlayer) -> (@MainActor @Sendable () async -> Void)? {
|
||||
guard stream.id == SpecialPlaybackChannelConfig.werkoutNSFWStreamID else { return nil }
|
||||
return {
|
||||
let currentTime = CMTimeGetSeconds(player.currentTime())
|
||||
logMultiView("playbackEnded (didPlayToEnd) id=\(stream.id) currentTime=\(String(format: "%.1f", currentTime))s rate=\(player.rate)")
|
||||
await playNextWerkoutClip(on: player)
|
||||
}
|
||||
}
|
||||
|
||||
private func playNextWerkoutClip(on player: AVPlayer) async {
|
||||
guard !isAdvancingClip else {
|
||||
logMultiView("playNextWerkoutClip SKIPPED id=\(stream.id) reason=already-advancing")
|
||||
return
|
||||
}
|
||||
isAdvancingClip = true
|
||||
defer { isAdvancingClip = false }
|
||||
|
||||
let currentURL = currentStreamURL(for: player)
|
||||
let playerRate = player.rate
|
||||
let playerStatus = player.status.rawValue
|
||||
let itemStatus = player.currentItem?.status.rawValue ?? -1
|
||||
let timeControl = player.timeControlStatus.rawValue
|
||||
logMultiView(
|
||||
"playNextWerkoutClip begin id=\(stream.id) currentURL=\(currentURL?.absoluteString ?? "nil")"
|
||||
"playNextWerkoutClip begin id=\(stream.id) currentURL=\(currentURL?.absoluteString ?? "nil") playerRate=\(playerRate) playerStatus=\(playerStatus) itemStatus=\(itemStatus) timeControl=\(timeControl)"
|
||||
)
|
||||
|
||||
let resolveStart = Date()
|
||||
guard let nextURL = await viewModel.resolveNextAuthenticatedFeedURLForActiveStream(
|
||||
id: stream.id,
|
||||
feedURL: SpecialPlaybackChannelConfig.werkoutNSFWFeedURL,
|
||||
headers: SpecialPlaybackChannelConfig.werkoutNSFWHeaders,
|
||||
maxRetries: 3
|
||||
) else {
|
||||
logMultiView("playNextWerkoutClip failed id=\(stream.id) reason=resolve-nil-after-retries")
|
||||
let elapsedMs = Int(Date().timeIntervalSince(resolveStart) * 1000)
|
||||
logMultiView("playNextWerkoutClip STOPPED id=\(stream.id) reason=resolve-nil-after-retries elapsedMs=\(elapsedMs)")
|
||||
return
|
||||
}
|
||||
let resolveMs = Int(Date().timeIntervalSince(resolveStart) * 1000)
|
||||
logMultiView("playNextWerkoutClip resolved id=\(stream.id) resolveMs=\(resolveMs) nextURL=\(nextURL.lastPathComponent)")
|
||||
|
||||
let nextItem = makePlayerItem(
|
||||
url: nextURL,
|
||||
@@ -790,10 +845,27 @@ private struct MultiStreamTile: View {
|
||||
label: stream.label,
|
||||
onPlaybackEnded: playbackEndedHandler(for: player)
|
||||
)
|
||||
viewModel.attachPlayer(player, to: stream.id)
|
||||
scheduleStartupPlaybackRecovery(for: player)
|
||||
logMultiView("playNextWerkoutClip replay id=\(stream.id) url=\(nextURL.absoluteString)")
|
||||
logMultiView("playNextWerkoutClip replay id=\(stream.id) url=\(nextURL.lastPathComponent)")
|
||||
player.playImmediately(atRate: 1.0)
|
||||
installClipTimeLimit(on: player)
|
||||
|
||||
// Monitor for failure and auto-skip to next clip
|
||||
Task { @MainActor in
|
||||
for checkDelay in [1.0, 3.0] {
|
||||
try? await Task.sleep(for: .seconds(checkDelay))
|
||||
let postItemStatus = player.currentItem?.status
|
||||
let error = player.currentItem?.error?.localizedDescription ?? "nil"
|
||||
logMultiView(
|
||||
"playNextWerkoutClip postCheck id=\(stream.id) delay=\(checkDelay)s rate=\(player.rate) itemStatus=\(postItemStatus?.rawValue ?? -1) error=\(error)"
|
||||
)
|
||||
if postItemStatus == .failed {
|
||||
logMultiView("playNextWerkoutClip AUTO-SKIP id=\(stream.id) reason=item-failed error=\(error)")
|
||||
await playNextWerkoutClip(on: player)
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -80,28 +80,104 @@ struct SingleStreamPlaybackScreen: View {
|
||||
let resolveSource: @Sendable () async -> SingleStreamPlaybackSource?
|
||||
var resolveNextSource: (@Sendable (URL?) async -> SingleStreamPlaybackSource?)? = nil
|
||||
let tickerGames: [Game]
|
||||
var game: Game? = nil
|
||||
var onPiPActiveChanged: ((Bool) -> Void)? = nil
|
||||
|
||||
@State private var showGameCenter = false
|
||||
@State private var showPitchInfo = false
|
||||
@State private var pitchViewModel = GameCenterViewModel()
|
||||
@State private var isPiPActive = false
|
||||
|
||||
var body: some View {
|
||||
ZStack(alignment: .bottom) {
|
||||
SingleStreamPlayerView(resolveSource: resolveSource, resolveNextSource: resolveNextSource)
|
||||
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()
|
||||
|
||||
if !showGameCenter && !showPitchInfo {
|
||||
SingleStreamScoreStripView(games: tickerGames)
|
||||
.allowsHitTesting(false)
|
||||
.padding(.horizontal, 18)
|
||||
.padding(.bottom, 14)
|
||||
.transition(.opacity)
|
||||
}
|
||||
|
||||
if showGameCenter, let game {
|
||||
gameCenterOverlay(game: game)
|
||||
.transition(.move(edge: .bottom).combined(with: .opacity))
|
||||
}
|
||||
}
|
||||
.animation(.easeInOut(duration: 0.3), value: showGameCenter)
|
||||
.animation(.easeInOut(duration: 0.3), value: showPitchInfo)
|
||||
.overlay(alignment: .bottomLeading) {
|
||||
if showPitchInfo, let feed = pitchViewModel.feed {
|
||||
pitchInfoBox(feed: feed)
|
||||
.transition(.move(edge: .leading).combined(with: .opacity))
|
||||
}
|
||||
}
|
||||
.overlay(alignment: .topTrailing) {
|
||||
#if os(iOS)
|
||||
.overlay(alignment: .topTrailing) {
|
||||
HStack(spacing: 12) {
|
||||
if game?.gamePk != nil {
|
||||
Button {
|
||||
showPitchInfo.toggle()
|
||||
if showPitchInfo { showGameCenter = false }
|
||||
} label: {
|
||||
Image(systemName: showPitchInfo ? "xmark.circle.fill" : "baseball.fill")
|
||||
.font(.system(size: 28, weight: .bold))
|
||||
.foregroundStyle(.white.opacity(0.9))
|
||||
.padding(6)
|
||||
.background(.black.opacity(0.5))
|
||||
.clipShape(Circle())
|
||||
}
|
||||
|
||||
Button {
|
||||
showGameCenter.toggle()
|
||||
if showGameCenter { showPitchInfo = false }
|
||||
} label: {
|
||||
Image(systemName: showGameCenter ? "xmark.circle.fill" : "chart.bar.fill")
|
||||
.font(.system(size: 28, weight: .bold))
|
||||
.foregroundStyle(.white.opacity(0.9))
|
||||
.padding(6)
|
||||
.background(.black.opacity(0.5))
|
||||
.clipShape(Circle())
|
||||
}
|
||||
|
||||
Button {
|
||||
dismiss()
|
||||
} label: {
|
||||
Image(systemName: "xmark.circle.fill")
|
||||
.font(.system(size: 28, weight: .bold))
|
||||
.foregroundStyle(.white.opacity(0.9))
|
||||
}
|
||||
}
|
||||
}
|
||||
.padding(20)
|
||||
}
|
||||
#endif
|
||||
.task(id: game?.gamePk) {
|
||||
guard let gamePk = game?.gamePk else { return }
|
||||
while !Task.isCancelled {
|
||||
await pitchViewModel.refresh(gamePk: gamePk)
|
||||
try? await Task.sleep(for: .seconds(5))
|
||||
}
|
||||
}
|
||||
.ignoresSafeArea()
|
||||
.onAppear {
|
||||
@@ -111,6 +187,135 @@ struct SingleStreamPlaybackScreen: View {
|
||||
logSingleStream("SingleStreamPlaybackScreen disappeared")
|
||||
}
|
||||
}
|
||||
|
||||
private func gameCenterOverlay(game: Game) -> some View {
|
||||
ScrollView {
|
||||
GameCenterView(game: game)
|
||||
.padding(.horizontal, 20)
|
||||
.padding(.top, 60)
|
||||
.padding(.bottom, 40)
|
||||
}
|
||||
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
||||
.background(.black.opacity(0.82))
|
||||
}
|
||||
|
||||
private func pitchInfoBox(feed: LiveGameFeed) -> some View {
|
||||
let pitches = feed.currentAtBatPitches
|
||||
let batter = feed.currentBatter?.displayName ?? "—"
|
||||
let pitcher = feed.currentPitcher?.displayName ?? "—"
|
||||
let countText = feed.currentCountText ?? ""
|
||||
|
||||
return VStack(alignment: .leading, spacing: 8) {
|
||||
// Matchup header — use last name only to save space
|
||||
HStack(spacing: 8) {
|
||||
VStack(alignment: .leading, spacing: 2) {
|
||||
Text("AB")
|
||||
.font(.system(size: 10, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(.white.opacity(0.4))
|
||||
Text(batter)
|
||||
.font(.system(size: 14, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(.white)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.7)
|
||||
}
|
||||
|
||||
Spacer()
|
||||
|
||||
VStack(alignment: .trailing, spacing: 2) {
|
||||
Text("P")
|
||||
.font(.system(size: 10, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(.white.opacity(0.4))
|
||||
Text(pitcher)
|
||||
.font(.system(size: 14, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(.white)
|
||||
.lineLimit(1)
|
||||
.minimumScaleFactor(0.7)
|
||||
}
|
||||
}
|
||||
|
||||
if !countText.isEmpty {
|
||||
Text(countText)
|
||||
.font(.system(size: 13, weight: .semibold, design: .rounded))
|
||||
.foregroundStyle(.white.opacity(0.7))
|
||||
}
|
||||
|
||||
if !pitches.isEmpty {
|
||||
// Latest pitch — bold and prominent
|
||||
if let last = pitches.last {
|
||||
let color = pitchCallColor(last.callCode)
|
||||
HStack(spacing: 6) {
|
||||
if let speed = last.speedMPH {
|
||||
Text("\(speed, specifier: "%.1f")")
|
||||
.font(.system(size: 24, weight: .black).monospacedDigit())
|
||||
.foregroundStyle(.white)
|
||||
Text("mph")
|
||||
.font(.system(size: 12, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(.white.opacity(0.5))
|
||||
}
|
||||
Spacer()
|
||||
VStack(alignment: .trailing, spacing: 2) {
|
||||
Text(last.pitchTypeDescription)
|
||||
.font(.system(size: 14, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(.white)
|
||||
.lineLimit(1)
|
||||
Text(last.callDescription)
|
||||
.font(.system(size: 12, weight: .bold, design: .rounded))
|
||||
.foregroundStyle(color)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Strike zone + previous pitches side by side
|
||||
HStack(alignment: .top, spacing: 14) {
|
||||
StrikeZoneView(pitches: pitches, size: 120)
|
||||
|
||||
// Previous pitches — compact rows
|
||||
if pitches.count > 1 {
|
||||
VStack(alignment: .leading, spacing: 3) {
|
||||
ForEach(Array(pitches.dropLast().reversed().prefix(8).enumerated()), id: \.offset) { _, pitch in
|
||||
let color = pitchCallColor(pitch.callCode)
|
||||
HStack(spacing: 4) {
|
||||
Text("\(pitch.pitchNumber ?? 0)")
|
||||
.font(.system(size: 10, weight: .bold).monospacedDigit())
|
||||
.foregroundStyle(.white.opacity(0.35))
|
||||
.frame(width: 14, alignment: .trailing)
|
||||
Text(shortPitchType(pitch.pitchTypeCode))
|
||||
.font(.system(size: 11, weight: .semibold, design: .rounded))
|
||||
.foregroundStyle(.white.opacity(0.7))
|
||||
if let speed = pitch.speedMPH {
|
||||
Text("\(speed, specifier: "%.0f")")
|
||||
.font(.system(size: 11, weight: .bold).monospacedDigit())
|
||||
.foregroundStyle(.white.opacity(0.45))
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
Circle()
|
||||
.fill(color)
|
||||
.frame(width: 6, height: 6)
|
||||
}
|
||||
}
|
||||
}
|
||||
.frame(maxWidth: .infinity, alignment: .leading)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Text("Waiting for pitch data...")
|
||||
.font(.system(size: 12, weight: .medium))
|
||||
.foregroundStyle(.white.opacity(0.5))
|
||||
}
|
||||
}
|
||||
.frame(width: 300)
|
||||
.padding(16)
|
||||
.background(
|
||||
RoundedRectangle(cornerRadius: 18, style: .continuous)
|
||||
.fill(.black.opacity(0.78))
|
||||
.overlay {
|
||||
RoundedRectangle(cornerRadius: 18, style: .continuous)
|
||||
.strokeBorder(.white.opacity(0.1), lineWidth: 1)
|
||||
}
|
||||
)
|
||||
.padding(.leading, 24)
|
||||
.padding(.bottom, 50)
|
||||
}
|
||||
}
|
||||
|
||||
struct SingleStreamPlaybackSource: Sendable {
|
||||
@@ -290,6 +495,12 @@ private final class SingleStreamMarqueeContainerView: UIView {
|
||||
struct SingleStreamPlayerView: UIViewControllerRepresentable {
|
||||
let resolveSource: @Sendable () async -> SingleStreamPlaybackSource?
|
||||
var resolveNextSource: (@Sendable (URL?) async -> SingleStreamPlaybackSource?)? = nil
|
||||
var hasGamePk: Bool = false
|
||||
var onTogglePitchInfo: (() -> Void)? = nil
|
||||
var onToggleGameCenter: (() -> Void)? = nil
|
||||
var onPiPStateChanged: ((Bool) -> Void)? = nil
|
||||
var showPitchInfo: Bool = false
|
||||
var showGameCenter: Bool = false
|
||||
|
||||
func makeCoordinator() -> Coordinator {
|
||||
Coordinator()
|
||||
@@ -300,10 +511,24 @@ struct SingleStreamPlayerView: UIViewControllerRepresentable {
|
||||
let controller = AVPlayerViewController()
|
||||
controller.allowsPictureInPicturePlayback = true
|
||||
controller.showsPlaybackControls = true
|
||||
context.coordinator.onPiPStateChanged = onPiPStateChanged
|
||||
controller.delegate = context.coordinator
|
||||
#if os(iOS)
|
||||
controller.canStartPictureInPictureAutomaticallyFromInline = true
|
||||
#endif
|
||||
logSingleStream("AVPlayerViewController configured without contentOverlayView ticker")
|
||||
|
||||
#if os(tvOS)
|
||||
if hasGamePk {
|
||||
context.coordinator.onTogglePitchInfo = onTogglePitchInfo
|
||||
context.coordinator.onToggleGameCenter = onToggleGameCenter
|
||||
controller.transportBarCustomMenuItems = context.coordinator.buildTransportBarItems(
|
||||
showPitchInfo: showPitchInfo,
|
||||
showGameCenter: showGameCenter
|
||||
)
|
||||
}
|
||||
#endif
|
||||
|
||||
logSingleStream("AVPlayerViewController configured")
|
||||
|
||||
Task { @MainActor in
|
||||
let resolveStartedAt = Date()
|
||||
@@ -335,26 +560,102 @@ struct SingleStreamPlayerView: UIViewControllerRepresentable {
|
||||
controller.player = player
|
||||
logSingleStream("AVPlayer assigned to controller; calling playImmediately(atRate: 1.0)")
|
||||
player.playImmediately(atRate: 1.0)
|
||||
context.coordinator.installClipTimeLimit(on: player, resolveNextSource: resolveNextSource)
|
||||
context.coordinator.scheduleStartupRecovery(for: player)
|
||||
}
|
||||
|
||||
return controller
|
||||
}
|
||||
|
||||
func updateUIViewController(_ uiViewController: AVPlayerViewController, context: Context) {}
|
||||
func updateUIViewController(_ uiViewController: AVPlayerViewController, context: Context) {
|
||||
context.coordinator.onPiPStateChanged = onPiPStateChanged
|
||||
#if os(tvOS)
|
||||
if hasGamePk {
|
||||
context.coordinator.onTogglePitchInfo = onTogglePitchInfo
|
||||
context.coordinator.onToggleGameCenter = onToggleGameCenter
|
||||
uiViewController.transportBarCustomMenuItems = context.coordinator.buildTransportBarItems(
|
||||
showPitchInfo: showPitchInfo,
|
||||
showGameCenter: showGameCenter
|
||||
)
|
||||
}
|
||||
#endif
|
||||
}
|
||||
|
||||
static func dismantleUIViewController(_ uiViewController: AVPlayerViewController, coordinator: Coordinator) {
|
||||
logSingleStream("dismantleUIViewController start")
|
||||
logSingleStream("dismantleUIViewController start isPiPActive=\(coordinator.isPiPActive)")
|
||||
if coordinator.isPiPActive {
|
||||
logSingleStream("dismantleUIViewController skipped — PiP is active")
|
||||
return
|
||||
}
|
||||
coordinator.clearDebugObservers()
|
||||
uiViewController.player?.pause()
|
||||
uiViewController.player = nil
|
||||
logSingleStream("dismantleUIViewController complete")
|
||||
}
|
||||
|
||||
final class Coordinator: NSObject, @unchecked Sendable {
|
||||
final class Coordinator: NSObject, @unchecked Sendable, AVPlayerViewControllerDelegate {
|
||||
private var playerObservations: [NSKeyValueObservation] = []
|
||||
private var notificationTokens: [NSObjectProtocol] = []
|
||||
private var startupRecoveryTask: Task<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(
|
||||
to player: AVPlayer,
|
||||
@@ -456,6 +757,7 @@ struct SingleStreamPlayerView: UIViewControllerRepresentable {
|
||||
self.attachDebugObservers(to: player, url: nextSource.url, resolveNextSource: resolveNextSource)
|
||||
logSingleStream("Autoplay replacing item and replaying url=\(singleStreamDebugURLDescription(nextSource.url))")
|
||||
player.playImmediately(atRate: 1.0)
|
||||
self.installClipTimeLimit(on: player, resolveNextSource: resolveNextSource)
|
||||
self.scheduleStartupRecovery(for: player)
|
||||
}
|
||||
}
|
||||
@@ -522,6 +824,45 @@ struct SingleStreamPlayerView: UIViewControllerRepresentable {
|
||||
}
|
||||
}
|
||||
|
||||
func installClipTimeLimit(
|
||||
on player: AVPlayer,
|
||||
resolveNextSource: (@Sendable (URL?) async -> SingleStreamPlaybackSource?)?
|
||||
) {
|
||||
removeClipTimeLimit(from: player)
|
||||
guard resolveNextSource != nil else { return }
|
||||
let limit = CMTime(seconds: Self.maxClipDuration, preferredTimescale: 600)
|
||||
clipTimeLimitObserver = player.addBoundaryTimeObserver(
|
||||
forTimes: [NSValue(time: limit)],
|
||||
queue: .main
|
||||
) { [weak self, weak player] in
|
||||
guard let self, let player, let resolveNextSource else { return }
|
||||
logSingleStream("clipTimeLimit hit \(Self.maxClipDuration)s — advancing to next clip")
|
||||
Task { @MainActor [weak self] in
|
||||
guard let self else { return }
|
||||
let currentURL = (player.currentItem?.asset as? AVURLAsset)?.url
|
||||
guard let nextSource = await resolveNextSource(currentURL) else {
|
||||
logSingleStream("clipTimeLimit next source nil")
|
||||
return
|
||||
}
|
||||
let nextItem = makeSingleStreamPlayerItem(from: nextSource)
|
||||
player.replaceCurrentItem(with: nextItem)
|
||||
player.automaticallyWaitsToMinimizeStalling = false
|
||||
player.isMuted = nextSource.forceMuteAudio
|
||||
self.attachDebugObservers(to: player, url: nextSource.url, resolveNextSource: resolveNextSource)
|
||||
player.playImmediately(atRate: 1.0)
|
||||
self.installClipTimeLimit(on: player, resolveNextSource: resolveNextSource)
|
||||
self.scheduleStartupRecovery(for: player)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func removeClipTimeLimit(from player: AVPlayer) {
|
||||
if let observer = clipTimeLimitObserver {
|
||||
player.removeTimeObserver(observer)
|
||||
clipTimeLimitObserver = nil
|
||||
}
|
||||
}
|
||||
|
||||
func clearDebugObservers() {
|
||||
startupRecoveryTask?.cancel()
|
||||
startupRecoveryTask = nil
|
||||
|
||||
252
mlbTVOSTests/VideoShuffleTests.swift
Normal file
252
mlbTVOSTests/VideoShuffleTests.swift
Normal 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")
|
||||
}
|
||||
}
|
||||
}
|
||||
11
project.yml
11
project.yml
@@ -23,6 +23,17 @@ targets:
|
||||
CODE_SIGN_ENTITLEMENTS: mlbTVOS/mlbTVOS.entitlements
|
||||
PRODUCT_BUNDLE_IDENTIFIER: com.treyt.mlbTVOS
|
||||
SWIFT_STRICT_CONCURRENCY: complete
|
||||
mlbTVOSTests:
|
||||
type: bundle.unit-test
|
||||
platform: tvOS
|
||||
sources: [mlbTVOSTests]
|
||||
dependencies:
|
||||
- target: mlbTVOS
|
||||
settings:
|
||||
base:
|
||||
PRODUCT_BUNDLE_IDENTIFIER: com.treyt.mlbTVOSTests
|
||||
GENERATE_INFOPLIST_FILE: YES
|
||||
SWIFT_STRICT_CONCURRENCY: complete
|
||||
mlbIOS:
|
||||
type: application
|
||||
platform: iOS
|
||||
|
||||
Reference in New Issue
Block a user