Add Werkout channel playback and autoplay
This commit is contained in:
@@ -47,6 +47,12 @@ struct Game: Identifiable, Sendable {
|
|||||||
|
|
||||||
var isLive: Bool { status.isLive }
|
var isLive: Bool { status.isLive }
|
||||||
var isFinal: Bool { if case .final_ = status { return true }; return false }
|
var isFinal: Bool { if case .final_ = status { return true }; return false }
|
||||||
|
var isSpecialChannel: Bool {
|
||||||
|
gamePk == nil
|
||||||
|
&& broadcasts.isEmpty
|
||||||
|
&& awayTeam.code == homeTeam.code
|
||||||
|
&& awayTeam.displayName == homeTeam.displayName
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct TeamInfo: Sendable {
|
struct TeamInfo: Sendable {
|
||||||
|
|||||||
@@ -24,6 +24,16 @@ private func gamesViewModelDebugURLDescription(_ url: URL) -> String {
|
|||||||
return "\(url.scheme ?? "unknown")://\(host)\(url.path)\(querySuffix)"
|
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
|
@Observable
|
||||||
@MainActor
|
@MainActor
|
||||||
final class GamesViewModel {
|
final class GamesViewModel {
|
||||||
@@ -41,6 +51,8 @@ final class GamesViewModel {
|
|||||||
|
|
||||||
@ObservationIgnored
|
@ObservationIgnored
|
||||||
private var refreshTask: Task<Void, Never>?
|
private var refreshTask: Task<Void, Never>?
|
||||||
|
@ObservationIgnored
|
||||||
|
private var authenticatedVideoFeedCache: [String: AuthenticatedVideoFeedCacheEntry] = [:]
|
||||||
|
|
||||||
// Computed properties for dashboard
|
// Computed properties for dashboard
|
||||||
var liveGames: [Game] { games.filter(\.isLive) }
|
var liveGames: [Game] { games.filter(\.isLive) }
|
||||||
@@ -157,6 +169,7 @@ final class GamesViewModel {
|
|||||||
streamURLString: activeStreams[streamIdx].streamURLString,
|
streamURLString: activeStreams[streamIdx].streamURLString,
|
||||||
config: activeStreams[streamIdx].config,
|
config: activeStreams[streamIdx].config,
|
||||||
overrideURL: activeStreams[streamIdx].overrideURL,
|
overrideURL: activeStreams[streamIdx].overrideURL,
|
||||||
|
overrideHeaders: activeStreams[streamIdx].overrideHeaders,
|
||||||
player: activeStreams[streamIdx].player,
|
player: activeStreams[streamIdx].player,
|
||||||
isPlaying: activeStreams[streamIdx].isPlaying,
|
isPlaying: activeStreams[streamIdx].isPlaying,
|
||||||
isMuted: activeStreams[streamIdx].isMuted
|
isMuted: activeStreams[streamIdx].isMuted
|
||||||
@@ -379,6 +392,69 @@ final class GamesViewModel {
|
|||||||
syncAudioFocus()
|
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) {
|
func removeStream(id: String) {
|
||||||
if let index = activeStreams.firstIndex(where: { $0.id == id }) {
|
if let index = activeStreams.firstIndex(where: { $0.id == id }) {
|
||||||
let removedWasAudioFocus = activeStreams[index].id == audioFocusStreamID
|
let removedWasAudioFocus = activeStreams[index].id == audioFocusStreamID
|
||||||
@@ -441,6 +517,12 @@ final class GamesViewModel {
|
|||||||
player.isMuted = shouldMute
|
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 {
|
func isPrimaryStream(_ streamID: String) -> Bool {
|
||||||
activeStreams.first?.id == streamID
|
activeStreams.first?.id == streamID
|
||||||
}
|
}
|
||||||
@@ -471,6 +553,104 @@ final class GamesViewModel {
|
|||||||
return url
|
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? {
|
func resolveStreamURL(for stream: ActiveStream) async -> URL? {
|
||||||
await resolveStreamURLImpl(
|
await resolveStreamURLImpl(
|
||||||
for: stream,
|
for: stream,
|
||||||
@@ -634,6 +814,7 @@ struct ActiveStream: Identifiable, @unchecked Sendable {
|
|||||||
var streamURLString: String?
|
var streamURLString: String?
|
||||||
var config: StreamConfig?
|
var config: StreamConfig?
|
||||||
var overrideURL: URL?
|
var overrideURL: URL?
|
||||||
|
var overrideHeaders: [String: String]?
|
||||||
var player: AVPlayer?
|
var player: AVPlayer?
|
||||||
var isPlaying = false
|
var isPlaying = false
|
||||||
var isMuted = false
|
var isMuted = false
|
||||||
|
|||||||
@@ -4,8 +4,8 @@ struct ScoreOverlayView: View {
|
|||||||
let game: Game
|
let game: Game
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
// Don't show for non-game streams (e.g., MLB Network)
|
// Don't show for non-game channel streams.
|
||||||
if game.id == "MLBN" {
|
if game.isSpecialChannel {
|
||||||
EmptyView()
|
EmptyView()
|
||||||
} else {
|
} else {
|
||||||
HStack(spacing: 10) {
|
HStack(spacing: 10) {
|
||||||
|
|||||||
@@ -8,12 +8,53 @@ private func logDashboard(_ message: String) {
|
|||||||
print("[Dashboard] \(message)")
|
print("[Dashboard] \(message)")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
enum SpecialPlaybackChannelConfig {
|
||||||
|
static let werkoutNSFWStreamID = "WKNSFW"
|
||||||
|
static let werkoutNSFWTitle = "Werkout NSFW"
|
||||||
|
static let werkoutNSFWSubtitle = "Authenticated private HLS feed"
|
||||||
|
static let werkoutNSFWFeedURLString = "https://dev.werkout.fitness/videos/nsfw_videos/"
|
||||||
|
static let werkoutNSFWAuthToken = "15d7565cde9e8c904ae934f8235f68f6a24b4a03"
|
||||||
|
static let werkoutNSFWTeamCode = "WK"
|
||||||
|
|
||||||
|
static var werkoutNSFWHeaders: [String: String] {
|
||||||
|
[
|
||||||
|
"authorization": "Token \(werkoutNSFWAuthToken)",
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
static var werkoutNSFWFeedURL: URL {
|
||||||
|
URL(string: werkoutNSFWFeedURLString)!
|
||||||
|
}
|
||||||
|
|
||||||
|
static var werkoutNSFWBroadcast: Broadcast {
|
||||||
|
Broadcast(
|
||||||
|
id: werkoutNSFWStreamID,
|
||||||
|
teamCode: werkoutNSFWTeamCode,
|
||||||
|
name: werkoutNSFWTitle,
|
||||||
|
mediaId: "",
|
||||||
|
streamURL: werkoutNSFWFeedURLString
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
static var werkoutNSFWGame: Game {
|
||||||
|
Game(
|
||||||
|
id: werkoutNSFWStreamID,
|
||||||
|
awayTeam: TeamInfo(code: werkoutNSFWTeamCode, name: werkoutNSFWTitle, score: nil),
|
||||||
|
homeTeam: TeamInfo(code: werkoutNSFWTeamCode, name: werkoutNSFWTitle, score: nil),
|
||||||
|
status: .live(nil), gameType: nil, startTime: nil, venue: nil,
|
||||||
|
pitchers: nil, gamePk: nil, gameDate: "",
|
||||||
|
broadcasts: [], isBlackedOut: false
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
struct DashboardView: View {
|
struct DashboardView: View {
|
||||||
@Environment(GamesViewModel.self) private var viewModel
|
@Environment(GamesViewModel.self) private var viewModel
|
||||||
@State private var selectedGame: Game?
|
@State private var selectedGame: Game?
|
||||||
@State private var fullScreenBroadcast: BroadcastSelection?
|
@State private var fullScreenBroadcast: BroadcastSelection?
|
||||||
@State private var pendingFullScreenBroadcast: BroadcastSelection?
|
@State private var pendingFullScreenBroadcast: BroadcastSelection?
|
||||||
@State private var showMLBNetworkSheet = false
|
@State private var showMLBNetworkSheet = false
|
||||||
|
@State private var showWerkoutNSFWSheet = false
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ScrollView {
|
ScrollView {
|
||||||
@@ -64,7 +105,7 @@ struct DashboardView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
mlbNetworkCard
|
featuredChannelsSection
|
||||||
|
|
||||||
if !viewModel.activeStreams.isEmpty {
|
if !viewModel.activeStreams.isEmpty {
|
||||||
multiViewStatus
|
multiViewStatus
|
||||||
@@ -88,29 +129,7 @@ struct DashboardView: View {
|
|||||||
selectedGame = nil
|
selectedGame = nil
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.fullScreenCover(item: $fullScreenBroadcast) { selection in
|
.fullScreenCover(item: $fullScreenBroadcast, content: fullScreenPlaybackScreen)
|
||||||
SingleStreamPlaybackScreen(
|
|
||||||
resolveURL: {
|
|
||||||
logDashboard("resolveURL closure invoked broadcastId=\(selection.broadcast.id) gameId=\(selection.game.id)")
|
|
||||||
if selection.broadcast.id == "MLBN" {
|
|
||||||
return await viewModel.buildEventStreamURL(event: "MLBN")
|
|
||||||
}
|
|
||||||
let s = ActiveStream(
|
|
||||||
id: selection.broadcast.id,
|
|
||||||
game: selection.game,
|
|
||||||
label: selection.broadcast.displayLabel,
|
|
||||||
mediaId: selection.broadcast.mediaId,
|
|
||||||
streamURLString: selection.broadcast.streamURL
|
|
||||||
)
|
|
||||||
return await viewModel.resolveStreamURL(for: s)
|
|
||||||
},
|
|
||||||
tickerGames: viewModel.games
|
|
||||||
)
|
|
||||||
.ignoresSafeArea()
|
|
||||||
.onAppear {
|
|
||||||
logDashboard("fullScreenCover content mounted broadcastId=\(selection.broadcast.id) gameId=\(selection.game.id)")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.onChange(of: fullScreenBroadcast?.id) { _, newValue in
|
.onChange(of: fullScreenBroadcast?.id) { _, newValue in
|
||||||
logDashboard("fullScreenBroadcast changed newValue=\(newValue ?? "nil")")
|
logDashboard("fullScreenBroadcast changed newValue=\(newValue ?? "nil")")
|
||||||
}
|
}
|
||||||
@@ -123,10 +142,19 @@ struct DashboardView: View {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
.sheet(isPresented: $showWerkoutNSFWSheet, onDismiss: presentPendingFullScreenBroadcast) {
|
||||||
|
WerkoutNSFWSheet(
|
||||||
|
onWatchFullScreen: {
|
||||||
|
logDashboard("Queued fullscreen broadcast from Werkout NSFW sheet")
|
||||||
|
showWerkoutNSFWSheet = false
|
||||||
|
pendingFullScreenBroadcast = nsfwVideosBroadcastSelection
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private func presentPendingFullScreenBroadcast() {
|
private func presentPendingFullScreenBroadcast() {
|
||||||
guard selectedGame == nil, !showMLBNetworkSheet else {
|
guard selectedGame == nil, !showMLBNetworkSheet, !showWerkoutNSFWSheet else {
|
||||||
logDashboard("Skipped pending fullscreen presentation because another sheet is still active")
|
logDashboard("Skipped pending fullscreen presentation because another sheet is still active")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@@ -141,6 +169,97 @@ struct DashboardView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func presentFullScreenBroadcast(_ selection: BroadcastSelection) {
|
||||||
|
if selectedGame == nil, !showMLBNetworkSheet, !showWerkoutNSFWSheet {
|
||||||
|
fullScreenBroadcast = selection
|
||||||
|
} else {
|
||||||
|
pendingFullScreenBroadcast = selection
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func fullScreenPlaybackScreen(selection: BroadcastSelection) -> some View {
|
||||||
|
SingleStreamPlaybackScreen(
|
||||||
|
resolveSource: {
|
||||||
|
await resolveFullScreenSource(for: selection)
|
||||||
|
},
|
||||||
|
resolveNextSource: nextFullScreenSourceResolver(for: selection),
|
||||||
|
tickerGames: tickerGames(for: selection)
|
||||||
|
)
|
||||||
|
.ignoresSafeArea()
|
||||||
|
.onAppear {
|
||||||
|
logDashboard("fullScreenCover content mounted broadcastId=\(selection.broadcast.id) gameId=\(selection.game.id)")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func resolveFullScreenSource(for selection: BroadcastSelection) async -> SingleStreamPlaybackSource? {
|
||||||
|
logDashboard("resolveSource closure invoked broadcastId=\(selection.broadcast.id) gameId=\(selection.game.id)")
|
||||||
|
if let directSource = selection.directSource {
|
||||||
|
return directSource
|
||||||
|
}
|
||||||
|
if selection.broadcast.id == "MLBN" {
|
||||||
|
let url = await viewModel.buildEventStreamURL(event: "MLBN")
|
||||||
|
return SingleStreamPlaybackSource(url: url)
|
||||||
|
}
|
||||||
|
if selection.broadcast.id == SpecialPlaybackChannelConfig.werkoutNSFWStreamID {
|
||||||
|
guard let url = await viewModel.resolveAuthenticatedVideoFeedURL(
|
||||||
|
feedURL: SpecialPlaybackChannelConfig.werkoutNSFWFeedURL,
|
||||||
|
headers: SpecialPlaybackChannelConfig.werkoutNSFWHeaders
|
||||||
|
) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return SingleStreamPlaybackSource(
|
||||||
|
url: url,
|
||||||
|
httpHeaders: SpecialPlaybackChannelConfig.werkoutNSFWHeaders
|
||||||
|
)
|
||||||
|
}
|
||||||
|
let stream = ActiveStream(
|
||||||
|
id: selection.broadcast.id,
|
||||||
|
game: selection.game,
|
||||||
|
label: selection.broadcast.displayLabel,
|
||||||
|
mediaId: selection.broadcast.mediaId,
|
||||||
|
streamURLString: selection.broadcast.streamURL
|
||||||
|
)
|
||||||
|
guard let url = await viewModel.resolveStreamURL(for: stream) else { return nil }
|
||||||
|
return SingleStreamPlaybackSource(url: url)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func resolveNextFullScreenSource(
|
||||||
|
for selection: BroadcastSelection,
|
||||||
|
currentURL: URL?
|
||||||
|
) async -> SingleStreamPlaybackSource? {
|
||||||
|
guard selection.broadcast.id == SpecialPlaybackChannelConfig.werkoutNSFWStreamID else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
guard let nextURL = await viewModel.resolveAuthenticatedVideoFeedURL(
|
||||||
|
feedURL: SpecialPlaybackChannelConfig.werkoutNSFWFeedURL,
|
||||||
|
headers: SpecialPlaybackChannelConfig.werkoutNSFWHeaders,
|
||||||
|
excluding: currentURL
|
||||||
|
) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return SingleStreamPlaybackSource(
|
||||||
|
url: nextURL,
|
||||||
|
httpHeaders: SpecialPlaybackChannelConfig.werkoutNSFWHeaders
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func nextFullScreenSourceResolver(
|
||||||
|
for selection: BroadcastSelection
|
||||||
|
) -> (@Sendable (URL?) async -> SingleStreamPlaybackSource?)? {
|
||||||
|
guard selection.broadcast.id == SpecialPlaybackChannelConfig.werkoutNSFWStreamID else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return { currentURL in
|
||||||
|
await resolveNextFullScreenSource(for: selection, currentURL: currentURL)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func tickerGames(for selection: BroadcastSelection) -> [Game] {
|
||||||
|
selection.broadcast.id == SpecialPlaybackChannelConfig.werkoutNSFWStreamID ? [] : viewModel.games
|
||||||
|
}
|
||||||
|
|
||||||
private var mlbNetworkBroadcastSelection: BroadcastSelection {
|
private var mlbNetworkBroadcastSelection: BroadcastSelection {
|
||||||
let bc = Broadcast(
|
let bc = Broadcast(
|
||||||
id: "MLBN",
|
id: "MLBN",
|
||||||
@@ -160,6 +279,13 @@ struct DashboardView: View {
|
|||||||
return BroadcastSelection(broadcast: bc, game: game)
|
return BroadcastSelection(broadcast: bc, game: game)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var nsfwVideosBroadcastSelection: BroadcastSelection {
|
||||||
|
return BroadcastSelection(
|
||||||
|
broadcast: SpecialPlaybackChannelConfig.werkoutNSFWBroadcast,
|
||||||
|
game: SpecialPlaybackChannelConfig.werkoutNSFWGame
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
// MARK: - Game Shelf (Horizontal)
|
// MARK: - Game Shelf (Horizontal)
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
@@ -262,7 +388,18 @@ struct DashboardView: View {
|
|||||||
.clipShape(RoundedRectangle(cornerRadius: 14))
|
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - MLB Network
|
// MARK: - Featured Channels
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var featuredChannelsSection: some View {
|
||||||
|
HStack(alignment: .top, spacing: 24) {
|
||||||
|
mlbNetworkCard
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
|
||||||
|
nsfwVideosCard
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private var mlbNetworkCard: some View {
|
private var mlbNetworkCard: some View {
|
||||||
@@ -294,6 +431,49 @@ struct DashboardView: View {
|
|||||||
.foregroundStyle(.green)
|
.foregroundStyle(.green)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
.frame(maxWidth: .infinity, minHeight: 108, alignment: .leading)
|
||||||
|
.padding(24)
|
||||||
|
.background(.regularMaterial)
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 20))
|
||||||
|
}
|
||||||
|
.buttonStyle(.card)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var nsfwVideosCard: some View {
|
||||||
|
let added = viewModel.activeStreams.contains(where: { $0.id == SpecialPlaybackChannelConfig.werkoutNSFWStreamID })
|
||||||
|
Button {
|
||||||
|
showWerkoutNSFWSheet = true
|
||||||
|
} label: {
|
||||||
|
HStack(spacing: 16) {
|
||||||
|
Image(systemName: "play.rectangle.fill")
|
||||||
|
.font(.title2)
|
||||||
|
.foregroundStyle(.pink)
|
||||||
|
.frame(width: 56, height: 56)
|
||||||
|
.background(.pink.opacity(0.2))
|
||||||
|
.clipShape(RoundedRectangle(cornerRadius: 14))
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text(SpecialPlaybackChannelConfig.werkoutNSFWTitle)
|
||||||
|
.font(.title3.weight(.bold))
|
||||||
|
Text(SpecialPlaybackChannelConfig.werkoutNSFWSubtitle)
|
||||||
|
.font(.subheadline)
|
||||||
|
.foregroundStyle(.secondary)
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer()
|
||||||
|
|
||||||
|
if added {
|
||||||
|
Label("In Multi-View", systemImage: "checkmark.circle.fill")
|
||||||
|
.font(.subheadline.weight(.bold))
|
||||||
|
.foregroundStyle(.green)
|
||||||
|
} else {
|
||||||
|
Label("Open", systemImage: "play.fill")
|
||||||
|
.font(.subheadline.weight(.bold))
|
||||||
|
.foregroundStyle(.pink)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.frame(maxWidth: .infinity, minHeight: 108, alignment: .leading)
|
||||||
.padding(24)
|
.padding(24)
|
||||||
.background(.regularMaterial)
|
.background(.regularMaterial)
|
||||||
.clipShape(RoundedRectangle(cornerRadius: 20))
|
.clipShape(RoundedRectangle(cornerRadius: 20))
|
||||||
@@ -331,10 +511,345 @@ struct BroadcastSelection: Identifiable {
|
|||||||
let id: String
|
let id: String
|
||||||
let broadcast: Broadcast
|
let broadcast: Broadcast
|
||||||
let game: Game
|
let game: Game
|
||||||
|
let directSource: SingleStreamPlaybackSource?
|
||||||
|
|
||||||
init(broadcast: Broadcast, game: Game) {
|
init(
|
||||||
|
broadcast: Broadcast,
|
||||||
|
game: Game,
|
||||||
|
directSource: SingleStreamPlaybackSource? = nil
|
||||||
|
) {
|
||||||
self.id = broadcast.id
|
self.id = broadcast.id
|
||||||
self.broadcast = broadcast
|
self.broadcast = broadcast
|
||||||
self.game = game
|
self.game = game
|
||||||
|
self.directSource = directSource
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
struct WerkoutNSFWSheet: View {
|
||||||
|
var onWatchFullScreen: () -> Void
|
||||||
|
@Environment(GamesViewModel.self) private var viewModel
|
||||||
|
@Environment(\.dismiss) private var dismiss
|
||||||
|
@State private var isResolvingMultiViewSource = false
|
||||||
|
@State private var multiViewErrorMessage: String?
|
||||||
|
|
||||||
|
private var added: Bool {
|
||||||
|
viewModel.activeStreams.contains(where: { $0.id == SpecialPlaybackChannelConfig.werkoutNSFWStreamID })
|
||||||
|
}
|
||||||
|
|
||||||
|
private var canToggleIntoMultiview: Bool {
|
||||||
|
added || viewModel.activeStreams.count < 4
|
||||||
|
}
|
||||||
|
|
||||||
|
var body: some View {
|
||||||
|
ZStack {
|
||||||
|
sheetBackground
|
||||||
|
.ignoresSafeArea()
|
||||||
|
|
||||||
|
HStack(alignment: .top, spacing: 32) {
|
||||||
|
overviewColumn
|
||||||
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
|
||||||
|
actionColumn
|
||||||
|
.frame(width: 360, alignment: .leading)
|
||||||
|
}
|
||||||
|
.padding(38)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 34, style: .continuous)
|
||||||
|
.fill(.black.opacity(0.46))
|
||||||
|
.overlay {
|
||||||
|
RoundedRectangle(cornerRadius: 34, style: .continuous)
|
||||||
|
.strokeBorder(.white.opacity(0.08), lineWidth: 1)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.padding(.horizontal, 94)
|
||||||
|
.padding(.vertical, 70)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var overviewColumn: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 28) {
|
||||||
|
VStack(alignment: .leading, spacing: 18) {
|
||||||
|
HStack(spacing: 12) {
|
||||||
|
statusPill(title: "PRIVATE FEED", color: .pink)
|
||||||
|
|
||||||
|
if added {
|
||||||
|
statusPill(title: "IN MULTI-VIEW", color: .green)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack(alignment: .center, spacing: 22) {
|
||||||
|
ZStack {
|
||||||
|
RoundedRectangle(cornerRadius: 26, style: .continuous)
|
||||||
|
.fill(.pink.opacity(0.16))
|
||||||
|
.frame(width: 110, height: 110)
|
||||||
|
|
||||||
|
Image(systemName: "play.rectangle.fill")
|
||||||
|
.font(.system(size: 46, weight: .bold))
|
||||||
|
.foregroundStyle(
|
||||||
|
LinearGradient(
|
||||||
|
colors: [.white, .pink.opacity(0.88)],
|
||||||
|
startPoint: .top,
|
||||||
|
endPoint: .bottom
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 10) {
|
||||||
|
Text(SpecialPlaybackChannelConfig.werkoutNSFWTitle)
|
||||||
|
.font(.system(size: 46, weight: .black, design: .rounded))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
|
||||||
|
Text("Launch the authenticated Werkout video feed full screen or drop it into an open Multi-View tile.")
|
||||||
|
.font(.system(size: 20, weight: .medium))
|
||||||
|
.foregroundStyle(.white.opacity(0.72))
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
HStack(spacing: 14) {
|
||||||
|
featurePill(title: "Authenticated", systemImage: "lock.fill")
|
||||||
|
featurePill(title: "Private Media", systemImage: "video.fill")
|
||||||
|
featurePill(title: "Direct Playback", systemImage: "play.fill")
|
||||||
|
}
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 14) {
|
||||||
|
Text("Notes")
|
||||||
|
.font(.system(size: 16, weight: .bold, design: .rounded))
|
||||||
|
.foregroundStyle(.white.opacity(0.48))
|
||||||
|
.kerning(1.2)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 14) {
|
||||||
|
overviewLine(
|
||||||
|
icon: "lock.shield.fill",
|
||||||
|
title: "Token-gated playback",
|
||||||
|
detail: "This feed is played with an Authorization header instead of a public URL."
|
||||||
|
)
|
||||||
|
overviewLine(
|
||||||
|
icon: "square.grid.2x2.fill",
|
||||||
|
title: "Multi-View compatible",
|
||||||
|
detail: "You can pin this feed into the active grid and route audio to it like other channel streams."
|
||||||
|
)
|
||||||
|
overviewLine(
|
||||||
|
icon: "exclamationmark.triangle.fill",
|
||||||
|
title: "Backend auth still matters",
|
||||||
|
detail: "If the server rejects the token, playback will fail in both full screen and Multi-View."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.padding(24)
|
||||||
|
.background(panelBackground)
|
||||||
|
|
||||||
|
if let multiViewErrorMessage {
|
||||||
|
Label(multiViewErrorMessage, systemImage: "exclamationmark.triangle.fill")
|
||||||
|
.font(.system(size: 16, weight: .semibold))
|
||||||
|
.foregroundStyle(.red)
|
||||||
|
.padding(.horizontal, 18)
|
||||||
|
.padding(.vertical, 16)
|
||||||
|
.background(panelBackground)
|
||||||
|
}
|
||||||
|
|
||||||
|
if !canToggleIntoMultiview {
|
||||||
|
Label(
|
||||||
|
"Multi-View is full. Remove a stream before adding Werkout NSFW.",
|
||||||
|
systemImage: "rectangle.split.2x2.fill"
|
||||||
|
)
|
||||||
|
.font(.system(size: 16, weight: .semibold))
|
||||||
|
.foregroundStyle(.orange)
|
||||||
|
.padding(.horizontal, 18)
|
||||||
|
.padding(.vertical, 16)
|
||||||
|
.background(panelBackground)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var actionColumn: some View {
|
||||||
|
VStack(alignment: .leading, spacing: 18) {
|
||||||
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
|
Text("Actions")
|
||||||
|
.font(.system(size: 28, weight: .bold, design: .rounded))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
|
||||||
|
Text("Play the feed full screen or send it into your active Multi-View lineup.")
|
||||||
|
.font(.system(size: 16, weight: .medium))
|
||||||
|
.foregroundStyle(.white.opacity(0.68))
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
|
.padding(24)
|
||||||
|
.background(panelBackground)
|
||||||
|
|
||||||
|
actionButton(
|
||||||
|
title: "Watch Full Screen",
|
||||||
|
subtitle: "Open the Werkout feed in the main player",
|
||||||
|
systemImage: "play.fill",
|
||||||
|
fill: .pink.opacity(0.18)
|
||||||
|
) {
|
||||||
|
onWatchFullScreen()
|
||||||
|
}
|
||||||
|
|
||||||
|
actionButton(
|
||||||
|
title: added ? "Remove From Multi-View" : "Add to Multi-View",
|
||||||
|
subtitle: added ? "Take the feed out of your active grid" : isResolvingMultiViewSource ? "Resolving a playable HLS source from the feed" : "Send the feed into an open tile",
|
||||||
|
systemImage: added ? "minus.circle.fill" : "plus.circle.fill",
|
||||||
|
fill: added ? .red.opacity(0.16) : .white.opacity(0.08),
|
||||||
|
foreground: added ? .red : .white,
|
||||||
|
disabled: !canToggleIntoMultiview || isResolvingMultiViewSource
|
||||||
|
) {
|
||||||
|
if added {
|
||||||
|
viewModel.removeStream(id: SpecialPlaybackChannelConfig.werkoutNSFWStreamID)
|
||||||
|
dismiss()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
multiViewErrorMessage = nil
|
||||||
|
isResolvingMultiViewSource = true
|
||||||
|
Task { @MainActor in
|
||||||
|
let didAddStream = await viewModel.addSpecialStreamFromAuthenticatedFeed(
|
||||||
|
id: SpecialPlaybackChannelConfig.werkoutNSFWStreamID,
|
||||||
|
label: SpecialPlaybackChannelConfig.werkoutNSFWTitle,
|
||||||
|
game: SpecialPlaybackChannelConfig.werkoutNSFWGame,
|
||||||
|
feedURL: SpecialPlaybackChannelConfig.werkoutNSFWFeedURL,
|
||||||
|
headers: SpecialPlaybackChannelConfig.werkoutNSFWHeaders
|
||||||
|
)
|
||||||
|
isResolvingMultiViewSource = false
|
||||||
|
if didAddStream {
|
||||||
|
dismiss()
|
||||||
|
} else {
|
||||||
|
multiViewErrorMessage = "Could not resolve a playable Werkout stream from the authenticated feed."
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func actionButton(
|
||||||
|
title: String,
|
||||||
|
subtitle: String,
|
||||||
|
systemImage: String,
|
||||||
|
fill: Color,
|
||||||
|
foreground: Color = .white,
|
||||||
|
disabled: Bool = false,
|
||||||
|
action: @escaping () -> Void
|
||||||
|
) -> some View {
|
||||||
|
Button(action: action) {
|
||||||
|
HStack(alignment: .center, spacing: 16) {
|
||||||
|
Image(systemName: systemImage)
|
||||||
|
.font(.system(size: 22, weight: .bold))
|
||||||
|
.frame(width: 32)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 5) {
|
||||||
|
Text(title)
|
||||||
|
.font(.system(size: 21, weight: .bold, design: .rounded))
|
||||||
|
|
||||||
|
HStack(spacing: 10) {
|
||||||
|
Text(subtitle)
|
||||||
|
.font(.system(size: 14, weight: .medium))
|
||||||
|
.foregroundStyle(foreground.opacity(0.68))
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
|
||||||
|
if isResolvingMultiViewSource && title == "Add to Multi-View" {
|
||||||
|
ProgressView()
|
||||||
|
.scaleEffect(0.8)
|
||||||
|
.tint(.white.opacity(0.84))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Spacer(minLength: 0)
|
||||||
|
}
|
||||||
|
.foregroundStyle(foreground)
|
||||||
|
.frame(maxWidth: .infinity, minHeight: 88, alignment: .leading)
|
||||||
|
.padding(.horizontal, 22)
|
||||||
|
.background(
|
||||||
|
RoundedRectangle(cornerRadius: 24, style: .continuous)
|
||||||
|
.fill(fill)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.buttonStyle(.card)
|
||||||
|
.disabled(disabled)
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func overviewLine(icon: String, title: String, detail: String) -> some View {
|
||||||
|
HStack(alignment: .top, spacing: 14) {
|
||||||
|
Image(systemName: icon)
|
||||||
|
.font(.system(size: 18, weight: .bold))
|
||||||
|
.foregroundStyle(.pink.opacity(0.9))
|
||||||
|
.frame(width: 26)
|
||||||
|
|
||||||
|
VStack(alignment: .leading, spacing: 4) {
|
||||||
|
Text(title)
|
||||||
|
.font(.system(size: 18, weight: .bold))
|
||||||
|
.foregroundStyle(.white)
|
||||||
|
|
||||||
|
Text(detail)
|
||||||
|
.font(.system(size: 15, weight: .medium))
|
||||||
|
.foregroundStyle(.white.opacity(0.66))
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func statusPill(title: String, color: Color) -> some View {
|
||||||
|
Text(title)
|
||||||
|
.font(.system(size: 13, weight: .black, design: .rounded))
|
||||||
|
.foregroundStyle(color)
|
||||||
|
.padding(.horizontal, 13)
|
||||||
|
.padding(.vertical, 9)
|
||||||
|
.background(color.opacity(0.14))
|
||||||
|
.clipShape(Capsule())
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private func featurePill(title: String, systemImage: String) -> some View {
|
||||||
|
Label(title, systemImage: systemImage)
|
||||||
|
.font(.system(size: 15, weight: .semibold))
|
||||||
|
.foregroundStyle(.white.opacity(0.84))
|
||||||
|
.padding(.horizontal, 16)
|
||||||
|
.padding(.vertical, 12)
|
||||||
|
.background(.white.opacity(0.06))
|
||||||
|
.clipShape(Capsule())
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var panelBackground: some View {
|
||||||
|
RoundedRectangle(cornerRadius: 24, style: .continuous)
|
||||||
|
.fill(.black.opacity(0.22))
|
||||||
|
.overlay {
|
||||||
|
RoundedRectangle(cornerRadius: 24, style: .continuous)
|
||||||
|
.strokeBorder(.white.opacity(0.08), lineWidth: 1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@ViewBuilder
|
||||||
|
private var sheetBackground: some View {
|
||||||
|
ZStack {
|
||||||
|
LinearGradient(
|
||||||
|
colors: [
|
||||||
|
Color(red: 0.07, green: 0.04, blue: 0.08),
|
||||||
|
Color(red: 0.09, green: 0.05, blue: 0.08),
|
||||||
|
],
|
||||||
|
startPoint: .topLeading,
|
||||||
|
endPoint: .bottomTrailing
|
||||||
|
)
|
||||||
|
|
||||||
|
Circle()
|
||||||
|
.fill(.pink.opacity(0.18))
|
||||||
|
.frame(width: 520, height: 520)
|
||||||
|
.blur(radius: 92)
|
||||||
|
.offset(x: -320, y: -220)
|
||||||
|
|
||||||
|
Circle()
|
||||||
|
.fill(.red.opacity(0.15))
|
||||||
|
.frame(width: 460, height: 460)
|
||||||
|
.blur(radius: 88)
|
||||||
|
.offset(x: 380, y: 140)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -41,6 +41,11 @@ private func multiViewTimeControlDescription(_ status: AVPlayer.TimeControlStatu
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func multiViewHeaderKeysDescription(_ headers: [String: String]) -> String {
|
||||||
|
guard !headers.isEmpty else { return "none" }
|
||||||
|
return headers.keys.sorted().joined(separator: ",")
|
||||||
|
}
|
||||||
|
|
||||||
struct MultiStreamView: View {
|
struct MultiStreamView: View {
|
||||||
@Environment(GamesViewModel.self) private var viewModel
|
@Environment(GamesViewModel.self) private var viewModel
|
||||||
@State private var selectedStream: StreamSelection?
|
@State private var selectedStream: StreamSelection?
|
||||||
@@ -497,11 +502,18 @@ private struct MultiStreamTile: View {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private func startStream() async {
|
private func startStream() async {
|
||||||
logMultiView("startStream begin id=\(stream.id) label=\(stream.label) hasInlinePlayer=\(player != nil) hasSharedPlayer=\(stream.player != nil) hasOverrideURL=\(stream.overrideURL != nil)")
|
logMultiView(
|
||||||
|
"startStream begin id=\(stream.id) label=\(stream.label) hasInlinePlayer=\(player != nil) hasSharedPlayer=\(stream.player != nil) hasOverrideURL=\(stream.overrideURL != nil) hasOverrideHeaders=\(stream.overrideHeaders != nil)"
|
||||||
|
)
|
||||||
|
|
||||||
if let player {
|
if let player {
|
||||||
player.isMuted = viewModel.audioFocusStreamID != stream.id
|
player.isMuted = viewModel.audioFocusStreamID != stream.id
|
||||||
playbackDiagnostics.attach(to: player, streamID: stream.id, label: stream.label)
|
playbackDiagnostics.attach(
|
||||||
|
to: player,
|
||||||
|
streamID: stream.id,
|
||||||
|
label: stream.label,
|
||||||
|
onPlaybackEnded: playbackEndedHandler(for: player)
|
||||||
|
)
|
||||||
scheduleStartupPlaybackRecovery(for: player)
|
scheduleStartupPlaybackRecovery(for: player)
|
||||||
scheduleQualityUpgrade(for: player)
|
scheduleQualityUpgrade(for: player)
|
||||||
logMultiView("startStream reused inline player id=\(stream.id) muted=\(player.isMuted)")
|
logMultiView("startStream reused inline player id=\(stream.id) muted=\(player.isMuted)")
|
||||||
@@ -520,7 +532,12 @@ private struct MultiStreamTile: View {
|
|||||||
existingPlayer.isMuted = viewModel.audioFocusStreamID != stream.id
|
existingPlayer.isMuted = viewModel.audioFocusStreamID != stream.id
|
||||||
self.player = existingPlayer
|
self.player = existingPlayer
|
||||||
hasError = false
|
hasError = false
|
||||||
playbackDiagnostics.attach(to: existingPlayer, streamID: stream.id, label: stream.label)
|
playbackDiagnostics.attach(
|
||||||
|
to: existingPlayer,
|
||||||
|
streamID: stream.id,
|
||||||
|
label: stream.label,
|
||||||
|
onPlaybackEnded: playbackEndedHandler(for: existingPlayer)
|
||||||
|
)
|
||||||
scheduleStartupPlaybackRecovery(for: existingPlayer)
|
scheduleStartupPlaybackRecovery(for: existingPlayer)
|
||||||
scheduleQualityUpgrade(for: existingPlayer)
|
scheduleQualityUpgrade(for: existingPlayer)
|
||||||
logMultiView("startStream reused shared player id=\(stream.id) muted=\(existingPlayer.isMuted)")
|
logMultiView("startStream reused shared player id=\(stream.id) muted=\(existingPlayer.isMuted)")
|
||||||
@@ -544,13 +561,17 @@ private struct MultiStreamTile: View {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
logMultiView("startStream creating AVPlayer id=\(stream.id) url=\(url.absoluteString)")
|
let avPlayer = makePlayer(url: url, headers: stream.overrideHeaders)
|
||||||
let avPlayer = AVPlayer(url: url)
|
|
||||||
avPlayer.automaticallyWaitsToMinimizeStalling = false
|
avPlayer.automaticallyWaitsToMinimizeStalling = false
|
||||||
avPlayer.currentItem?.preferredForwardBufferDuration = 2
|
avPlayer.currentItem?.preferredForwardBufferDuration = 2
|
||||||
|
|
||||||
self.player = avPlayer
|
self.player = avPlayer
|
||||||
playbackDiagnostics.attach(to: avPlayer, streamID: stream.id, label: stream.label)
|
playbackDiagnostics.attach(
|
||||||
|
to: avPlayer,
|
||||||
|
streamID: stream.id,
|
||||||
|
label: stream.label,
|
||||||
|
onPlaybackEnded: playbackEndedHandler(for: avPlayer)
|
||||||
|
)
|
||||||
viewModel.attachPlayer(avPlayer, to: stream.id)
|
viewModel.attachPlayer(avPlayer, to: stream.id)
|
||||||
scheduleStartupPlaybackRecovery(for: avPlayer)
|
scheduleStartupPlaybackRecovery(for: avPlayer)
|
||||||
scheduleQualityUpgrade(for: avPlayer)
|
scheduleQualityUpgrade(for: avPlayer)
|
||||||
@@ -558,6 +579,28 @@ private struct MultiStreamTile: View {
|
|||||||
avPlayer.playImmediately(atRate: 1.0)
|
avPlayer.playImmediately(atRate: 1.0)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func makePlayer(url: URL, headers: [String: String]?) -> AVPlayer {
|
||||||
|
let headers = headers ?? [:]
|
||||||
|
logMultiView(
|
||||||
|
"startStream creating AVPlayer id=\(stream.id) url=\(url.absoluteString) headerKeys=\(multiViewHeaderKeysDescription(headers))"
|
||||||
|
)
|
||||||
|
|
||||||
|
let item = makePlayerItem(url: url, headers: headers)
|
||||||
|
return AVPlayer(playerItem: item)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func makePlayerItem(url: URL, headers: [String: String]) -> AVPlayerItem {
|
||||||
|
if headers.isEmpty {
|
||||||
|
return AVPlayerItem(url: url)
|
||||||
|
}
|
||||||
|
|
||||||
|
let assetOptions: [String: Any] = [
|
||||||
|
"AVURLAssetHTTPHeaderFieldsKey": headers,
|
||||||
|
]
|
||||||
|
let asset = AVURLAsset(url: url, options: assetOptions)
|
||||||
|
return AVPlayerItem(asset: asset)
|
||||||
|
}
|
||||||
|
|
||||||
private func scheduleStartupPlaybackRecovery(for player: AVPlayer) {
|
private func scheduleStartupPlaybackRecovery(for player: AVPlayer) {
|
||||||
startupPlaybackTask?.cancel()
|
startupPlaybackTask?.cancel()
|
||||||
|
|
||||||
@@ -681,6 +724,49 @@ private struct MultiStreamTile: View {
|
|||||||
.first(where: { $0.name == "resolution" })?
|
.first(where: { $0.name == "resolution" })?
|
||||||
.value
|
.value
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func playbackEndedHandler(for player: AVPlayer) -> (() -> Void)? {
|
||||||
|
guard stream.id == SpecialPlaybackChannelConfig.werkoutNSFWStreamID else { return nil }
|
||||||
|
return {
|
||||||
|
Task { @MainActor in
|
||||||
|
await playNextWerkoutClip(on: player)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func playNextWerkoutClip(on player: AVPlayer) async {
|
||||||
|
let currentURL = currentStreamURL(for: player)
|
||||||
|
logMultiView(
|
||||||
|
"playNextWerkoutClip begin id=\(stream.id) currentURL=\(currentURL?.absoluteString ?? "nil")"
|
||||||
|
)
|
||||||
|
|
||||||
|
guard let nextURL = await viewModel.resolveNextAuthenticatedFeedURLForActiveStream(
|
||||||
|
id: stream.id,
|
||||||
|
feedURL: SpecialPlaybackChannelConfig.werkoutNSFWFeedURL,
|
||||||
|
headers: SpecialPlaybackChannelConfig.werkoutNSFWHeaders
|
||||||
|
) else {
|
||||||
|
logMultiView("playNextWerkoutClip failed id=\(stream.id) reason=resolve-nil")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
let nextItem = makePlayerItem(
|
||||||
|
url: nextURL,
|
||||||
|
headers: stream.overrideHeaders ?? SpecialPlaybackChannelConfig.werkoutNSFWHeaders
|
||||||
|
)
|
||||||
|
nextItem.preferredForwardBufferDuration = 2
|
||||||
|
player.replaceCurrentItem(with: nextItem)
|
||||||
|
player.automaticallyWaitsToMinimizeStalling = false
|
||||||
|
playbackDiagnostics.attach(
|
||||||
|
to: player,
|
||||||
|
streamID: stream.id,
|
||||||
|
label: stream.label,
|
||||||
|
onPlaybackEnded: playbackEndedHandler(for: player)
|
||||||
|
)
|
||||||
|
viewModel.attachPlayer(player, to: stream.id)
|
||||||
|
scheduleStartupPlaybackRecovery(for: player)
|
||||||
|
logMultiView("playNextWerkoutClip replay id=\(stream.id) url=\(nextURL.absoluteString)")
|
||||||
|
player.playImmediately(atRate: 1.0)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct MultiStreamPlayerLayerView: UIViewRepresentable {
|
private struct MultiStreamPlayerLayerView: UIViewRepresentable {
|
||||||
@@ -742,7 +828,12 @@ private final class MultiStreamPlaybackDiagnostics: ObservableObject {
|
|||||||
private var attachedPlayerIdentifier: ObjectIdentifier?
|
private var attachedPlayerIdentifier: ObjectIdentifier?
|
||||||
private var attachedItemIdentifier: ObjectIdentifier?
|
private var attachedItemIdentifier: ObjectIdentifier?
|
||||||
|
|
||||||
func attach(to player: AVPlayer, streamID: String, label: String) {
|
func attach(
|
||||||
|
to player: AVPlayer,
|
||||||
|
streamID: String,
|
||||||
|
label: String,
|
||||||
|
onPlaybackEnded: (() -> Void)? = nil
|
||||||
|
) {
|
||||||
let playerIdentifier = ObjectIdentifier(player)
|
let playerIdentifier = ObjectIdentifier(player)
|
||||||
let itemIdentifier = player.currentItem.map { ObjectIdentifier($0) }
|
let itemIdentifier = player.currentItem.map { ObjectIdentifier($0) }
|
||||||
if attachedPlayerIdentifier == playerIdentifier, attachedItemIdentifier == itemIdentifier {
|
if attachedPlayerIdentifier == playerIdentifier, attachedItemIdentifier == itemIdentifier {
|
||||||
@@ -824,6 +915,17 @@ private final class MultiStreamPlaybackDiagnostics: ObservableObject {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
notificationTokens.append(
|
||||||
|
NotificationCenter.default.addObserver(
|
||||||
|
forName: .AVPlayerItemDidPlayToEndTime,
|
||||||
|
object: item,
|
||||||
|
queue: .main
|
||||||
|
) { _ in
|
||||||
|
logMultiView("playerItem didPlayToEnd id=\(streamID)")
|
||||||
|
onPlaybackEnded?()
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
notificationTokens.append(
|
notificationTokens.append(
|
||||||
NotificationCenter.default.addObserver(
|
NotificationCenter.default.addObserver(
|
||||||
forName: .AVPlayerItemNewErrorLogEntry,
|
forName: .AVPlayerItemNewErrorLogEntry,
|
||||||
|
|||||||
@@ -24,6 +24,11 @@ private func singleStreamDebugURLDescription(_ url: URL) -> String {
|
|||||||
return "\(url.scheme ?? "unknown")://\(host)\(url.path)\(querySuffix)"
|
return "\(url.scheme ?? "unknown")://\(host)\(url.path)\(querySuffix)"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func singleStreamHeaderKeysDescription(_ headers: [String: String]) -> String {
|
||||||
|
guard !headers.isEmpty else { return "none" }
|
||||||
|
return headers.keys.sorted().joined(separator: ",")
|
||||||
|
}
|
||||||
|
|
||||||
private func singleStreamStatusDescription(_ status: AVPlayer.Status) -> String {
|
private func singleStreamStatusDescription(_ status: AVPlayer.Status) -> String {
|
||||||
switch status {
|
switch status {
|
||||||
case .unknown: "unknown"
|
case .unknown: "unknown"
|
||||||
@@ -51,13 +56,33 @@ private func singleStreamTimeControlDescription(_ status: AVPlayer.TimeControlSt
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func makeSingleStreamPlayerItem(from source: SingleStreamPlaybackSource) -> AVPlayerItem {
|
||||||
|
if source.httpHeaders.isEmpty {
|
||||||
|
let item = AVPlayerItem(url: source.url)
|
||||||
|
item.preferredForwardBufferDuration = 2
|
||||||
|
return item
|
||||||
|
}
|
||||||
|
|
||||||
|
let assetOptions: [String: Any] = [
|
||||||
|
"AVURLAssetHTTPHeaderFieldsKey": source.httpHeaders,
|
||||||
|
]
|
||||||
|
let asset = AVURLAsset(url: source.url, options: assetOptions)
|
||||||
|
let item = AVPlayerItem(asset: asset)
|
||||||
|
item.preferredForwardBufferDuration = 2
|
||||||
|
logSingleStream(
|
||||||
|
"Configured authenticated AVURLAsset headerKeys=\(singleStreamHeaderKeysDescription(source.httpHeaders))"
|
||||||
|
)
|
||||||
|
return item
|
||||||
|
}
|
||||||
|
|
||||||
struct SingleStreamPlaybackScreen: View {
|
struct SingleStreamPlaybackScreen: View {
|
||||||
let resolveURL: @Sendable () async -> URL?
|
let resolveSource: @Sendable () async -> SingleStreamPlaybackSource?
|
||||||
|
var resolveNextSource: (@Sendable (URL?) async -> SingleStreamPlaybackSource?)? = nil
|
||||||
let tickerGames: [Game]
|
let tickerGames: [Game]
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
ZStack(alignment: .bottom) {
|
ZStack(alignment: .bottom) {
|
||||||
SingleStreamPlayerView(resolveURL: resolveURL)
|
SingleStreamPlayerView(resolveSource: resolveSource, resolveNextSource: resolveNextSource)
|
||||||
.ignoresSafeArea()
|
.ignoresSafeArea()
|
||||||
|
|
||||||
SingleStreamScoreStripView(games: tickerGames)
|
SingleStreamScoreStripView(games: tickerGames)
|
||||||
@@ -75,6 +100,16 @@ struct SingleStreamPlaybackScreen: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
struct SingleStreamPlaybackSource: Sendable {
|
||||||
|
let url: URL
|
||||||
|
let httpHeaders: [String: String]
|
||||||
|
|
||||||
|
init(url: URL, httpHeaders: [String: String] = [:]) {
|
||||||
|
self.url = url
|
||||||
|
self.httpHeaders = httpHeaders
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private struct SingleStreamScoreStripView: View {
|
private struct SingleStreamScoreStripView: View {
|
||||||
let games: [Game]
|
let games: [Game]
|
||||||
|
|
||||||
@@ -238,7 +273,8 @@ private final class SingleStreamMarqueeContainerView: UIView {
|
|||||||
|
|
||||||
/// Full-screen player using AVPlayerViewController for PiP support on tvOS.
|
/// Full-screen player using AVPlayerViewController for PiP support on tvOS.
|
||||||
struct SingleStreamPlayerView: UIViewControllerRepresentable {
|
struct SingleStreamPlayerView: UIViewControllerRepresentable {
|
||||||
let resolveURL: @Sendable () async -> URL?
|
let resolveSource: @Sendable () async -> SingleStreamPlaybackSource?
|
||||||
|
var resolveNextSource: (@Sendable (URL?) async -> SingleStreamPlaybackSource?)? = nil
|
||||||
|
|
||||||
func makeCoordinator() -> Coordinator {
|
func makeCoordinator() -> Coordinator {
|
||||||
Coordinator()
|
Coordinator()
|
||||||
@@ -253,13 +289,16 @@ struct SingleStreamPlayerView: UIViewControllerRepresentable {
|
|||||||
|
|
||||||
Task { @MainActor in
|
Task { @MainActor in
|
||||||
let resolveStartedAt = Date()
|
let resolveStartedAt = Date()
|
||||||
logSingleStream("Starting stream URL resolution")
|
logSingleStream("Starting stream source resolution")
|
||||||
guard let url = await resolveURL() else {
|
guard let source = await resolveSource() else {
|
||||||
logSingleStream("resolveURL returned nil; aborting player startup")
|
logSingleStream("resolveSource returned nil; aborting player startup")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
let url = source.url
|
||||||
let resolveElapsedMs = Int(Date().timeIntervalSince(resolveStartedAt) * 1000)
|
let resolveElapsedMs = Int(Date().timeIntervalSince(resolveStartedAt) * 1000)
|
||||||
logSingleStream("Resolved stream URL elapsedMs=\(resolveElapsedMs) url=\(singleStreamDebugURLDescription(url))")
|
logSingleStream(
|
||||||
|
"Resolved stream source elapsedMs=\(resolveElapsedMs) url=\(singleStreamDebugURLDescription(url)) headerKeys=\(singleStreamHeaderKeysDescription(source.httpHeaders))"
|
||||||
|
)
|
||||||
|
|
||||||
do {
|
do {
|
||||||
try AVAudioSession.sharedInstance().setCategory(.playback, mode: .moviePlayback)
|
try AVAudioSession.sharedInstance().setCategory(.playback, mode: .moviePlayback)
|
||||||
@@ -269,15 +308,15 @@ struct SingleStreamPlayerView: UIViewControllerRepresentable {
|
|||||||
logSingleStream("AVAudioSession configuration failed error=\(error.localizedDescription)")
|
logSingleStream("AVAudioSession configuration failed error=\(error.localizedDescription)")
|
||||||
}
|
}
|
||||||
|
|
||||||
let playerItem = AVPlayerItem(url: url)
|
let playerItem = makeSingleStreamPlayerItem(from: source)
|
||||||
playerItem.preferredForwardBufferDuration = 2
|
|
||||||
let player = AVPlayer(playerItem: playerItem)
|
let player = AVPlayer(playerItem: playerItem)
|
||||||
player.automaticallyWaitsToMinimizeStalling = false
|
player.automaticallyWaitsToMinimizeStalling = false
|
||||||
logSingleStream("Configured player for fast start preferredForwardBufferDuration=2 automaticallyWaitsToMinimizeStalling=false")
|
logSingleStream("Configured player for fast start preferredForwardBufferDuration=2 automaticallyWaitsToMinimizeStalling=false")
|
||||||
context.coordinator.attachDebugObservers(to: player, url: url)
|
context.coordinator.attachDebugObservers(to: player, url: url, resolveNextSource: resolveNextSource)
|
||||||
controller.player = player
|
controller.player = player
|
||||||
logSingleStream("AVPlayer assigned to controller; calling playImmediately(atRate: 1.0)")
|
logSingleStream("AVPlayer assigned to controller; calling playImmediately(atRate: 1.0)")
|
||||||
player.playImmediately(atRate: 1.0)
|
player.playImmediately(atRate: 1.0)
|
||||||
|
context.coordinator.scheduleStartupRecovery(for: player)
|
||||||
}
|
}
|
||||||
|
|
||||||
return controller
|
return controller
|
||||||
@@ -293,11 +332,16 @@ struct SingleStreamPlayerView: UIViewControllerRepresentable {
|
|||||||
logSingleStream("dismantleUIViewController complete")
|
logSingleStream("dismantleUIViewController complete")
|
||||||
}
|
}
|
||||||
|
|
||||||
final class Coordinator: NSObject {
|
final class Coordinator: NSObject, @unchecked Sendable {
|
||||||
private var playerObservations: [NSKeyValueObservation] = []
|
private var playerObservations: [NSKeyValueObservation] = []
|
||||||
private var notificationTokens: [NSObjectProtocol] = []
|
private var notificationTokens: [NSObjectProtocol] = []
|
||||||
|
private var startupRecoveryTask: Task<Void, Never>?
|
||||||
|
|
||||||
func attachDebugObservers(to player: AVPlayer, url: URL) {
|
func attachDebugObservers(
|
||||||
|
to player: AVPlayer,
|
||||||
|
url: URL,
|
||||||
|
resolveNextSource: (@Sendable (URL?) async -> SingleStreamPlaybackSource?)? = nil
|
||||||
|
) {
|
||||||
clearDebugObservers()
|
clearDebugObservers()
|
||||||
|
|
||||||
logSingleStream("Attaching AVPlayer observers url=\(singleStreamDebugURLDescription(url))")
|
logSingleStream("Attaching AVPlayer observers url=\(singleStreamDebugURLDescription(url))")
|
||||||
@@ -371,6 +415,32 @@ struct SingleStreamPlayerView: UIViewControllerRepresentable {
|
|||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
notificationTokens.append(
|
||||||
|
NotificationCenter.default.addObserver(
|
||||||
|
forName: .AVPlayerItemDidPlayToEndTime,
|
||||||
|
object: item,
|
||||||
|
queue: .main
|
||||||
|
) { [weak self] _ in
|
||||||
|
logSingleStream("Notification AVPlayerItemDidPlayToEndTime")
|
||||||
|
guard let self, let resolveNextSource else { return }
|
||||||
|
Task { @MainActor [weak self] in
|
||||||
|
guard let self else { return }
|
||||||
|
let currentURL = (player.currentItem?.asset as? AVURLAsset)?.url
|
||||||
|
guard let nextSource = await resolveNextSource(currentURL) else {
|
||||||
|
logSingleStream("Autoplay next source resolution returned nil")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
let nextItem = makeSingleStreamPlayerItem(from: nextSource)
|
||||||
|
player.replaceCurrentItem(with: nextItem)
|
||||||
|
player.automaticallyWaitsToMinimizeStalling = false
|
||||||
|
self.attachDebugObservers(to: player, url: nextSource.url, resolveNextSource: resolveNextSource)
|
||||||
|
logSingleStream("Autoplay replacing item and replaying url=\(singleStreamDebugURLDescription(nextSource.url))")
|
||||||
|
player.playImmediately(atRate: 1.0)
|
||||||
|
self.scheduleStartupRecovery(for: player)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
notificationTokens.append(
|
notificationTokens.append(
|
||||||
NotificationCenter.default.addObserver(
|
NotificationCenter.default.addObserver(
|
||||||
forName: .AVPlayerItemNewErrorLogEntry,
|
forName: .AVPlayerItemNewErrorLogEntry,
|
||||||
@@ -399,7 +469,42 @@ struct SingleStreamPlayerView: UIViewControllerRepresentable {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func scheduleStartupRecovery(for player: AVPlayer) {
|
||||||
|
startupRecoveryTask?.cancel()
|
||||||
|
|
||||||
|
startupRecoveryTask = Task { @MainActor [weak player] in
|
||||||
|
let retryDelays: [Double] = [0.35, 1.0, 2.0, 4.0]
|
||||||
|
|
||||||
|
for delay in retryDelays {
|
||||||
|
try? await Task.sleep(for: .seconds(delay))
|
||||||
|
guard !Task.isCancelled, let player else { return }
|
||||||
|
|
||||||
|
let itemStatus = player.currentItem?.status ?? .unknown
|
||||||
|
let likelyToKeepUp = player.currentItem?.isPlaybackLikelyToKeepUp ?? false
|
||||||
|
let bufferEmpty = player.currentItem?.isPlaybackBufferEmpty ?? false
|
||||||
|
let timeControl = player.timeControlStatus
|
||||||
|
let startupSatisfied = player.rate > 0 && (itemStatus == .readyToPlay || likelyToKeepUp)
|
||||||
|
|
||||||
|
logSingleStream(
|
||||||
|
"startupRecovery check delay=\(delay)s rate=\(player.rate) timeControl=\(singleStreamTimeControlDescription(timeControl)) itemStatus=\(singleStreamItemStatusDescription(itemStatus)) likelyToKeepUp=\(likelyToKeepUp) bufferEmpty=\(bufferEmpty)"
|
||||||
|
)
|
||||||
|
|
||||||
|
if startupSatisfied {
|
||||||
|
logSingleStream("startupRecovery satisfied delay=\(delay)s")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if player.rate == 0 {
|
||||||
|
logSingleStream("startupRecovery replay delay=\(delay)s")
|
||||||
|
player.playImmediately(atRate: 1.0)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func clearDebugObservers() {
|
func clearDebugObservers() {
|
||||||
|
startupRecoveryTask?.cancel()
|
||||||
|
startupRecoveryTask = nil
|
||||||
playerObservations.removeAll()
|
playerObservations.removeAll()
|
||||||
for token in notificationTokens {
|
for token in notificationTokens {
|
||||||
NotificationCenter.default.removeObserver(token)
|
NotificationCenter.default.removeObserver(token)
|
||||||
|
|||||||
Reference in New Issue
Block a user