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>
943 lines
35 KiB
Swift
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)
|
|
}
|
|
}
|
|
}
|