Files
MLBApp/mlbTVOS/Views/DashboardView.swift
Trey t 58e4c36963 Fix video autoplay reliability, add shuffle playback, refresh data on appear
- Remove cache expiry on video feed (fetch once, keep for session)
- Add retry logic (3 attempts with backoff) for autoplay resolution
- Replace random video selection with shuffle-bag (no repeats until all played)
- Reload games every time DashboardView appears
- Cache standings per day to avoid redundant fetches

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-30 21:49:54 -05:00

934 lines
34 KiB
Swift

import SwiftUI
import OSLog
private let dashboardLogger = Logger(subsystem: "com.treyt.mlbTVOS", category: "Dashboard")
private func logDashboard(_ message: String) {
dashboardLogger.debug("\(message, privacy: .public)")
print("[Dashboard] \(message)")
}
enum SpecialPlaybackChannelConfig {
static let werkoutNSFWStreamID = "WKNSFW"
static let werkoutNSFWTitle = "Werkout NSFW"
static let werkoutNSFWSubtitle = "Authenticated OF media feed"
static let werkoutNSFWFeedURLString = "https://ofapp.treytartt.com/api/media?users=tabatachang,werkout&type=video"
static let werkoutNSFWCookie = "ofapp_token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjEsImlhdCI6MTc3NDY0MjEyNiwiZXhwIjoxNzc1MjQ2OTI2fQ.rDvZzLQ70MM9drHwA8hRxmqcTTgBGBHXxv1Cc55HSqc"
static let werkoutNSFWTeamCode = "WK"
static var werkoutNSFWHeaders: [String: String] {
[
"Cookie": werkoutNSFWCookie,
]
}
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
@Environment(\.horizontalSizeClass) private var horizontalSizeClass
@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
private var horizontalPadding: CGFloat {
#if os(iOS)
horizontalSizeClass == .compact ? 20 : 32
#else
60
#endif
}
private var verticalPadding: CGFloat {
#if os(iOS)
horizontalSizeClass == .compact ? 24 : 32
#else
40
#endif
}
private var contentSpacing: CGFloat {
#if os(iOS)
horizontalSizeClass == .compact ? 32 : 42
#else
50
#endif
}
private var shelfCardWidth: CGFloat {
#if os(iOS)
horizontalSizeClass == .compact ? 300 : 360
#else
400
#endif
}
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: contentSpacing) {
headerSection
if viewModel.isLoading {
HStack {
Spacer()
ProgressView("Loading games...")
.font(.title3)
Spacer()
}
.padding(.top, 80)
} else if let error = viewModel.errorMessage, viewModel.games.isEmpty {
HStack {
Spacer()
VStack(spacing: 20) {
Image(systemName: "exclamationmark.triangle")
.font(.system(size: 50))
.foregroundStyle(.secondary)
Text(error)
.font(.title3)
.foregroundStyle(.secondary)
Button("Retry") {
Task { await viewModel.loadGames() }
}
}
Spacer()
}
.padding(.top, 80)
} else {
// Hero featured game
if let featured = viewModel.featuredGame {
FeaturedGameCard(game: featured) {
selectedGame = featured
}
}
if !viewModel.liveGames.isEmpty {
gameShelf(title: "Live", icon: "antenna.radiowaves.left.and.right", games: viewModel.liveGames, excludeId: viewModel.featuredGame?.id)
}
if !viewModel.scheduledGames.isEmpty {
gameShelf(title: "Upcoming", icon: "calendar", games: viewModel.scheduledGames, excludeId: viewModel.featuredGame?.id)
}
if !viewModel.finalGames.isEmpty {
gameShelf(title: "Final", icon: "checkmark.circle", games: viewModel.finalGames, excludeId: viewModel.featuredGame?.id)
}
}
featuredChannelsSection
if !viewModel.activeStreams.isEmpty {
multiViewStatus
}
}
.padding(.horizontal, horizontalPadding)
.padding(.vertical, verticalPadding)
}
.onAppear {
logDashboard("DashboardView appeared")
viewModel.startAutoRefresh()
Task { await viewModel.loadGames() }
}
.onDisappear {
logDashboard("DashboardView disappeared")
viewModel.stopAutoRefresh()
}
.sheet(item: $selectedGame, onDismiss: presentPendingFullScreenBroadcast) { game in
StreamOptionsSheet(game: game) { broadcast in
logDashboard("Queued fullscreen broadcast from game sheet broadcastId=\(broadcast.broadcast.id) gameId=\(broadcast.game.id)")
pendingFullScreenBroadcast = broadcast
selectedGame = nil
}
}
.fullScreenCover(item: $fullScreenBroadcast, content: fullScreenPlaybackScreen)
.onChange(of: fullScreenBroadcast?.id) { _, newValue in
logDashboard("fullScreenBroadcast changed newValue=\(newValue ?? "nil")")
}
.sheet(isPresented: $showMLBNetworkSheet, onDismiss: presentPendingFullScreenBroadcast) {
MLBNetworkSheet(
onWatchFullScreen: {
logDashboard("Queued fullscreen broadcast from MLB Network sheet")
showMLBNetworkSheet = false
pendingFullScreenBroadcast = mlbNetworkBroadcastSelection
}
)
}
.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, !showWerkoutNSFWSheet else {
logDashboard("Skipped pending fullscreen presentation because another sheet is still active")
return
}
guard let pendingFullScreenBroadcast else { return }
logDashboard(
"Presenting pending fullscreen broadcast broadcastId=\(pendingFullScreenBroadcast.broadcast.id) gameId=\(pendingFullScreenBroadcast.game.id)"
)
self.pendingFullScreenBroadcast = nil
DispatchQueue.main.async {
fullScreenBroadcast = pendingFullScreenBroadcast
}
}
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,
forceMuteAudio: true
)
}
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
}
var nextURL: URL?
for attempt in 1...3 {
nextURL = await viewModel.resolveAuthenticatedVideoFeedURL(
feedURL: SpecialPlaybackChannelConfig.werkoutNSFWFeedURL,
headers: SpecialPlaybackChannelConfig.werkoutNSFWHeaders,
excluding: currentURL
)
if nextURL != nil { break }
logDashboard("resolveNextFullScreenSource retry attempt=\(attempt)/3")
if attempt < 3 {
try? await Task.sleep(nanoseconds: UInt64(attempt) * 500_000_000)
}
}
guard let nextURL else {
return nil
}
return SingleStreamPlaybackSource(
url: nextURL,
httpHeaders: SpecialPlaybackChannelConfig.werkoutNSFWHeaders,
forceMuteAudio: true
)
}
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",
teamCode: "MLBN",
name: "MLB Network",
mediaId: "",
streamURL: ""
)
let game = Game(
id: "MLBN",
awayTeam: TeamInfo(code: "MLBN", name: "MLB Network", score: nil),
homeTeam: TeamInfo(code: "MLBN", name: "MLB Network", score: nil),
status: .live(nil), gameType: nil, startTime: nil, venue: nil,
pitchers: nil, gamePk: nil, gameDate: "",
broadcasts: [], isBlackedOut: false
)
return BroadcastSelection(broadcast: bc, game: game)
}
private var nsfwVideosBroadcastSelection: BroadcastSelection {
return BroadcastSelection(
broadcast: SpecialPlaybackChannelConfig.werkoutNSFWBroadcast,
game: SpecialPlaybackChannelConfig.werkoutNSFWGame
)
}
// MARK: - Game Shelf (Horizontal)
@ViewBuilder
private func gameShelf(title: String, icon: String, games: [Game], excludeId: String?) -> some View {
let filtered = games.filter { $0.id != excludeId }
if !filtered.isEmpty {
VStack(alignment: .leading, spacing: 20) {
Label(title, systemImage: icon)
.font(.title3.weight(.bold))
.foregroundStyle(.secondary)
ScrollView(.horizontal) {
LazyHStack(spacing: 30) {
ForEach(filtered) { game in
GameCardView(game: game) {
selectedGame = game
}
.frame(width: shelfCardWidth)
}
}
.padding(.vertical, 12)
}
.scrollClipDisabled()
}
}
}
// MARK: - Header
@ViewBuilder
private var headerSection: some View {
VStack(spacing: 24) {
HStack(alignment: .bottom) {
VStack(alignment: .leading, spacing: 6) {
Text("MLB")
.font(.headline.weight(.black))
.foregroundStyle(.secondary)
.kerning(4)
Text(viewModel.displayDateString)
.font(.system(size: 40, weight: .bold))
.contentTransition(.numericText())
}
Spacer()
HStack(spacing: 16) {
statPill("\(viewModel.games.count)", label: "Games")
if !viewModel.liveGames.isEmpty {
statPill("\(viewModel.liveGames.count)", label: "Live", color: .red)
}
if !viewModel.activeStreams.isEmpty {
statPill("\(viewModel.activeStreams.count)/4", label: "Streams", color: .green)
}
}
}
HStack(spacing: 16) {
Button {
Task { await viewModel.goToPreviousDay() }
} label: {
Label("Previous Day", systemImage: "chevron.left")
}
if !viewModel.isToday {
Button {
Task { await viewModel.goToToday() }
} label: {
Label("Today", systemImage: "calendar")
}
.tint(.blue)
}
Button {
Task { await viewModel.goToNextDay() }
} label: {
HStack(spacing: 6) {
Text("Next Day")
Image(systemName: "chevron.right")
}
}
Spacer()
}
}
}
@ViewBuilder
private func statPill(_ value: String, label: String, color: Color = .blue) -> some View {
VStack(spacing: 2) {
Text(value)
.font(.title3.weight(.bold).monospacedDigit())
.foregroundStyle(color)
Text(label)
.font(.subheadline.weight(.medium))
.foregroundStyle(.secondary)
}
.padding(.horizontal, 20)
.padding(.vertical, 10)
.background(.regularMaterial)
.clipShape(RoundedRectangle(cornerRadius: 14))
}
// MARK: - Featured Channels
@ViewBuilder
private var featuredChannelsSection: some View {
ViewThatFits {
HStack(alignment: .top, spacing: 24) {
mlbNetworkCard
.frame(maxWidth: .infinity)
nsfwVideosCard
.frame(maxWidth: .infinity)
}
VStack(alignment: .leading, spacing: 16) {
mlbNetworkCard
nsfwVideosCard
}
}
}
@ViewBuilder
private var mlbNetworkCard: some View {
let added = viewModel.activeStreams.contains(where: { $0.id == "MLBN" })
Button {
showMLBNetworkSheet = true
} label: {
HStack(spacing: 16) {
Image(systemName: "tv.fill")
.font(.title2)
.foregroundStyle(.blue)
.frame(width: 56, height: 56)
.background(.blue.opacity(0.2))
.clipShape(RoundedRectangle(cornerRadius: 14))
VStack(alignment: .leading, spacing: 4) {
Text("MLB Network")
.font(.title3.weight(.bold))
Text("Live coverage, analysis & highlights")
.font(.subheadline)
.foregroundStyle(.secondary)
}
Spacer()
if added {
Label("In Multi-View", systemImage: "checkmark.circle.fill")
.font(.subheadline.weight(.bold))
.foregroundStyle(.green)
}
}
.frame(maxWidth: .infinity, minHeight: 108, alignment: .leading)
.padding(24)
.background(.regularMaterial)
.clipShape(RoundedRectangle(cornerRadius: 20))
}
.platformCardStyle()
}
@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))
}
.platformCardStyle()
}
// MARK: - Multi-View Status
@ViewBuilder
private var multiViewStatus: some View {
VStack(alignment: .leading, spacing: 14) {
Label("Multi-View", systemImage: "rectangle.split.2x2")
.font(.title3.weight(.bold))
.foregroundStyle(.secondary)
HStack(spacing: 12) {
ForEach(viewModel.activeStreams) { stream in
HStack(spacing: 8) {
Circle().fill(.green).frame(width: 8, height: 8)
Text(stream.label)
.font(.subheadline.weight(.semibold))
}
.padding(.horizontal, 14)
.padding(.vertical, 8)
.background(.regularMaterial)
.clipShape(Capsule())
}
}
}
}
}
struct BroadcastSelection: Identifiable {
let id: String
let broadcast: Broadcast
let game: Game
let directSource: SingleStreamPlaybackSource?
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(\.horizontalSizeClass) private var horizontalSizeClass
@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
}
private var usesStackedLayout: Bool {
#if os(iOS)
horizontalSizeClass == .compact
#else
false
#endif
}
private var outerHorizontalPadding: CGFloat {
usesStackedLayout ? 20 : 94
}
private var outerVerticalPadding: CGFloat {
usesStackedLayout ? 24 : 70
}
var body: some View {
ZStack {
sheetBackground
.ignoresSafeArea()
ViewThatFits {
HStack(alignment: .top, spacing: 32) {
overviewColumn
.frame(maxWidth: .infinity, alignment: .leading)
actionColumn
.frame(width: 360, alignment: .leading)
}
VStack(alignment: .leading, spacing: 24) {
overviewColumn
actionColumn
.frame(maxWidth: .infinity, 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, outerHorizontalPadding)
.padding(.vertical, outerVerticalPadding)
}
}
@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,
forceMuteAudio: true
)
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)
)
}
.platformCardStyle()
.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)
}
}
}