Fix video autoplay reliability, add shuffle playback, refresh data on appear

- Remove cache expiry on video feed (fetch once, keep for session)
- Add retry logic (3 attempts with backoff) for autoplay resolution
- Replace random video selection with shuffle-bag (no repeats until all played)
- Reload games every time DashboardView appears
- Cache standings per day to avoid redundant fetches

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Trey t
2026-03-30 21:49:54 -05:00
parent fda809fd2f
commit 58e4c36963
3 changed files with 64 additions and 30 deletions

View File

@@ -54,6 +54,10 @@ final class GamesViewModel {
private var refreshTask: Task<Void, Never>? private var refreshTask: Task<Void, Never>?
@ObservationIgnored @ObservationIgnored
private var authenticatedVideoFeedCache: [String: AuthenticatedVideoFeedCacheEntry] = [:] private var authenticatedVideoFeedCache: [String: AuthenticatedVideoFeedCacheEntry] = [:]
@ObservationIgnored
private var videoShuffleBags: [String: [URL]] = [:]
@ObservationIgnored
private var cachedStandings: (date: String, standings: [Int: TeamStanding])?
// Computed properties for dashboard // Computed properties for dashboard
var liveGames: [Game] { games.filter(\.isLive) } var liveGames: [Game] { games.filter(\.isLive) }
@@ -243,11 +247,19 @@ final class GamesViewModel {
} }
private func fetchStandings() async -> [Int: TeamStanding] { private func fetchStandings() async -> [Int: TeamStanding] {
let year = String(todayDateString.prefix(4)) let today = todayDateString
if let cached = cachedStandings, cached.date == today {
logGamesViewModel("fetchStandings cache hit date=\(today)")
return cached.standings
}
let year = String(today.prefix(4))
do { do {
return try await statsAPI.fetchStandings(season: year) let standings = try await statsAPI.fetchStandings(season: year)
cachedStandings = (date: today, standings: standings)
logGamesViewModel("fetchStandings fetched date=\(today) teams=\(standings.count)")
return standings
} catch { } catch {
return [:] return cachedStandings?.standings ?? [:]
} }
} }
@@ -442,18 +454,26 @@ final class GamesViewModel {
func resolveNextAuthenticatedFeedURLForActiveStream( func resolveNextAuthenticatedFeedURLForActiveStream(
id: String, id: String,
feedURL: URL, feedURL: URL,
headers: [String: String] = [:] headers: [String: String] = [:],
maxRetries: Int = 3
) async -> URL? { ) async -> URL? {
let currentURL = activeStreams.first(where: { $0.id == id })?.overrideURL let currentURL = activeStreams.first(where: { $0.id == id })?.overrideURL
guard let nextURL = await resolveAuthenticatedVideoFeedURL( for attempt in 1...maxRetries {
feedURL: feedURL, if let nextURL = await resolveAuthenticatedVideoFeedURL(
headers: headers, feedURL: feedURL,
excluding: currentURL headers: headers,
) else { excluding: currentURL
return nil ) {
updateStreamOverrideSource(id: id, url: nextURL, headers: headers)
return nextURL
}
logGamesViewModel("resolveNextAuthenticatedFeedURL retry attempt=\(attempt)/\(maxRetries) id=\(id)")
if attempt < maxRetries {
try? await Task.sleep(nanoseconds: UInt64(attempt) * 500_000_000)
}
} }
updateStreamOverrideSource(id: id, url: nextURL, headers: headers) logGamesViewModel("resolveNextAuthenticatedFeedURL exhausted retries id=\(id)")
return nextURL return nil
} }
func removeStream(id: String) { func removeStream(id: String) {
@@ -597,21 +617,25 @@ final class GamesViewModel {
return nil return nil
} }
let selectableURLs: [URL] let cacheKey = authenticatedVideoFeedCacheKey(feedURL: feedURL, headers: headers)
if let excludedURL, urls.count > 1 { var bag = videoShuffleBags[cacheKey] ?? []
let filteredURLs = urls.filter { $0 != excludedURL }
selectableURLs = filteredURLs.isEmpty ? urls : filteredURLs // Refill the bag when empty (or first time), shuffled
} else { if bag.isEmpty {
selectableURLs = urls bag = urls.shuffled()
logGamesViewModel("resolveAuthenticatedVideoFeedURL reshuffled bag count=\(bag.count)")
} }
guard let selectedURL = selectableURLs.randomElement() else { // If the next video is the one we just played, push it to the back
logGamesViewModel("resolveAuthenticatedVideoFeedURL failed reason=no-selectable-urls") if let excludedURL, bag.count > 1, bag.first == excludedURL {
return nil bag.append(bag.removeFirst())
} }
let selectedURL = bag.removeFirst()
videoShuffleBags[cacheKey] = bag
logGamesViewModel( logGamesViewModel(
"resolveAuthenticatedVideoFeedURL success resolvedURL=\(gamesViewModelDebugURLDescription(selectedURL)) excludedURL=\(excludedURL.map(gamesViewModelDebugURLDescription) ?? "nil")" "resolveAuthenticatedVideoFeedURL success resolvedURL=\(gamesViewModelDebugURLDescription(selectedURL)) remaining=\(bag.count) excludedURL=\(excludedURL.map(gamesViewModelDebugURLDescription) ?? "nil")"
) )
return selectedURL return selectedURL
} }
@@ -622,7 +646,6 @@ final class GamesViewModel {
) async -> [URL]? { ) async -> [URL]? {
let cacheKey = authenticatedVideoFeedCacheKey(feedURL: feedURL, headers: headers) let cacheKey = authenticatedVideoFeedCacheKey(feedURL: feedURL, headers: headers)
if let cachedEntry = authenticatedVideoFeedCache[cacheKey], if let cachedEntry = authenticatedVideoFeedCache[cacheKey],
Date().timeIntervalSince(cachedEntry.loadedAt) < 300,
!cachedEntry.urls.isEmpty { !cachedEntry.urls.isEmpty {
logGamesViewModel( logGamesViewModel(
"fetchAuthenticatedVideoFeedURLs cache hit feedURL=\(gamesViewModelDebugURLDescription(feedURL)) count=\(cachedEntry.urls.count)" "fetchAuthenticatedVideoFeedURLs cache hit feedURL=\(gamesViewModelDebugURLDescription(feedURL)) count=\(cachedEntry.urls.count)"

View File

@@ -150,6 +150,7 @@ struct DashboardView: View {
.onAppear { .onAppear {
logDashboard("DashboardView appeared") logDashboard("DashboardView appeared")
viewModel.startAutoRefresh() viewModel.startAutoRefresh()
Task { await viewModel.loadGames() }
} }
.onDisappear { .onDisappear {
logDashboard("DashboardView disappeared") logDashboard("DashboardView disappeared")
@@ -265,11 +266,20 @@ struct DashboardView: View {
guard selection.broadcast.id == SpecialPlaybackChannelConfig.werkoutNSFWStreamID else { guard selection.broadcast.id == SpecialPlaybackChannelConfig.werkoutNSFWStreamID else {
return nil return nil
} }
guard let nextURL = await viewModel.resolveAuthenticatedVideoFeedURL( var nextURL: URL?
feedURL: SpecialPlaybackChannelConfig.werkoutNSFWFeedURL, for attempt in 1...3 {
headers: SpecialPlaybackChannelConfig.werkoutNSFWHeaders, nextURL = await viewModel.resolveAuthenticatedVideoFeedURL(
excluding: currentURL feedURL: SpecialPlaybackChannelConfig.werkoutNSFWFeedURL,
) else { headers: SpecialPlaybackChannelConfig.werkoutNSFWHeaders,
excluding: currentURL
)
if nextURL != nil { break }
logDashboard("resolveNextFullScreenSource retry attempt=\(attempt)/3")
if attempt < 3 {
try? await Task.sleep(nanoseconds: UInt64(attempt) * 500_000_000)
}
}
guard let nextURL else {
return nil return nil
} }
return SingleStreamPlaybackSource( return SingleStreamPlaybackSource(

View File

@@ -770,9 +770,10 @@ private struct MultiStreamTile: View {
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
) else { ) else {
logMultiView("playNextWerkoutClip failed id=\(stream.id) reason=resolve-nil") logMultiView("playNextWerkoutClip failed id=\(stream.id) reason=resolve-nil-after-retries")
return return
} }