Add Werkout channel playback and autoplay
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user