Add Werkout channel playback and autoplay

This commit is contained in:
Trey t
2026-03-26 20:53:08 -05:00
parent bae265b132
commit 127125ae1b
6 changed files with 957 additions and 48 deletions

View File

@@ -24,6 +24,16 @@ private func gamesViewModelDebugURLDescription(_ url: URL) -> String {
return "\(url.scheme ?? "unknown")://\(host)\(url.path)\(querySuffix)"
}
private struct RemoteVideoFeedEntry: Decodable {
let videoFile: String
let genderValue: String?
}
private struct AuthenticatedVideoFeedCacheEntry {
let loadedAt: Date
let urls: [URL]
}
@Observable
@MainActor
final class GamesViewModel {
@@ -41,6 +51,8 @@ final class GamesViewModel {
@ObservationIgnored
private var refreshTask: Task<Void, Never>?
@ObservationIgnored
private var authenticatedVideoFeedCache: [String: AuthenticatedVideoFeedCacheEntry] = [:]
// Computed properties for dashboard
var liveGames: [Game] { games.filter(\.isLive) }
@@ -157,6 +169,7 @@ final class GamesViewModel {
streamURLString: activeStreams[streamIdx].streamURLString,
config: activeStreams[streamIdx].config,
overrideURL: activeStreams[streamIdx].overrideURL,
overrideHeaders: activeStreams[streamIdx].overrideHeaders,
player: activeStreams[streamIdx].player,
isPlaying: activeStreams[streamIdx].isPlaying,
isMuted: activeStreams[streamIdx].isMuted
@@ -379,6 +392,69 @@ final class GamesViewModel {
syncAudioFocus()
}
func addSpecialStream(
id: String,
label: String,
game: Game,
url: URL,
headers: [String: String] = [:]
) {
guard activeStreams.count < 4 else { return }
guard !activeStreams.contains(where: { $0.id == id }) else { return }
let shouldCaptureAudio = activeStreams.isEmpty && audioFocusStreamID == nil
let stream = ActiveStream(
id: id,
game: game,
label: label,
overrideURL: url,
overrideHeaders: headers.isEmpty ? nil : headers
)
activeStreams.append(stream)
if shouldCaptureAudio {
audioFocusStreamID = stream.id
}
syncAudioFocus()
}
func addSpecialStreamFromAuthenticatedFeed(
id: String,
label: String,
game: Game,
feedURL: URL,
headers: [String: String] = [:]
) async -> Bool {
guard let resolvedURL = await resolveAuthenticatedVideoFeedURL(feedURL: feedURL, headers: headers) else {
return false
}
addSpecialStream(
id: id,
label: label,
game: game,
url: resolvedURL,
headers: headers
)
return true
}
func resolveNextAuthenticatedFeedURLForActiveStream(
id: String,
feedURL: URL,
headers: [String: String] = [:]
) async -> URL? {
let currentURL = activeStreams.first(where: { $0.id == id })?.overrideURL
guard let nextURL = await resolveAuthenticatedVideoFeedURL(
feedURL: feedURL,
headers: headers,
excluding: currentURL
) else {
return nil
}
updateStreamOverrideSource(id: id, url: nextURL, headers: headers)
return nextURL
}
func removeStream(id: String) {
if let index = activeStreams.firstIndex(where: { $0.id == id }) {
let removedWasAudioFocus = activeStreams[index].id == audioFocusStreamID
@@ -441,6 +517,12 @@ final class GamesViewModel {
player.isMuted = shouldMute
}
func updateStreamOverrideSource(id: String, url: URL, headers: [String: String] = [:]) {
guard let index = activeStreams.firstIndex(where: { $0.id == id }) else { return }
activeStreams[index].overrideURL = url
activeStreams[index].overrideHeaders = headers.isEmpty ? nil : headers
}
func isPrimaryStream(_ streamID: String) -> Bool {
activeStreams.first?.id == streamID
}
@@ -471,6 +553,104 @@ final class GamesViewModel {
return url
}
func resolveAuthenticatedVideoFeedURL(
feedURL: URL,
headers: [String: String] = [:],
excluding excludedURL: URL? = nil
) async -> URL? {
guard let urls = await fetchAuthenticatedVideoFeedURLs(feedURL: feedURL, headers: headers) else {
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
}
guard let selectedURL = selectableURLs.randomElement() else {
logGamesViewModel("resolveAuthenticatedVideoFeedURL failed reason=no-selectable-urls")
return nil
}
logGamesViewModel(
"resolveAuthenticatedVideoFeedURL success resolvedURL=\(gamesViewModelDebugURLDescription(selectedURL)) excludedURL=\(excludedURL.map(gamesViewModelDebugURLDescription) ?? "nil")"
)
return selectedURL
}
private func fetchAuthenticatedVideoFeedURLs(
feedURL: URL,
headers: [String: String] = [:]
) 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)"
)
return cachedEntry.urls
}
logGamesViewModel(
"fetchAuthenticatedVideoFeedURLs start feedURL=\(gamesViewModelDebugURLDescription(feedURL)) headerKeys=\(headers.keys.sorted().joined(separator: ","))"
)
var request = URLRequest(url: feedURL)
request.httpMethod = "GET"
request.timeoutInterval = 20
request.cachePolicy = .reloadIgnoringLocalCacheData
for (header, value) in headers {
request.setValue(value, forHTTPHeaderField: header)
}
do {
let (data, response) = try await URLSession.shared.data(for: request)
guard let httpResponse = response as? HTTPURLResponse else {
logGamesViewModel("fetchAuthenticatedVideoFeedURLs failed reason=non-http-response")
return nil
}
guard (200 ... 299).contains(httpResponse.statusCode) else {
logGamesViewModel("fetchAuthenticatedVideoFeedURLs failed statusCode=\(httpResponse.statusCode)")
return nil
}
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
let entries = try decoder.decode([RemoteVideoFeedEntry].self, from: data)
let urls = entries.compactMap { entry in
URL(string: entry.videoFile, relativeTo: feedURL)?.absoluteURL
}
guard !urls.isEmpty else {
logGamesViewModel("fetchAuthenticatedVideoFeedURLs failed reason=no-feed-entries")
return nil
}
authenticatedVideoFeedCache[cacheKey] = AuthenticatedVideoFeedCacheEntry(loadedAt: Date(), urls: urls)
logGamesViewModel(
"fetchAuthenticatedVideoFeedURLs success feedURL=\(gamesViewModelDebugURLDescription(feedURL)) count=\(urls.count)"
)
return urls
} catch {
logGamesViewModel("fetchAuthenticatedVideoFeedURLs failed error=\(error.localizedDescription)")
return nil
}
}
private func authenticatedVideoFeedCacheKey(feedURL: URL, headers: [String: String]) -> String {
let serializedHeaders = headers
.sorted { $0.key < $1.key }
.map { "\($0.key)=\($0.value)" }
.joined(separator: "&")
return "\(feedURL.absoluteString)|\(serializedHeaders)"
}
func resolveStreamURL(for stream: ActiveStream) async -> URL? {
await resolveStreamURLImpl(
for: stream,
@@ -634,6 +814,7 @@ struct ActiveStream: Identifiable, @unchecked Sendable {
var streamURLString: String?
var config: StreamConfig?
var overrideURL: URL?
var overrideHeaders: [String: String]?
var player: AVPlayer?
var isPlaying = false
var isMuted = false