stremio/apps/ios/VMCTV/UI/SettingsView.swift
Jos Vooges | STH ac951617cb Add Live Events across API/admin/Android TV, mobile ClearKey/DASH fixes, and tvOS app.
Wire FAAB schedule-events into master-api and admin IPTV settings, add Android TV Events UI, fix mobile ClearKey/stream playback (DASH + bump 1.0.23), and scaffold the Apple TV (VMCTV) client with docs/scripts.
2026-09-18 23:34:42 +02:00

399 lines
15 KiB
Swift

import SwiftUI
struct SettingsView: View {
@EnvironmentObject private var app: AppModel
@State private var prefs: [ShelfPref] = []
@State private var prefsError: String?
@State private var prefsMessage: String?
@State private var savingPrefs = false
var body: some View {
ScrollView {
VStack(alignment: .leading, spacing: 36) {
VStack(alignment: .leading, spacing: 8) {
Text("Instellingen")
.font(TvFonts.title)
.foregroundStyle(TvColors.text)
Text("Profiel, home-planken en account.")
.font(TvFonts.body)
.foregroundStyle(TvColors.textDim)
}
profileHero
settingsSection(title: "Profiel", subtitle: "Wissel of beheer kijkprofielen") {
HStack(spacing: 20) {
settingsActionButton(
title: "Profiel wisselen",
systemImage: "person.2.fill"
) {
Task { await app.switchProfile() }
}
NavigationLink {
ManageProfilesView()
} label: {
settingsActionLabel(
title: app.canManageProfiles ? "Profielen beheren" : "Profielen bekijken",
systemImage: "person.crop.circle.badge.plus"
)
}
.buttonStyle(FocusableCardStyle(scale: 1.04))
}
.focusSection()
}
settingsSection(
title: "Home-planken",
subtitle: "Zet planken aan/uit en bepaal de volgorde op Home"
) {
if prefs.isEmpty {
Text(prefsError ?? "Geen planken geladen")
.font(TvFonts.caption)
.foregroundStyle(TvColors.textDim)
} else {
VStack(spacing: 12) {
ForEach(Array(prefs.enumerated()), id: \.element.id) { index, pref in
ShelfPrefRow(
pref: pref,
index: index,
total: prefs.count,
onToggle: {
prefs[index].visible.toggle()
Task { await persistPrefs() }
},
onMoveUp: {
movePref(from: index, to: index - 1)
},
onMoveDown: {
movePref(from: index, to: index + 1)
}
)
}
}
.focusSection()
HStack(spacing: 16) {
if savingPrefs {
ProgressView().tint(.white).scaleEffect(0.8)
}
if let prefsMessage {
Label(prefsMessage, systemImage: "checkmark.circle.fill")
.font(TvFonts.caption)
.foregroundStyle(TvColors.success)
}
if let prefsError {
Text(prefsError)
.font(TvFonts.caption)
.foregroundStyle(TvColors.danger)
}
}
.padding(.top, 4)
}
}
settingsSection(title: "Account", subtitle: "Sessie op dit apparaat") {
settingsActionButton(
title: "Uitloggen",
systemImage: "rectangle.portrait.and.arrow.right",
destructive: true
) {
Task { await app.logout() }
}
}
settingsSection(title: "Over deze app", subtitle: nil) {
VStack(spacing: 0) {
infoRow("App", "Vonas Media Cluster · Apple TV")
divider
infoRow("Versie", "1.0.0")
divider
infoRow("Platform", "apple_tv")
divider
infoRow("Live TV", app.iptvEnabled ? "Aan" : "Uit")
divider
infoRow("Apparaat", AppModel.deviceName)
divider
infoRow("Server", app.store.apiBase)
}
}
}
.padding(.horizontal, 56)
.padding(.top, 8)
.padding(.bottom, 72)
}
.background(
ZStack {
TvColors.bg
LinearGradient(
colors: [TvColors.accent.opacity(0.12), .clear, .clear],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
}
.ignoresSafeArea()
)
.task {
await loadPrefs()
if let payload = try? await app.api.listProfiles() {
app.canManageProfiles = payload.canManage == true
}
}
}
private var profileHero: some View {
HStack(spacing: 28) {
Text(ProfileAvatars.emoji(for: app.store.profileAvatarId))
.font(.system(size: 64))
.frame(width: 112, height: 112)
.background(
LinearGradient(
colors: [TvColors.surfaceSoft, TvColors.surface],
startPoint: .topLeading,
endPoint: .bottomTrailing
),
in: Circle()
)
.overlay(Circle().strokeBorder(TvColors.accent.opacity(0.45), lineWidth: 2))
VStack(alignment: .leading, spacing: 10) {
Text(app.store.profileName ?? "Profiel")
.font(TvFonts.headline)
.foregroundStyle(TvColors.text)
Text("Actief kijkprofiel")
.font(TvFonts.caption)
.foregroundStyle(TvColors.textDim)
Text(app.store.apiBase)
.font(.system(size: 18, weight: .medium, design: .monospaced))
.foregroundStyle(TvColors.textDim.opacity(0.9))
.lineLimit(1)
}
Spacer(minLength: 0)
}
.padding(32)
.background(
RoundedRectangle(cornerRadius: 24, style: .continuous)
.fill(.ultraThinMaterial)
.overlay(
RoundedRectangle(cornerRadius: 24, style: .continuous)
.strokeBorder(
LinearGradient(
colors: [TvColors.accent.opacity(0.45), Color.white.opacity(0.08)],
startPoint: .topLeading,
endPoint: .bottomTrailing
),
lineWidth: 1.5
)
)
)
}
private func settingsSection<Content: View>(
title: String,
subtitle: String?,
@ViewBuilder content: () -> Content
) -> some View {
VStack(alignment: .leading, spacing: 18) {
VStack(alignment: .leading, spacing: 6) {
Text(title)
.font(TvFonts.headline)
.foregroundStyle(TvColors.text)
if let subtitle {
Text(subtitle)
.font(TvFonts.caption)
.foregroundStyle(TvColors.textDim)
}
}
content()
.padding(24)
.frame(maxWidth: .infinity, alignment: .leading)
.background(
RoundedRectangle(cornerRadius: 22, style: .continuous)
.fill(TvColors.bgElevated.opacity(0.92))
.overlay(
RoundedRectangle(cornerRadius: 22, style: .continuous)
.strokeBorder(Color.white.opacity(0.07), lineWidth: 1)
)
)
}
}
private func settingsActionButton(
title: String,
systemImage: String,
destructive: Bool = false,
action: @escaping () -> Void
) -> some View {
Button(action: action) {
settingsActionLabel(title: title, systemImage: systemImage, destructive: destructive)
}
.buttonStyle(FocusableCardStyle(scale: 1.04))
}
private func settingsActionLabel(
title: String,
systemImage: String,
destructive: Bool = false
) -> some View {
HStack(spacing: 14) {
Image(systemName: systemImage)
.font(.system(size: 24, weight: .semibold))
.foregroundStyle(destructive ? TvColors.danger : TvColors.accent)
.frame(width: 36)
Text(title)
.font(TvFonts.body)
.foregroundStyle(TvColors.text)
Spacer(minLength: 8)
Image(systemName: "chevron.right")
.font(.system(size: 18, weight: .semibold))
.foregroundStyle(TvColors.textDim)
}
.padding(.horizontal, 22)
.padding(.vertical, 18)
.frame(minWidth: 320)
.background(TvColors.surface, in: RoundedRectangle(cornerRadius: 16, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 16, style: .continuous)
.strokeBorder(Color.white.opacity(0.08), lineWidth: 1)
)
}
private var divider: some View {
Rectangle()
.fill(Color.white.opacity(0.06))
.frame(height: 1)
.padding(.vertical, 4)
}
private func infoRow(_ label: String, _ value: String) -> some View {
HStack(alignment: .firstTextBaseline) {
Text(label)
.font(TvFonts.body)
.foregroundStyle(TvColors.textDim)
.frame(width: 160, alignment: .leading)
Text(value)
.font(TvFonts.body)
.foregroundStyle(TvColors.text)
.frame(maxWidth: .infinity, alignment: .trailing)
.multilineTextAlignment(.trailing)
}
.padding(.vertical, 10)
}
private func movePref(from: Int, to: Int) {
guard prefs.indices.contains(from), prefs.indices.contains(to) else { return }
withAnimation(.easeOut(duration: 0.2)) {
prefs.swapAt(from, to)
}
Task { await persistPrefs() }
}
private func loadPrefs() async {
do {
prefs = try await app.api.getShelfPrefs().sorted { $0.sortOrder < $1.sortOrder }
prefsError = nil
} catch {
prefsError = error.localizedDescription
}
}
private func persistPrefs() async {
savingPrefs = true
do {
let next = prefs.enumerated().map { i, p in
ShelfPref(
shelfId: p.shelfId,
name: p.name,
slug: p.slug,
kind: p.kind,
visible: p.visible,
sortOrder: i
)
}
prefs = next
try await app.api.saveShelfPrefs(next)
prefsMessage = "Opgeslagen"
prefsError = nil
savingPrefs = false
try? await Task.sleep(nanoseconds: 1_800_000_000)
if prefsMessage == "Opgeslagen" { prefsMessage = nil }
} catch {
prefsError = error.localizedDescription
prefsMessage = nil
savingPrefs = false
}
}
}
private struct ShelfPrefRow: View {
let pref: ShelfPref
let index: Int
let total: Int
let onToggle: () -> Void
let onMoveUp: () -> Void
let onMoveDown: () -> Void
var body: some View {
HStack(spacing: 18) {
Text("\(index + 1)")
.font(.system(size: 22, weight: .bold, design: .rounded))
.foregroundStyle(TvColors.textDim)
.frame(width: 36)
VStack(alignment: .leading, spacing: 4) {
Text(pref.name)
.font(TvFonts.body)
.foregroundStyle(TvColors.text)
Text(pref.visible ? "Zichtbaar op Home" : "Verborgen")
.font(TvFonts.caption)
.foregroundStyle(pref.visible ? TvColors.success.opacity(0.9) : TvColors.textDim)
}
Spacer(minLength: 12)
HStack(spacing: 10) {
Button(action: onMoveUp) {
Image(systemName: "chevron.up")
.font(.system(size: 20, weight: .bold))
.frame(width: 52, height: 52)
.background(TvColors.surfaceSoft, in: RoundedRectangle(cornerRadius: 12, style: .continuous))
}
.buttonStyle(FocusableCardStyle(scale: 1.08))
.disabled(index == 0)
.opacity(index == 0 ? 0.35 : 1)
Button(action: onMoveDown) {
Image(systemName: "chevron.down")
.font(.system(size: 20, weight: .bold))
.frame(width: 52, height: 52)
.background(TvColors.surfaceSoft, in: RoundedRectangle(cornerRadius: 12, style: .continuous))
}
.buttonStyle(FocusableCardStyle(scale: 1.08))
.disabled(index >= total - 1)
.opacity(index >= total - 1 ? 0.35 : 1)
Button(action: onToggle) {
Text(pref.visible ? "Aan" : "Uit")
.font(.system(size: 20, weight: .semibold, design: .rounded))
.foregroundStyle(pref.visible ? .white : TvColors.textDim)
.frame(width: 88, height: 52)
.background(
pref.visible ? TvColors.accent : TvColors.surfaceSoft,
in: RoundedRectangle(cornerRadius: 12, style: .continuous)
)
}
.buttonStyle(FocusableCardStyle(scale: 1.08))
}
}
.padding(.horizontal, 18)
.padding(.vertical, 14)
.background(
RoundedRectangle(cornerRadius: 16, style: .continuous)
.fill(TvColors.surface.opacity(0.85))
.overlay(
RoundedRectangle(cornerRadius: 16, style: .continuous)
.strokeBorder(Color.white.opacity(0.06), lineWidth: 1)
)
)
}
}