Quick Channels (MLB Network + Werkout NSFW) were cramped in the right
sidebar with text wrapping ("Networ k"). Moved them out of controlRail
into the main content flow as a full-width HStack. Each card takes
50% width with proper text display. Positioned between the hero section
and game shelves.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1362 lines
52 KiB
Swift
1362 lines
52 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 ? 340 : 500
|
|
#else
|
|
540
|
|
#endif
|
|
}
|
|
|
|
private var controlRailWidth: CGFloat {
|
|
#if os(iOS)
|
|
horizontalSizeClass == .compact ? 0 : 300
|
|
#else
|
|
340
|
|
#endif
|
|
}
|
|
|
|
private var usesStackedHeroLayout: Bool {
|
|
#if os(iOS)
|
|
horizontalSizeClass == .compact
|
|
#else
|
|
false
|
|
#endif
|
|
}
|
|
|
|
private var radarGames: [Game] {
|
|
if !viewModel.liveGames.isEmpty {
|
|
return Array(viewModel.liveGames.prefix(4))
|
|
}
|
|
if !viewModel.scheduledGames.isEmpty {
|
|
return Array(viewModel.scheduledGames.prefix(4))
|
|
}
|
|
return Array(viewModel.finalGames.prefix(4))
|
|
}
|
|
|
|
var body: some View {
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: contentSpacing) {
|
|
headerSection
|
|
.platformFocusSection()
|
|
|
|
if viewModel.isLoading {
|
|
loadingState
|
|
} else if let error = viewModel.errorMessage, viewModel.games.isEmpty {
|
|
errorState(error)
|
|
} else {
|
|
overviewStrip
|
|
.platformFocusSection()
|
|
heroAndControlSection
|
|
.platformFocusSection()
|
|
|
|
featuredChannelsSection
|
|
.platformFocusSection()
|
|
|
|
if !viewModel.liveGames.isEmpty {
|
|
gameShelf(
|
|
title: "Live Board",
|
|
subtitle: "Open games with inning state, records, and stream availability.",
|
|
icon: "antenna.radiowaves.left.and.right",
|
|
accent: DS.Colors.live,
|
|
games: viewModel.liveGames,
|
|
excludeId: viewModel.featuredGame?.id
|
|
)
|
|
}
|
|
if !viewModel.scheduledGames.isEmpty {
|
|
gameShelf(
|
|
title: "Upcoming Windows",
|
|
subtitle: "Probables, first pitch, and watch-ready cards for the rest of the slate.",
|
|
icon: "calendar",
|
|
accent: DS.Colors.warning,
|
|
games: viewModel.scheduledGames,
|
|
excludeId: viewModel.featuredGame?.id
|
|
)
|
|
}
|
|
if !viewModel.finalGames.isEmpty {
|
|
gameShelf(
|
|
title: "Completed Games",
|
|
subtitle: "Finished scoreboards ready for replays, box scores, and highlights.",
|
|
icon: "checkmark.circle",
|
|
accent: DS.Colors.positive,
|
|
games: viewModel.finalGames,
|
|
excludeId: viewModel.featuredGame?.id
|
|
)
|
|
}
|
|
}
|
|
}
|
|
.padding(.horizontal, horizontalPadding)
|
|
.padding(.vertical, verticalPadding)
|
|
}
|
|
.scrollIndicators(.hidden)
|
|
.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
|
|
)
|
|
}
|
|
|
|
private var loadingState: some View {
|
|
VStack(spacing: 18) {
|
|
ProgressView()
|
|
.scaleEffect(1.3)
|
|
Text("Loading the daily board")
|
|
.font(sectionTitleFont)
|
|
.foregroundStyle(DS.Colors.textPrimary)
|
|
Text("Scores, streams, and matchup context are on the way.")
|
|
.font(sectionBodyFont)
|
|
.foregroundStyle(DS.Colors.textSecondary)
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.vertical, 120)
|
|
.background(surfaceCardBackground())
|
|
}
|
|
|
|
private func errorState(_ error: String) -> some View {
|
|
VStack(spacing: 18) {
|
|
Image(systemName: "exclamationmark.triangle.fill")
|
|
.font(.system(size: 44, weight: .bold))
|
|
.foregroundStyle(DS.Colors.warning)
|
|
|
|
Text(error)
|
|
.font(sectionTitleFont)
|
|
.foregroundStyle(DS.Colors.textPrimary)
|
|
|
|
Button("Reload Board") {
|
|
Task { await viewModel.loadGames() }
|
|
}
|
|
.padding(.horizontal, 22)
|
|
.padding(.vertical, 14)
|
|
.background(
|
|
Capsule()
|
|
.fill(DS.Colors.interactive)
|
|
)
|
|
.foregroundStyle(Color.black.opacity(0.82))
|
|
.platformCardStyle()
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
.padding(.vertical, 110)
|
|
.background(surfaceCardBackground())
|
|
}
|
|
|
|
private var headerSection: some View {
|
|
ViewThatFits {
|
|
HStack(alignment: .bottom, spacing: 28) {
|
|
headerCopy
|
|
Spacer(minLength: 16)
|
|
dateNavigator
|
|
}
|
|
|
|
VStack(alignment: .leading, spacing: 22) {
|
|
headerCopy
|
|
dateNavigator
|
|
}
|
|
}
|
|
}
|
|
|
|
private var headerCopy: some View {
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
Text("Daily Control Room")
|
|
.font(headerTitleFont)
|
|
.foregroundStyle(DS.Colors.textPrimary)
|
|
|
|
Text("A broadcast-grade slate view with live radar, featured watch windows, and fast access to every stream.")
|
|
.font(headerBodyFont)
|
|
.foregroundStyle(DS.Colors.textSecondary)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
}
|
|
}
|
|
|
|
private var dateNavigator: some View {
|
|
HStack(spacing: 12) {
|
|
navigatorButton(systemImage: "chevron.left") {
|
|
Task { await viewModel.goToPreviousDay() }
|
|
}
|
|
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text(viewModel.isToday ? "Today" : "Archive Day")
|
|
.font(dateLabelFont)
|
|
.foregroundStyle(DS.Colors.textTertiary)
|
|
|
|
Text(viewModel.displayDateString)
|
|
.font(dateFont)
|
|
.foregroundStyle(DS.Colors.textPrimary)
|
|
.contentTransition(.numericText())
|
|
}
|
|
|
|
navigatorButton(systemImage: "chevron.right") {
|
|
Task { await viewModel.goToNextDay() }
|
|
}
|
|
|
|
if !viewModel.isToday {
|
|
Button {
|
|
Task { await viewModel.goToToday() }
|
|
} label: {
|
|
Text("Jump to Today")
|
|
.font(todayBtnFont)
|
|
.foregroundStyle(Color.black.opacity(0.84))
|
|
.padding(.horizontal, 18)
|
|
.padding(.vertical, 14)
|
|
.background(
|
|
Capsule()
|
|
.fill(DS.Colors.interactive)
|
|
)
|
|
}
|
|
.platformCardStyle()
|
|
}
|
|
}
|
|
.padding(.horizontal, 22)
|
|
.padding(.vertical, 18)
|
|
.background(surfaceCardBackground(radius: 26))
|
|
}
|
|
|
|
private func navigatorButton(systemImage: String, action: @escaping () -> Void) -> some View {
|
|
Button(action: action) {
|
|
Image(systemName: systemImage)
|
|
.font(.system(size: dateNavIconSize, weight: .bold))
|
|
.foregroundStyle(DS.Colors.textPrimary)
|
|
.frame(width: 52, height: 52)
|
|
.background(
|
|
Circle()
|
|
.fill(DS.Colors.panelFillMuted)
|
|
)
|
|
}
|
|
.platformCardStyle()
|
|
}
|
|
|
|
private var overviewStrip: some View {
|
|
ViewThatFits {
|
|
HStack(spacing: 18) {
|
|
metricTile(value: "\(viewModel.liveGames.count)", label: "Live Games", detail: liveStatusDetail, systemImage: "dot.radiowaves.left.and.right", tint: DS.Colors.live)
|
|
metricTile(value: "\(viewModel.scheduledGames.count)", label: "Upcoming", detail: "First pitches still ahead", systemImage: "calendar", tint: DS.Colors.warning)
|
|
metricTile(value: "\(viewModel.finalGames.count)", label: "Final", detail: "Completed scoreboards", systemImage: "flag.checkered.2.crossed", tint: DS.Colors.positive)
|
|
metricTile(value: "\(viewModel.activeStreams.count)", label: "Stream Tiles", detail: activeAudioDetail, systemImage: "rectangle.split.2x2", tint: DS.Colors.media)
|
|
}
|
|
|
|
ScrollView(.horizontal, showsIndicators: false) {
|
|
HStack(spacing: 18) {
|
|
metricTile(value: "\(viewModel.liveGames.count)", label: "Live Games", detail: liveStatusDetail, systemImage: "dot.radiowaves.left.and.right", tint: DS.Colors.live)
|
|
metricTile(value: "\(viewModel.scheduledGames.count)", label: "Upcoming", detail: "First pitches still ahead", systemImage: "calendar", tint: DS.Colors.warning)
|
|
metricTile(value: "\(viewModel.finalGames.count)", label: "Final", detail: "Completed scoreboards", systemImage: "flag.checkered.2.crossed", tint: DS.Colors.positive)
|
|
metricTile(value: "\(viewModel.activeStreams.count)", label: "Stream Tiles", detail: activeAudioDetail, systemImage: "rectangle.split.2x2", tint: DS.Colors.media)
|
|
}
|
|
.padding(.vertical, 4)
|
|
}
|
|
.scrollClipDisabled()
|
|
}
|
|
}
|
|
|
|
private var liveStatusDetail: String {
|
|
if viewModel.liveGames.isEmpty {
|
|
return "No live first pitch yet"
|
|
}
|
|
return "\(viewModel.liveGames.count) games active now"
|
|
}
|
|
|
|
private var activeAudioDetail: String {
|
|
if let activeAudio = viewModel.activeAudioStream {
|
|
return "Audio: \(activeAudio.game.awayTeam.code) @ \(activeAudio.game.homeTeam.code)"
|
|
}
|
|
if isPiPActive {
|
|
return "Picture in Picture active"
|
|
}
|
|
return "Quadbox ready"
|
|
}
|
|
|
|
private func metricTile(value: String, label: String, detail: String, systemImage: String, tint: Color) -> some View {
|
|
HStack(alignment: .top, spacing: 14) {
|
|
Image(systemName: systemImage)
|
|
.font(.system(size: 20, weight: .bold))
|
|
.foregroundStyle(tint)
|
|
.frame(width: 28, height: 28)
|
|
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
Text(value)
|
|
.font(metricValueFont)
|
|
.foregroundStyle(DS.Colors.textPrimary)
|
|
.monospacedDigit()
|
|
|
|
Text(label)
|
|
.font(metricLabelFont)
|
|
.foregroundStyle(DS.Colors.textSecondary)
|
|
|
|
Text(detail)
|
|
.font(metricDetailFont)
|
|
.foregroundStyle(DS.Colors.textTertiary)
|
|
.lineLimit(2)
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.padding(.horizontal, 20)
|
|
.padding(.vertical, 18)
|
|
.background(surfaceCardBackground(radius: 26))
|
|
}
|
|
|
|
private var heroAndControlSection: some View {
|
|
Group {
|
|
if usesStackedHeroLayout {
|
|
VStack(alignment: .leading, spacing: 22) {
|
|
featuredHero
|
|
controlRail
|
|
}
|
|
} else {
|
|
HStack(alignment: .top, spacing: 24) {
|
|
featuredHero
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
controlRail
|
|
.frame(width: controlRailWidth, alignment: .leading)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private var featuredHero: some View {
|
|
Group {
|
|
if let featured = viewModel.featuredGame {
|
|
FeaturedGameCard(game: featured) {
|
|
selectedGame = featured
|
|
}
|
|
} else {
|
|
VStack(alignment: .leading, spacing: 16) {
|
|
Text("No featured matchup")
|
|
.font(DS.Fonts.sectionTitle)
|
|
.foregroundStyle(DS.Colors.textPrimary)
|
|
Text("As soon as the slate populates, the best watch window appears here with scores, context, and stream access.")
|
|
.font(DS.Fonts.body)
|
|
.foregroundStyle(DS.Colors.textSecondary)
|
|
}
|
|
.frame(maxWidth: .infinity, minHeight: 320, alignment: .leading)
|
|
.padding(32)
|
|
.background(surfaceCardBackground(radius: 34))
|
|
}
|
|
}
|
|
}
|
|
|
|
private var controlRail: some View {
|
|
VStack(alignment: .leading, spacing: 18) {
|
|
liveRadarPanel
|
|
multiViewStatus
|
|
}
|
|
}
|
|
|
|
private var liveRadarPanel: some View {
|
|
VStack(alignment: .leading, spacing: 18) {
|
|
HStack {
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
Text("Live Radar")
|
|
.font(railTitleFont)
|
|
.foregroundStyle(DS.Colors.textPrimary)
|
|
|
|
Text(radarSubtitle)
|
|
.font(railBodyFont)
|
|
.foregroundStyle(DS.Colors.textSecondary)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
}
|
|
Spacer()
|
|
}
|
|
|
|
if radarGames.isEmpty {
|
|
Text("No games loaded yet.")
|
|
.font(railBodyFont)
|
|
.foregroundStyle(DS.Colors.textTertiary)
|
|
} else {
|
|
VStack(spacing: 12) {
|
|
ForEach(radarGames) { game in
|
|
radarRow(game)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.padding(24)
|
|
.background(surfaceCardBackground())
|
|
}
|
|
|
|
private var radarSubtitle: String {
|
|
if !viewModel.liveGames.isEmpty {
|
|
return "Fast board for the most active windows."
|
|
}
|
|
if !viewModel.scheduledGames.isEmpty {
|
|
return "No live action yet. Upcoming first pitches are next."
|
|
}
|
|
return "Completed slate snapshots."
|
|
}
|
|
|
|
private func radarRow(_ game: Game) -> some View {
|
|
Button {
|
|
selectedGame = game
|
|
} label: {
|
|
HStack(spacing: 14) {
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
Text("\(game.awayTeam.code) @ \(game.homeTeam.code)")
|
|
.font(radarRowTitleFont)
|
|
.foregroundStyle(DS.Colors.textPrimary)
|
|
|
|
Text(radarDetail(for: game))
|
|
.font(radarRowBodyFont)
|
|
.foregroundStyle(DS.Colors.textSecondary)
|
|
.lineLimit(2)
|
|
}
|
|
|
|
Spacer(minLength: 8)
|
|
|
|
VStack(alignment: .trailing, spacing: 6) {
|
|
Text(radarStatus(for: game))
|
|
.font(radarRowStatusFont)
|
|
.foregroundStyle(radarTint(for: game))
|
|
|
|
if let score = game.scoreDisplay {
|
|
Text(score)
|
|
.font(radarRowScoreFont)
|
|
.foregroundStyle(DS.Colors.textPrimary)
|
|
.monospacedDigit()
|
|
}
|
|
}
|
|
}
|
|
.padding(.horizontal, 16)
|
|
.padding(.vertical, 14)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 20, style: .continuous)
|
|
.fill(DS.Colors.panelFillMuted)
|
|
.overlay {
|
|
RoundedRectangle(cornerRadius: 20, style: .continuous)
|
|
.strokeBorder(DS.Colors.panelStroke, lineWidth: 1)
|
|
}
|
|
)
|
|
}
|
|
.platformCardStyle()
|
|
}
|
|
|
|
private func radarDetail(for game: Game) -> String {
|
|
if game.isLive {
|
|
return game.venue ?? "Live board ready"
|
|
}
|
|
if let pitchers = game.pitchers, !pitchers.isEmpty {
|
|
return pitchers
|
|
}
|
|
return game.venue ?? "Open matchup details"
|
|
}
|
|
|
|
private func radarStatus(for game: Game) -> String {
|
|
switch game.status {
|
|
case .live(let inning):
|
|
return inning?.uppercased() ?? "LIVE"
|
|
case .scheduled(let time):
|
|
return time.uppercased()
|
|
case .final_:
|
|
return "FINAL"
|
|
case .unknown:
|
|
return "PENDING"
|
|
}
|
|
}
|
|
|
|
private func radarTint(for game: Game) -> Color {
|
|
if game.isLive { return DS.Colors.live }
|
|
if game.isFinal { return DS.Colors.positive }
|
|
return DS.Colors.warning
|
|
}
|
|
|
|
private func gameShelf(
|
|
title: String,
|
|
subtitle: String,
|
|
icon: String,
|
|
accent: Color,
|
|
games: [Game],
|
|
excludeId: String?
|
|
) -> some View {
|
|
let filtered = games.filter { $0.id != excludeId }
|
|
return Group {
|
|
if !filtered.isEmpty {
|
|
VStack(alignment: .leading, spacing: 18) {
|
|
HStack(alignment: .bottom, spacing: 16) {
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
Label(title, systemImage: icon)
|
|
.font(sectionTitleFont)
|
|
.foregroundStyle(DS.Colors.textPrimary)
|
|
|
|
Text(subtitle)
|
|
.font(sectionBodyFont)
|
|
.foregroundStyle(DS.Colors.textSecondary)
|
|
}
|
|
|
|
Spacer()
|
|
|
|
Circle()
|
|
.fill(accent)
|
|
.frame(width: 10, height: 10)
|
|
}
|
|
|
|
ScrollView(.horizontal, showsIndicators: false) {
|
|
LazyHStack(spacing: 24) {
|
|
ForEach(filtered) { game in
|
|
GameCardView(game: game) {
|
|
selectedGame = game
|
|
}
|
|
.frame(width: shelfCardWidth)
|
|
}
|
|
}
|
|
.padding(.vertical, 8)
|
|
}
|
|
.scrollClipDisabled()
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
#if os(tvOS)
|
|
private var headerTitleFont: Font { .system(size: 50, weight: .black, design: .rounded) }
|
|
private var headerBodyFont: Font { .system(size: 22, weight: .medium) }
|
|
private var dateLabelFont: Font { .system(size: 14, weight: .black, design: .rounded) }
|
|
private var dateFont: Font { .system(size: 26, weight: .bold, design: .rounded) }
|
|
private var dateNavIconSize: CGFloat { 20 }
|
|
private var todayBtnFont: Font { .system(size: 18, weight: .black, design: .rounded) }
|
|
private var metricValueFont: Font { .system(size: 34, weight: .black, design: .rounded) }
|
|
private var metricLabelFont: Font { .system(size: 19, weight: .bold, design: .rounded) }
|
|
private var metricDetailFont: Font { .system(size: 16, weight: .semibold) }
|
|
private var railTitleFont: Font { .system(size: 28, weight: .black, design: .rounded) }
|
|
private var railBodyFont: Font { .system(size: 17, weight: .medium) }
|
|
private var radarRowTitleFont: Font { .system(size: 19, weight: .black, design: .rounded) }
|
|
private var radarRowBodyFont: Font { .system(size: 15, weight: .semibold) }
|
|
private var radarRowStatusFont: Font { .system(size: 14, weight: .black, design: .rounded) }
|
|
private var radarRowScoreFont: Font { .system(size: 20, weight: .black, design: .rounded).monospacedDigit() }
|
|
private var sectionTitleFont: Font { .system(size: 30, weight: .black, design: .rounded) }
|
|
private var sectionBodyFont: Font { .system(size: 17, weight: .medium) }
|
|
#else
|
|
private var headerTitleFont: Font { .system(size: 28, weight: .black, design: .rounded) }
|
|
private var headerBodyFont: Font { .system(size: 15, weight: .medium) }
|
|
private var dateLabelFont: Font { .system(size: 10, weight: .black, design: .rounded) }
|
|
private var dateFont: Font { .system(size: 17, weight: .bold, design: .rounded) }
|
|
private var dateNavIconSize: CGFloat { 15 }
|
|
private var todayBtnFont: Font { .system(size: 13, weight: .black, design: .rounded) }
|
|
private var metricValueFont: Font { .system(size: 22, weight: .black, design: .rounded) }
|
|
private var metricLabelFont: Font { .system(size: 13, weight: .bold, design: .rounded) }
|
|
private var metricDetailFont: Font { .system(size: 11, weight: .semibold) }
|
|
private var railTitleFont: Font { .system(size: 20, weight: .black, design: .rounded) }
|
|
private var railBodyFont: Font { .system(size: 12, weight: .medium) }
|
|
private var radarRowTitleFont: Font { .system(size: 14, weight: .black, design: .rounded) }
|
|
private var radarRowBodyFont: Font { .system(size: 11, weight: .semibold) }
|
|
private var radarRowStatusFont: Font { .system(size: 10, weight: .black, design: .rounded) }
|
|
private var radarRowScoreFont: Font { .system(size: 14, weight: .black, design: .rounded).monospacedDigit() }
|
|
private var sectionTitleFont: Font { .system(size: 22, weight: .black, design: .rounded) }
|
|
private var sectionBodyFont: Font { .system(size: 12, weight: .medium) }
|
|
#endif
|
|
|
|
private var featuredChannelsSection: some View {
|
|
HStack(spacing: DS.Spacing.cardGap) {
|
|
mlbNetworkCard
|
|
.frame(maxWidth: .infinity)
|
|
nsfwVideosCard
|
|
.frame(maxWidth: .infinity)
|
|
}
|
|
}
|
|
|
|
private var mlbNetworkCard: some View {
|
|
let added = viewModel.activeStreams.contains(where: { $0.id == "MLBN" })
|
|
return channelCard(
|
|
title: "MLB Network",
|
|
subtitle: "League-wide coverage, whip-around cuts, analysis, and highlights.",
|
|
systemImage: "tv.fill",
|
|
tint: .blue,
|
|
status: added ? "Pinned to Multi-View" : "Open Channel"
|
|
) {
|
|
showMLBNetworkSheet = true
|
|
}
|
|
}
|
|
|
|
private var nsfwVideosCard: some View {
|
|
let added = viewModel.activeStreams.contains(where: { $0.id == SpecialPlaybackChannelConfig.werkoutNSFWStreamID })
|
|
return channelCard(
|
|
title: SpecialPlaybackChannelConfig.werkoutNSFWTitle,
|
|
subtitle: SpecialPlaybackChannelConfig.werkoutNSFWSubtitle,
|
|
systemImage: "play.rectangle.fill",
|
|
tint: .pink,
|
|
status: added ? "Pinned to Multi-View" : "Private feed access"
|
|
) {
|
|
showWerkoutNSFWSheet = true
|
|
}
|
|
}
|
|
|
|
private var multiViewStatus: some View {
|
|
VStack(alignment: .leading, spacing: 14) {
|
|
Text("Multi-View Status")
|
|
.font(railTitleFont)
|
|
.foregroundStyle(DS.Colors.textPrimary)
|
|
|
|
Text("Current grid state, active audio focus, and ready-to-open tiles.")
|
|
.font(railBodyFont)
|
|
.foregroundStyle(DS.Colors.textSecondary)
|
|
|
|
HStack(spacing: 14) {
|
|
metricBadge(value: "\(viewModel.activeStreams.count)/4", label: "Tiles")
|
|
metricBadge(value: viewModel.multiViewLayoutMode.title, label: "Layout")
|
|
metricBadge(value: viewModel.activeAudioStream == nil ? "Muted" : "Live", label: "Audio")
|
|
}
|
|
|
|
if viewModel.activeStreams.isEmpty {
|
|
Text("No active tiles yet. Add any game feed to build the quadbox.")
|
|
.font(railBodyFont)
|
|
.foregroundStyle(DS.Colors.textTertiary)
|
|
} else {
|
|
LazyVGrid(columns: [GridItem(.flexible()), GridItem(.flexible())], spacing: 10) {
|
|
ForEach(viewModel.activeStreams) { stream in
|
|
HStack(spacing: 10) {
|
|
Circle()
|
|
.fill(viewModel.activeAudioStream?.id == stream.id ? DS.Colors.interactive : DS.Colors.positive)
|
|
.frame(width: 10, height: 10)
|
|
Text(stream.label)
|
|
.font(radarRowBodyFont)
|
|
.foregroundStyle(DS.Colors.textPrimary)
|
|
.lineLimit(1)
|
|
Spacer(minLength: 0)
|
|
}
|
|
.padding(.horizontal, 14)
|
|
.padding(.vertical, 12)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 18, style: .continuous)
|
|
.fill(DS.Colors.panelFillMuted)
|
|
.overlay {
|
|
RoundedRectangle(cornerRadius: 18, style: .continuous)
|
|
.strokeBorder(DS.Colors.panelStroke, lineWidth: 1)
|
|
}
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.padding(24)
|
|
.background(surfaceCardBackground())
|
|
}
|
|
|
|
private func channelCard(
|
|
title: String,
|
|
subtitle: String,
|
|
systemImage: String,
|
|
tint: Color,
|
|
status: String,
|
|
action: @escaping () -> Void
|
|
) -> some View {
|
|
Button(action: action) {
|
|
HStack(spacing: 16) {
|
|
Image(systemName: systemImage)
|
|
.font(.system(size: 22, weight: .bold))
|
|
.foregroundStyle(tint)
|
|
.frame(width: 58, height: 58)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 18, style: .continuous)
|
|
.fill(tint.opacity(0.16))
|
|
)
|
|
|
|
VStack(alignment: .leading, spacing: 5) {
|
|
Text(title)
|
|
.font(radarRowTitleFont)
|
|
.foregroundStyle(DS.Colors.textPrimary)
|
|
|
|
Text(subtitle)
|
|
.font(radarRowBodyFont)
|
|
.foregroundStyle(DS.Colors.textSecondary)
|
|
.lineLimit(2)
|
|
}
|
|
|
|
Spacer(minLength: 8)
|
|
|
|
Text(status)
|
|
.font(radarRowStatusFont)
|
|
.foregroundStyle(tint)
|
|
.multilineTextAlignment(.trailing)
|
|
.lineLimit(2)
|
|
}
|
|
.padding(.horizontal, 16)
|
|
.padding(.vertical, 14)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 22, style: .continuous)
|
|
.fill(DS.Colors.panelFillMuted)
|
|
.overlay {
|
|
RoundedRectangle(cornerRadius: 22, style: .continuous)
|
|
.strokeBorder(DS.Colors.panelStroke, lineWidth: 1)
|
|
}
|
|
)
|
|
}
|
|
.platformCardStyle()
|
|
}
|
|
|
|
private func metricBadge(value: String, label: String) -> some View {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text(value)
|
|
.font(radarRowTitleFont)
|
|
.foregroundStyle(DS.Colors.textPrimary)
|
|
Text(label)
|
|
.font(radarRowBodyFont)
|
|
.foregroundStyle(DS.Colors.textTertiary)
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.padding(.horizontal, 14)
|
|
.padding(.vertical, 12)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 18, style: .continuous)
|
|
.fill(DS.Colors.panelFillMuted)
|
|
.overlay {
|
|
RoundedRectangle(cornerRadius: 18, style: .continuous)
|
|
.strokeBorder(DS.Colors.panelStroke, lineWidth: 1)
|
|
}
|
|
)
|
|
}
|
|
|
|
private func surfaceCardBackground(radius: CGFloat = 28) -> some View {
|
|
RoundedRectangle(cornerRadius: radius, style: .continuous)
|
|
.fill(
|
|
LinearGradient(
|
|
colors: [
|
|
DS.Colors.panelFill,
|
|
DS.Colors.panelFillMuted,
|
|
],
|
|
startPoint: .topLeading,
|
|
endPoint: .bottomTrailing
|
|
)
|
|
)
|
|
.overlay {
|
|
RoundedRectangle(cornerRadius: radius, style: .continuous)
|
|
.strokeBorder(DS.Colors.panelStroke, lineWidth: 1)
|
|
}
|
|
.shadow(color: DS.Shadows.card, radius: DS.Shadows.cardRadius, y: DS.Shadows.cardY)
|
|
}
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
}
|