Everything was clipping off the left edge because the hero card + Live Radar sidebar + padding exceeded screen width. Reduced control rail from 420px to 340px, hero internal padding from 48px to 40px, detail panel from 360px to 300px, title font from 52pt to 44pt, hero height from 560px to 500px. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1361 lines
52 KiB
Swift
1361 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
|
|
|
|
if viewModel.isLoading {
|
|
loadingState
|
|
} else if let error = viewModel.errorMessage, viewModel.games.isEmpty {
|
|
errorState(error)
|
|
} else {
|
|
overviewStrip
|
|
heroAndControlSection
|
|
|
|
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
|
|
featuredChannelsSection
|
|
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 {
|
|
VStack(alignment: .leading, spacing: 14) {
|
|
Text("Quick Channels")
|
|
.font(railTitleFont)
|
|
.foregroundStyle(DS.Colors.textPrimary)
|
|
|
|
mlbNetworkCard
|
|
nsfwVideosCard
|
|
}
|
|
.padding(24)
|
|
.background(surfaceCardBackground())
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
}
|