Files
MLBApp/mlbTVOS/Views/DashboardView.swift
Trey t ba24c767a0 Improve stream quality: stop capping resolution, allow AVPlayer to ramp
SingleStream: pass preserveServerResolutionWhenBest=false so "best"
always reaches the server for a full multi-variant manifest. Increase
buffer to 8s and enable automaticallyWaitsToMinimizeStalling so AVPlayer
can measure bandwidth and select higher variants. Add quality monitor
that nudges AVPlayer if observed bandwidth far exceeds indicated bitrate.

MultiStream: remove broken URL-param resolution detection that falsely
skipped upgrades, log actual indicatedBitrate instead. Extend upgrade
check windows from [2,4,7]s to [2,4,7,15,30]s for slow-to-stabilize
streams.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 12:38:38 -05:00

943 lines
35 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,kayla-lauren,ray-mattos,josie-hamming-2,dani-speegle-2&type=video"
static let werkoutNSFWCookie = "ofapp_token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VySWQiOjEsImlhdCI6MTc3NDY0MjEyNiwiZXhwIjoxNzc3MjM0MTI2fQ.RdYGvyBPbR6tmK0kaEVhSnIVW6TZlm3Ef42Lxpvl_oQ"
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
@State private var isPiPActive = 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),
game: selection.game,
onPiPActiveChanged: { active in
isPiPActive = active
}
)
.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,
resolutionOverride: viewModel.defaultResolution,
preserveServerResolutionWhenBest: false
) 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)
}
}
}