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:
@@ -54,6 +54,10 @@ final class GamesViewModel {
|
||||
private var refreshTask: Task<Void, Never>?
|
||||
@ObservationIgnored
|
||||
private var authenticatedVideoFeedCache: [String: AuthenticatedVideoFeedCacheEntry] = [:]
|
||||
@ObservationIgnored
|
||||
private var videoShuffleBags: [String: [URL]] = [:]
|
||||
@ObservationIgnored
|
||||
private var cachedStandings: (date: String, standings: [Int: TeamStanding])?
|
||||
|
||||
// Computed properties for dashboard
|
||||
var liveGames: [Game] { games.filter(\.isLive) }
|
||||
@@ -243,11 +247,19 @@ final class GamesViewModel {
|
||||
}
|
||||
|
||||
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 {
|
||||
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 {
|
||||
return [:]
|
||||
return cachedStandings?.standings ?? [:]
|
||||
}
|
||||
}
|
||||
|
||||
@@ -442,19 +454,27 @@ final class GamesViewModel {
|
||||
func resolveNextAuthenticatedFeedURLForActiveStream(
|
||||
id: String,
|
||||
feedURL: URL,
|
||||
headers: [String: String] = [:]
|
||||
headers: [String: String] = [:],
|
||||
maxRetries: Int = 3
|
||||
) async -> URL? {
|
||||
let currentURL = activeStreams.first(where: { $0.id == id })?.overrideURL
|
||||
guard let nextURL = await resolveAuthenticatedVideoFeedURL(
|
||||
for attempt in 1...maxRetries {
|
||||
if let nextURL = await resolveAuthenticatedVideoFeedURL(
|
||||
feedURL: feedURL,
|
||||
headers: headers,
|
||||
excluding: currentURL
|
||||
) else {
|
||||
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)
|
||||
}
|
||||
}
|
||||
logGamesViewModel("resolveNextAuthenticatedFeedURL exhausted retries id=\(id)")
|
||||
return nil
|
||||
}
|
||||
|
||||
func removeStream(id: String) {
|
||||
if let index = activeStreams.firstIndex(where: { $0.id == id }) {
|
||||
@@ -597,21 +617,25 @@ final class GamesViewModel {
|
||||
return nil
|
||||
}
|
||||
|
||||
let selectableURLs: [URL]
|
||||
if let excludedURL, urls.count > 1 {
|
||||
let filteredURLs = urls.filter { $0 != excludedURL }
|
||||
selectableURLs = filteredURLs.isEmpty ? urls : filteredURLs
|
||||
} else {
|
||||
selectableURLs = urls
|
||||
let cacheKey = authenticatedVideoFeedCacheKey(feedURL: feedURL, headers: headers)
|
||||
var bag = videoShuffleBags[cacheKey] ?? []
|
||||
|
||||
// Refill the bag when empty (or first time), shuffled
|
||||
if bag.isEmpty {
|
||||
bag = urls.shuffled()
|
||||
logGamesViewModel("resolveAuthenticatedVideoFeedURL reshuffled bag count=\(bag.count)")
|
||||
}
|
||||
|
||||
guard let selectedURL = selectableURLs.randomElement() else {
|
||||
logGamesViewModel("resolveAuthenticatedVideoFeedURL failed reason=no-selectable-urls")
|
||||
return nil
|
||||
// If the next video is the one we just played, push it to the back
|
||||
if let excludedURL, bag.count > 1, bag.first == excludedURL {
|
||||
bag.append(bag.removeFirst())
|
||||
}
|
||||
|
||||
let selectedURL = bag.removeFirst()
|
||||
videoShuffleBags[cacheKey] = bag
|
||||
|
||||
logGamesViewModel(
|
||||
"resolveAuthenticatedVideoFeedURL success resolvedURL=\(gamesViewModelDebugURLDescription(selectedURL)) excludedURL=\(excludedURL.map(gamesViewModelDebugURLDescription) ?? "nil")"
|
||||
"resolveAuthenticatedVideoFeedURL success resolvedURL=\(gamesViewModelDebugURLDescription(selectedURL)) remaining=\(bag.count) excludedURL=\(excludedURL.map(gamesViewModelDebugURLDescription) ?? "nil")"
|
||||
)
|
||||
return selectedURL
|
||||
}
|
||||
@@ -622,7 +646,6 @@ final class GamesViewModel {
|
||||
) async -> [URL]? {
|
||||
let cacheKey = authenticatedVideoFeedCacheKey(feedURL: feedURL, headers: headers)
|
||||
if let cachedEntry = authenticatedVideoFeedCache[cacheKey],
|
||||
Date().timeIntervalSince(cachedEntry.loadedAt) < 300,
|
||||
!cachedEntry.urls.isEmpty {
|
||||
logGamesViewModel(
|
||||
"fetchAuthenticatedVideoFeedURLs cache hit feedURL=\(gamesViewModelDebugURLDescription(feedURL)) count=\(cachedEntry.urls.count)"
|
||||
|
||||
@@ -150,6 +150,7 @@ struct DashboardView: View {
|
||||
.onAppear {
|
||||
logDashboard("DashboardView appeared")
|
||||
viewModel.startAutoRefresh()
|
||||
Task { await viewModel.loadGames() }
|
||||
}
|
||||
.onDisappear {
|
||||
logDashboard("DashboardView disappeared")
|
||||
@@ -265,11 +266,20 @@ struct DashboardView: View {
|
||||
guard selection.broadcast.id == SpecialPlaybackChannelConfig.werkoutNSFWStreamID else {
|
||||
return nil
|
||||
}
|
||||
guard let nextURL = await viewModel.resolveAuthenticatedVideoFeedURL(
|
||||
var nextURL: URL?
|
||||
for attempt in 1...3 {
|
||||
nextURL = await viewModel.resolveAuthenticatedVideoFeedURL(
|
||||
feedURL: SpecialPlaybackChannelConfig.werkoutNSFWFeedURL,
|
||||
headers: SpecialPlaybackChannelConfig.werkoutNSFWHeaders,
|
||||
excluding: currentURL
|
||||
) else {
|
||||
)
|
||||
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 SingleStreamPlaybackSource(
|
||||
|
||||
@@ -770,9 +770,10 @@ private struct MultiStreamTile: View {
|
||||
guard let nextURL = await viewModel.resolveNextAuthenticatedFeedURLForActiveStream(
|
||||
id: stream.id,
|
||||
feedURL: SpecialPlaybackChannelConfig.werkoutNSFWFeedURL,
|
||||
headers: SpecialPlaybackChannelConfig.werkoutNSFWHeaders
|
||||
headers: SpecialPlaybackChannelConfig.werkoutNSFWHeaders,
|
||||
maxRetries: 3
|
||||
) else {
|
||||
logMultiView("playNextWerkoutClip failed id=\(stream.id) reason=resolve-nil")
|
||||
logMultiView("playNextWerkoutClip failed id=\(stream.id) reason=resolve-nil-after-retries")
|
||||
return
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user