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>?
|
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)"
|
||||||
|
|||||||
@@ -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(
|
||||||
|
|||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user