From 127125ae1bed45af143657b71ba92aca4f3eeecd Mon Sep 17 00:00:00 2001 From: Trey t Date: Thu, 26 Mar 2026 20:53:08 -0500 Subject: [PATCH] Add Werkout channel playback and autoplay --- mlbTVOS/Models/Game.swift | 6 + mlbTVOS/ViewModels/GamesViewModel.swift | 181 ++++++ .../Views/Components/ScoreOverlayView.swift | 4 +- mlbTVOS/Views/DashboardView.swift | 569 +++++++++++++++++- mlbTVOS/Views/MultiStreamView.swift | 116 +++- mlbTVOS/Views/SingleStreamPlayerView.swift | 129 +++- 6 files changed, 957 insertions(+), 48 deletions(-) diff --git a/mlbTVOS/Models/Game.swift b/mlbTVOS/Models/Game.swift index 5491d1a..1ed19ea 100644 --- a/mlbTVOS/Models/Game.swift +++ b/mlbTVOS/Models/Game.swift @@ -47,6 +47,12 @@ struct Game: Identifiable, Sendable { var isLive: Bool { status.isLive } 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 { diff --git a/mlbTVOS/ViewModels/GamesViewModel.swift b/mlbTVOS/ViewModels/GamesViewModel.swift index 00d5cf9..20fbbf3 100644 --- a/mlbTVOS/ViewModels/GamesViewModel.swift +++ b/mlbTVOS/ViewModels/GamesViewModel.swift @@ -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? + @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 diff --git a/mlbTVOS/Views/Components/ScoreOverlayView.swift b/mlbTVOS/Views/Components/ScoreOverlayView.swift index 3400f93..b3ee1de 100644 --- a/mlbTVOS/Views/Components/ScoreOverlayView.swift +++ b/mlbTVOS/Views/Components/ScoreOverlayView.swift @@ -4,8 +4,8 @@ struct ScoreOverlayView: View { let game: Game var body: some View { - // Don't show for non-game streams (e.g., MLB Network) - if game.id == "MLBN" { + // Don't show for non-game channel streams. + if game.isSpecialChannel { EmptyView() } else { HStack(spacing: 10) { diff --git a/mlbTVOS/Views/DashboardView.swift b/mlbTVOS/Views/DashboardView.swift index edb30f3..ee96356 100644 --- a/mlbTVOS/Views/DashboardView.swift +++ b/mlbTVOS/Views/DashboardView.swift @@ -8,12 +8,53 @@ private func logDashboard(_ message: String) { 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 { @Environment(GamesViewModel.self) private var viewModel @State private var selectedGame: Game? @State private var fullScreenBroadcast: BroadcastSelection? @State private var pendingFullScreenBroadcast: BroadcastSelection? @State private var showMLBNetworkSheet = false + @State private var showWerkoutNSFWSheet = false var body: some View { ScrollView { @@ -64,7 +105,7 @@ struct DashboardView: View { } } - mlbNetworkCard + featuredChannelsSection if !viewModel.activeStreams.isEmpty { multiViewStatus @@ -88,29 +129,7 @@ struct DashboardView: View { selectedGame = nil } } - .fullScreenCover(item: $fullScreenBroadcast) { selection in - 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)") - } - } + .fullScreenCover(item: $fullScreenBroadcast, content: fullScreenPlaybackScreen) .onChange(of: fullScreenBroadcast?.id) { _, newValue in 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() { - guard selectedGame == nil, !showMLBNetworkSheet else { + guard selectedGame == nil, !showMLBNetworkSheet, !showWerkoutNSFWSheet else { logDashboard("Skipped pending fullscreen presentation because another sheet is still active") 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 { let bc = Broadcast( id: "MLBN", @@ -160,6 +279,13 @@ struct DashboardView: View { return BroadcastSelection(broadcast: bc, game: game) } + private var nsfwVideosBroadcastSelection: BroadcastSelection { + return BroadcastSelection( + broadcast: SpecialPlaybackChannelConfig.werkoutNSFWBroadcast, + game: SpecialPlaybackChannelConfig.werkoutNSFWGame + ) + } + // MARK: - Game Shelf (Horizontal) @ViewBuilder @@ -262,7 +388,18 @@ struct DashboardView: View { .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 private var mlbNetworkCard: some View { @@ -294,6 +431,49 @@ struct DashboardView: View { .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) .background(.regularMaterial) .clipShape(RoundedRectangle(cornerRadius: 20)) @@ -331,10 +511,345 @@ struct BroadcastSelection: Identifiable { let id: String let broadcast: Broadcast let game: Game + let directSource: SingleStreamPlaybackSource? - init(broadcast: Broadcast, game: Game) { + init( + broadcast: Broadcast, + game: Game, + directSource: SingleStreamPlaybackSource? = nil + ) { self.id = broadcast.id self.broadcast = broadcast 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) + } } } diff --git a/mlbTVOS/Views/MultiStreamView.swift b/mlbTVOS/Views/MultiStreamView.swift index 44cbe70..07fd3be 100644 --- a/mlbTVOS/Views/MultiStreamView.swift +++ b/mlbTVOS/Views/MultiStreamView.swift @@ -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 { @Environment(GamesViewModel.self) private var viewModel @State private var selectedStream: StreamSelection? @@ -497,11 +502,18 @@ private struct MultiStreamTile: View { } 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 { 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) scheduleQualityUpgrade(for: player) 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 self.player = existingPlayer 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) scheduleQualityUpgrade(for: existingPlayer) logMultiView("startStream reused shared player id=\(stream.id) muted=\(existingPlayer.isMuted)") @@ -544,13 +561,17 @@ private struct MultiStreamTile: View { return } - logMultiView("startStream creating AVPlayer id=\(stream.id) url=\(url.absoluteString)") - let avPlayer = AVPlayer(url: url) + let avPlayer = makePlayer(url: url, headers: stream.overrideHeaders) avPlayer.automaticallyWaitsToMinimizeStalling = false avPlayer.currentItem?.preferredForwardBufferDuration = 2 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) scheduleStartupPlaybackRecovery(for: avPlayer) scheduleQualityUpgrade(for: avPlayer) @@ -558,6 +579,28 @@ private struct MultiStreamTile: View { 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) { startupPlaybackTask?.cancel() @@ -681,6 +724,49 @@ private struct MultiStreamTile: View { .first(where: { $0.name == "resolution" })? .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 { @@ -742,7 +828,12 @@ private final class MultiStreamPlaybackDiagnostics: ObservableObject { private var attachedPlayerIdentifier: 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 itemIdentifier = player.currentItem.map { ObjectIdentifier($0) } 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( NotificationCenter.default.addObserver( forName: .AVPlayerItemNewErrorLogEntry, diff --git a/mlbTVOS/Views/SingleStreamPlayerView.swift b/mlbTVOS/Views/SingleStreamPlayerView.swift index 933b5a6..a82dbef 100644 --- a/mlbTVOS/Views/SingleStreamPlayerView.swift +++ b/mlbTVOS/Views/SingleStreamPlayerView.swift @@ -24,6 +24,11 @@ private func singleStreamDebugURLDescription(_ url: URL) -> String { 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 { switch status { 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 { - let resolveURL: @Sendable () async -> URL? + let resolveSource: @Sendable () async -> SingleStreamPlaybackSource? + var resolveNextSource: (@Sendable (URL?) async -> SingleStreamPlaybackSource?)? = nil let tickerGames: [Game] var body: some View { ZStack(alignment: .bottom) { - SingleStreamPlayerView(resolveURL: resolveURL) + SingleStreamPlayerView(resolveSource: resolveSource, resolveNextSource: resolveNextSource) .ignoresSafeArea() 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 { let games: [Game] @@ -238,7 +273,8 @@ private final class SingleStreamMarqueeContainerView: UIView { /// Full-screen player using AVPlayerViewController for PiP support on tvOS. struct SingleStreamPlayerView: UIViewControllerRepresentable { - let resolveURL: @Sendable () async -> URL? + let resolveSource: @Sendable () async -> SingleStreamPlaybackSource? + var resolveNextSource: (@Sendable (URL?) async -> SingleStreamPlaybackSource?)? = nil func makeCoordinator() -> Coordinator { Coordinator() @@ -253,13 +289,16 @@ struct SingleStreamPlayerView: UIViewControllerRepresentable { Task { @MainActor in let resolveStartedAt = Date() - logSingleStream("Starting stream URL resolution") - guard let url = await resolveURL() else { - logSingleStream("resolveURL returned nil; aborting player startup") + logSingleStream("Starting stream source resolution") + guard let source = await resolveSource() else { + logSingleStream("resolveSource returned nil; aborting player startup") return } + let url = source.url 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 { try AVAudioSession.sharedInstance().setCategory(.playback, mode: .moviePlayback) @@ -269,15 +308,15 @@ struct SingleStreamPlayerView: UIViewControllerRepresentable { logSingleStream("AVAudioSession configuration failed error=\(error.localizedDescription)") } - let playerItem = AVPlayerItem(url: url) - playerItem.preferredForwardBufferDuration = 2 + let playerItem = makeSingleStreamPlayerItem(from: source) let player = AVPlayer(playerItem: playerItem) player.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 logSingleStream("AVPlayer assigned to controller; calling playImmediately(atRate: 1.0)") player.playImmediately(atRate: 1.0) + context.coordinator.scheduleStartupRecovery(for: player) } return controller @@ -293,11 +332,16 @@ struct SingleStreamPlayerView: UIViewControllerRepresentable { logSingleStream("dismantleUIViewController complete") } - final class Coordinator: NSObject { + final class Coordinator: NSObject, @unchecked Sendable { private var playerObservations: [NSKeyValueObservation] = [] private var notificationTokens: [NSObjectProtocol] = [] + private var startupRecoveryTask: Task? - func attachDebugObservers(to player: AVPlayer, url: URL) { + func attachDebugObservers( + to player: AVPlayer, + url: URL, + resolveNextSource: (@Sendable (URL?) async -> SingleStreamPlaybackSource?)? = nil + ) { clearDebugObservers() 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( NotificationCenter.default.addObserver( 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() { + startupRecoveryTask?.cancel() + startupRecoveryTask = nil playerObservations.removeAll() for token in notificationTokens { NotificationCenter.default.removeObserver(token)