The app was crashing from memory pressure on tvOS. Three causes fixed: 1. Feed was rendering all 418 highlights at once — capped to 50 items. 2. FeaturedGameCard had 3 blur effects (radius 80-120) on large circles for team color glow — replaced with a single LinearGradient. Same visual effect, fraction of the GPU memory. 3. BroadcastBackground had 3 blurred circles (radius 120-140, 680-900px) rendering on every screen — replaced with RadialGradients which are composited by the GPU natively without offscreen render passes. Also fixed iOS build: replaced tvOS-only font refs (tvSectionTitle, tvBody) with cross-platform equivalents in DashboardView fallback state. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
274 lines
12 KiB
Swift
274 lines
12 KiB
Swift
import SwiftUI
|
|
|
|
struct SettingsView: View {
|
|
@Environment(GamesViewModel.self) private var viewModel
|
|
|
|
private let resolutions = [
|
|
("best", "Best Quality"),
|
|
("1080p60", "1080p 60fps"),
|
|
("720p60", "720p 60fps"),
|
|
("540p", "540p"),
|
|
("adaptive", "Adaptive"),
|
|
]
|
|
|
|
var body: some View {
|
|
@Bindable var vm = viewModel
|
|
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: 26) {
|
|
header
|
|
|
|
settingsPanel(
|
|
title: "Server",
|
|
subtitle: "Current upstream endpoint and playback defaults."
|
|
) {
|
|
infoRow(label: "Base URL", value: viewModel.serverBaseURL)
|
|
infoRow(label: "Current Quality", value: resolutionLabel(for: viewModel.defaultResolution))
|
|
}
|
|
|
|
settingsPanel(
|
|
title: "Playback Quality",
|
|
subtitle: "Preferred stream profile for newly opened feeds."
|
|
) {
|
|
VStack(spacing: 12) {
|
|
ForEach(resolutions, id: \.0) { resolution in
|
|
Button {
|
|
vm.defaultResolution = resolution.0
|
|
} label: {
|
|
HStack(spacing: 14) {
|
|
VStack(alignment: .leading, spacing: 5) {
|
|
Text(resolution.1)
|
|
.font(optionTitleFont)
|
|
.foregroundStyle(DS.Colors.textPrimary)
|
|
|
|
Text(resolutionDescription(for: resolution.0))
|
|
.font(optionBodyFont)
|
|
.foregroundStyle(DS.Colors.textSecondary)
|
|
}
|
|
|
|
Spacer()
|
|
|
|
Image(systemName: viewModel.defaultResolution == resolution.0 ? "checkmark.circle.fill" : "circle")
|
|
.font(.system(size: indicatorSize, weight: .bold))
|
|
.foregroundStyle(viewModel.defaultResolution == resolution.0 ? DS.Colors.interactive : DS.Colors.textQuaternary)
|
|
}
|
|
.padding(.horizontal, 18)
|
|
.padding(.vertical, 16)
|
|
.background(optionBackground(selected: viewModel.defaultResolution == resolution.0))
|
|
}
|
|
.platformCardStyle()
|
|
}
|
|
}
|
|
}
|
|
|
|
settingsPanel(
|
|
title: "Active Streams",
|
|
subtitle: "Tile occupancy and cleanup controls for Multi-View."
|
|
) {
|
|
if viewModel.activeStreams.isEmpty {
|
|
Text("No active streams. Add broadcasts from the dashboard to populate the grid.")
|
|
.font(optionBodyFont)
|
|
.foregroundStyle(DS.Colors.textSecondary)
|
|
} else {
|
|
VStack(spacing: 12) {
|
|
ForEach(viewModel.activeStreams) { stream in
|
|
HStack(spacing: 14) {
|
|
VStack(alignment: .leading, spacing: 5) {
|
|
Text(stream.label)
|
|
.font(optionTitleFont)
|
|
.foregroundStyle(DS.Colors.textPrimary)
|
|
|
|
Text(stream.game.displayTitle)
|
|
.font(optionBodyFont)
|
|
.foregroundStyle(DS.Colors.textSecondary)
|
|
}
|
|
|
|
Spacer()
|
|
|
|
Button(role: .destructive) {
|
|
viewModel.removeStream(id: stream.id)
|
|
} label: {
|
|
Label("Remove", systemImage: "trash")
|
|
.font(actionFont)
|
|
.foregroundStyle(.white)
|
|
.padding(.horizontal, 14)
|
|
.padding(.vertical, 10)
|
|
.background(
|
|
Capsule()
|
|
.fill(DS.Colors.live.opacity(0.82))
|
|
)
|
|
}
|
|
.platformCardStyle()
|
|
}
|
|
.padding(.horizontal, 18)
|
|
.padding(.vertical, 16)
|
|
.background(optionBackground(selected: false))
|
|
}
|
|
}
|
|
|
|
Button(role: .destructive) {
|
|
viewModel.clearAllStreams()
|
|
} label: {
|
|
Label("Clear All Streams", systemImage: "xmark.circle.fill")
|
|
.font(actionFont)
|
|
.foregroundStyle(.white)
|
|
.padding(.horizontal, 18)
|
|
.padding(.vertical, 14)
|
|
.background(
|
|
Capsule()
|
|
.fill(DS.Colors.live.opacity(0.82))
|
|
)
|
|
}
|
|
.platformCardStyle()
|
|
}
|
|
}
|
|
|
|
settingsPanel(
|
|
title: "About",
|
|
subtitle: "Build and environment context."
|
|
) {
|
|
infoRow(label: "Version", value: "1.0")
|
|
infoRow(label: "Player", value: "mlbserver")
|
|
infoRow(label: "Active Tiles", value: "\(viewModel.activeStreams.count)/4")
|
|
}
|
|
}
|
|
.padding(.horizontal, horizontalPadding)
|
|
.padding(.vertical, verticalPadding)
|
|
}
|
|
}
|
|
|
|
private var header: some View {
|
|
VStack(alignment: .leading, spacing: 10) {
|
|
Text("Settings")
|
|
.font(headerTitleFont)
|
|
.foregroundStyle(DS.Colors.textPrimary)
|
|
|
|
Text("Playback defaults, server routing, and Multi-View controls.")
|
|
.font(headerBodyFont)
|
|
.foregroundStyle(DS.Colors.textSecondary)
|
|
}
|
|
}
|
|
|
|
private func settingsPanel<Content: View>(
|
|
title: String,
|
|
subtitle: String,
|
|
@ViewBuilder content: () -> Content
|
|
) -> some View {
|
|
VStack(alignment: .leading, spacing: 18) {
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
Text(title)
|
|
.font(sectionTitleFont)
|
|
.foregroundStyle(DS.Colors.textPrimary)
|
|
|
|
Text(subtitle)
|
|
.font(sectionBodyFont)
|
|
.foregroundStyle(DS.Colors.textSecondary)
|
|
}
|
|
|
|
content()
|
|
}
|
|
.padding(panelPadding)
|
|
.background(panelBackground)
|
|
}
|
|
|
|
private func infoRow(label: String, value: String) -> some View {
|
|
HStack(alignment: .top, spacing: 16) {
|
|
Text(label)
|
|
.font(infoLabelFont)
|
|
.foregroundStyle(DS.Colors.textTertiary)
|
|
.frame(width: labelWidth, alignment: .leading)
|
|
|
|
Text(value)
|
|
.font(infoValueFont)
|
|
.foregroundStyle(DS.Colors.textPrimary)
|
|
|
|
Spacer(minLength: 0)
|
|
}
|
|
}
|
|
|
|
private func optionBackground(selected: Bool) -> some View {
|
|
RoundedRectangle(cornerRadius: optionRadius, style: .continuous)
|
|
.fill(selected ? DS.Colors.interactive.opacity(0.14) : DS.Colors.panelFillMuted)
|
|
.overlay {
|
|
RoundedRectangle(cornerRadius: optionRadius, style: .continuous)
|
|
.strokeBorder(selected ? DS.Colors.interactive.opacity(0.26) : DS.Colors.panelStroke, lineWidth: 1)
|
|
}
|
|
}
|
|
|
|
private func resolutionLabel(for key: String) -> String {
|
|
resolutions.first(where: { $0.0 == key })?.1 ?? key
|
|
}
|
|
|
|
private func resolutionDescription(for key: String) -> String {
|
|
switch key {
|
|
case "best":
|
|
return "Uses the highest reliable stream available."
|
|
case "1080p60":
|
|
return "Prioritizes smooth full-HD playback when available."
|
|
case "720p60":
|
|
return "Balanced quality for lighter network conditions."
|
|
case "540p":
|
|
return "Lower bandwidth option for stability."
|
|
case "adaptive":
|
|
return "Lets the player adjust dynamically."
|
|
default:
|
|
return "Custom playback preference."
|
|
}
|
|
}
|
|
|
|
private var panelBackground: some View {
|
|
RoundedRectangle(cornerRadius: panelRadius, style: .continuous)
|
|
.fill(
|
|
LinearGradient(
|
|
colors: [
|
|
DS.Colors.panelFill,
|
|
DS.Colors.panelFillMuted,
|
|
],
|
|
startPoint: .topLeading,
|
|
endPoint: .bottomTrailing
|
|
)
|
|
)
|
|
.overlay {
|
|
RoundedRectangle(cornerRadius: panelRadius, style: .continuous)
|
|
.strokeBorder(DS.Colors.panelStroke, lineWidth: 1)
|
|
}
|
|
.shadow(color: DS.Shadows.card, radius: DS.Shadows.cardRadius, y: DS.Shadows.cardY)
|
|
}
|
|
|
|
#if os(tvOS)
|
|
private var horizontalPadding: CGFloat { 56 }
|
|
private var verticalPadding: CGFloat { 40 }
|
|
private var panelPadding: CGFloat { 24 }
|
|
private var panelRadius: CGFloat { 28 }
|
|
private var optionRadius: CGFloat { 22 }
|
|
private var labelWidth: CGFloat { 150 }
|
|
private var indicatorSize: CGFloat { 22 }
|
|
private var headerTitleFont: Font { .system(size: 44, weight: .black, design: .rounded) }
|
|
private var headerBodyFont: Font { .system(size: 18, weight: .medium) }
|
|
private var sectionTitleFont: Font { .system(size: 28, weight: .black, design: .rounded) }
|
|
private var sectionBodyFont: Font { .system(size: 16, weight: .medium) }
|
|
private var infoLabelFont: Font { .system(size: 15, weight: .black, design: .rounded) }
|
|
private var infoValueFont: Font { .system(size: 18, weight: .bold, design: .rounded) }
|
|
private var optionTitleFont: Font { .system(size: 20, weight: .bold, design: .rounded) }
|
|
private var optionBodyFont: Font { .system(size: 15, weight: .medium) }
|
|
private var actionFont: Font { .system(size: 14, weight: .black, design: .rounded) }
|
|
#else
|
|
private var horizontalPadding: CGFloat { 20 }
|
|
private var verticalPadding: CGFloat { 24 }
|
|
private var panelPadding: CGFloat { 18 }
|
|
private var panelRadius: CGFloat { 22 }
|
|
private var optionRadius: CGFloat { 18 }
|
|
private var labelWidth: CGFloat { 100 }
|
|
private var indicatorSize: CGFloat { 18 }
|
|
private var headerTitleFont: Font { .system(size: 28, weight: .black, design: .rounded) }
|
|
private var headerBodyFont: Font { .system(size: 14, weight: .medium) }
|
|
private var sectionTitleFont: Font { .system(size: 20, weight: .black, design: .rounded) }
|
|
private var sectionBodyFont: Font { .system(size: 13, weight: .medium) }
|
|
private var infoLabelFont: Font { .system(size: 12, weight: .black, design: .rounded) }
|
|
private var infoValueFont: Font { .system(size: 14, weight: .bold, design: .rounded) }
|
|
private var optionTitleFont: Font { .system(size: 16, weight: .bold, design: .rounded) }
|
|
private var optionBodyFont: Font { .system(size: 12, weight: .medium) }
|
|
private var actionFont: Font { .system(size: 12, weight: .black, design: .rounded) }
|
|
#endif
|
|
}
|