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