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>
This commit is contained in:
@@ -14,60 +14,260 @@ struct SettingsView: View {
|
||||
var body: some View {
|
||||
@Bindable var vm = viewModel
|
||||
|
||||
NavigationStack {
|
||||
Form {
|
||||
Section("Server") {
|
||||
LabeledContent("URL", value: viewModel.serverBaseURL)
|
||||
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))
|
||||
}
|
||||
|
||||
Section("Default Quality") {
|
||||
ForEach(resolutions, id: \.0) { res in
|
||||
Button {
|
||||
vm.defaultResolution = res.0
|
||||
} label: {
|
||||
HStack {
|
||||
Text(res.1)
|
||||
Spacer()
|
||||
if viewModel.defaultResolution == res.0 {
|
||||
Image(systemName: "checkmark")
|
||||
.foregroundStyle(.blue)
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section("Active Streams (\(viewModel.activeStreams.count)/4)") {
|
||||
settingsPanel(
|
||||
title: "Active Streams",
|
||||
subtitle: "Tile occupancy and cleanup controls for Multi-View."
|
||||
) {
|
||||
if viewModel.activeStreams.isEmpty {
|
||||
Text("No active streams")
|
||||
.foregroundStyle(.secondary)
|
||||
Text("No active streams. Add broadcasts from the dashboard to populate the grid.")
|
||||
.font(optionBodyFont)
|
||||
.foregroundStyle(DS.Colors.textSecondary)
|
||||
} else {
|
||||
ForEach(viewModel.activeStreams) { stream in
|
||||
HStack {
|
||||
Text(stream.label)
|
||||
.fontWeight(.bold)
|
||||
Text(stream.game.displayTitle)
|
||||
.foregroundStyle(.secondary)
|
||||
Spacer()
|
||||
Button(role: .destructive) {
|
||||
viewModel.removeStream(id: stream.id)
|
||||
} label: {
|
||||
Image(systemName: "trash")
|
||||
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("Clear All Streams", role: .destructive) {
|
||||
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
Section("About") {
|
||||
LabeledContent("Version", value: "1.0")
|
||||
LabeledContent("Server", value: "mlbserver")
|
||||
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")
|
||||
}
|
||||
}
|
||||
.navigationTitle("Settings")
|
||||
.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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user