Files
MLBApp/mlbTVOS/Views/SettingsView.swift
Trey t 588b42ffed Fix tvOS memory crash: cap highlights to 50, replace blurs with gradients
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>
2026-04-12 16:44:25 -05:00

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
}