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.
399 lines
15 KiB
Swift
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)
|
|
)
|
|
)
|
|
}
|
|
}
|