import SwiftUI import CoreImage import CoreImage.CIFilterBuiltins enum ServerUrl { static func normalize(_ raw: String, fallback: String = SessionStore.defaultApiBase) -> String { var s = raw.trimmingCharacters(in: .whitespacesAndNewlines) .trimmingCharacters(in: CharacterSet(charactersIn: "/")) if s.isEmpty { return fallback.trimmingCharacters(in: CharacterSet(charactersIn: "/")) } if !s.lowercased().hasPrefix("http://") && !s.lowercased().hasPrefix("https://") { s = "https://\(s)" } return s.trimmingCharacters(in: CharacterSet(charactersIn: "/")) } } private struct AuthBackground: View { var body: some View { LinearGradient( colors: [ Color(red: 0.12, green: 0.03, blue: 0.05), TvColors.bg, Color(red: 0.03, green: 0.04, blue: 0.1), ], startPoint: .topLeading, endPoint: .bottomTrailing ) .ignoresSafeArea() } } func qrImage(from string: String) -> Image? { let context = CIContext() let filter = CIFilter.qrCodeGenerator() filter.message = Data(string.utf8) filter.correctionLevel = "M" guard let output = filter.outputImage else { return nil } let scaled = output.transformed(by: CGAffineTransform(scaleX: 12, y: 12)) guard let cg = context.createCGImage(scaled, from: scaled.extent) else { return nil } return Image(decorative: cg, scale: 1) } struct ServerChoiceView: View { @EnvironmentObject private var app: AppModel @State private var customMode = false @State private var urlInput = "" @State private var error: String? @State private var busy = false var body: some View { ZStack { AuthBackground() VStack(spacing: 28) { Text("VMC") .font(TvFonts.brand) .foregroundStyle(TvColors.accent) .shadow(color: TvColors.accent.opacity(0.45), radius: 28) Text("Verbind met je media") .font(TvFonts.title) .foregroundStyle(TvColors.text) Text("Vonas Media Cluster of je eigen server") .font(TvFonts.body) .foregroundStyle(TvColors.textDim) if !customMode { Button("Doorgaan met VMC") { useVmc() } .buttonStyle(TvPrimaryButtonStyle()) .disabled(busy) Button("Eigen server") { customMode = true error = nil if app.store.apiBase != SessionStore.defaultApiBase { urlInput = app.store.apiBase } } .buttonStyle(TvSecondaryButtonStyle()) } else { TextField("https://media.voorbeeld.nl", text: $urlInput) .font(TvFonts.body) .foregroundStyle(TvColors.text) .padding(20) .background(TvColors.surface.opacity(0.9), in: RoundedRectangle(cornerRadius: 16, style: .continuous)) .frame(maxWidth: 720) Button(busy ? "Verbinden…" : "Doorgaan") { useCustom() } .buttonStyle(TvPrimaryButtonStyle()) .disabled(busy) Button("Terug") { customMode = false error = nil } .buttonStyle(TvSecondaryButtonStyle()) } if let error { Text(error).font(TvFonts.body).foregroundStyle(TvColors.danger) } } .padding(48) .frame(maxWidth: 900) .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 28, style: .continuous)) } } private func useVmc() { busy = true error = nil app.store.setApiBase(SessionStore.defaultApiBase) Task { await app.api.configure(baseUrl: app.store.apiBase, token: nil) busy = false app.phase = .auth } } private func useCustom() { guard !urlInput.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { error = "Voer een server-URL in" return } busy = true error = nil app.store.setApiBase(ServerUrl.normalize(urlInput)) Task { await app.api.configure(baseUrl: app.store.apiBase, token: nil) busy = false app.phase = .auth } } } struct AuthChoiceView: View { @EnvironmentObject private var app: AppModel var body: some View { ZStack { AuthBackground() VStack(spacing: 28) { Text("VMC") .font(TvFonts.brand) .foregroundStyle(TvColors.accent) Text("Aanmelden") .font(TvFonts.title) .foregroundStyle(TvColors.text) Text("Koppel dit Apple TV-apparaat of log in met wachtwoord") .font(TvFonts.body) .foregroundStyle(TvColors.textDim) .multilineTextAlignment(.center) .frame(maxWidth: 700) Button("Koppelen met code") { app.phase = .link } .buttonStyle(TvPrimaryButtonStyle()) Button("Wachtwoord") { app.phase = .password } .buttonStyle(TvSecondaryButtonStyle()) Button("Andere server") { app.phase = .server } .buttonStyle(TvSecondaryButtonStyle()) } .padding(48) .frame(maxWidth: 900) .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 28, style: .continuous)) } } } struct LinkView: View { @EnvironmentObject private var app: AppModel @State private var code: String? @State private var linkUrl = "" @State private var qrPayload = "" @State private var status = "Verbinden…" @State private var error: String? var body: some View { ZStack { AuthBackground() HStack(spacing: 56) { VStack(spacing: 24) { Text("Koppel dit apparaat") .font(TvFonts.title) .foregroundStyle(TvColors.text) Text("Open je Vonas-account, scan de QR of voer de code in.") .font(TvFonts.body) .foregroundStyle(TvColors.textDim) .multilineTextAlignment(.center) .frame(maxWidth: 520) if let code { Text(code) .font(TvFonts.code) .foregroundStyle(TvColors.text) .padding(.vertical, 20) .padding(.horizontal, 36) .background(TvColors.surface.opacity(0.95), in: RoundedRectangle(cornerRadius: 22, style: .continuous)) .overlay( RoundedRectangle(cornerRadius: 22, style: .continuous) .stroke(TvColors.accent.opacity(0.45), lineWidth: 2) ) .shadow(color: TvColors.accent.opacity(0.25), radius: 24) } else if error == nil { ProgressView().tint(.white).scaleEffect(1.4) } Text(status) .font(TvFonts.caption) .foregroundStyle(TvColors.textDim) if let error { Text(error) .font(TvFonts.body) .foregroundStyle(TvColors.danger) Button("Opnieuw") { Task { await startLink() } } .buttonStyle(TvPrimaryButtonStyle()) } HStack(spacing: 20) { Button("Andere server") { app.phase = .server } .buttonStyle(TvSecondaryButtonStyle()) Button("Terug") { app.phase = .auth } .buttonStyle(TvSecondaryButtonStyle()) } } if let payload = qrPayload.isEmpty ? (linkUrl.isEmpty ? nil : linkUrl) : qrPayload, let image = qrImage(from: payload) { image .interpolation(.none) .resizable() .scaledToFit() .frame(width: 320, height: 320) .padding(24) .background(Color.white, in: RoundedRectangle(cornerRadius: 20, style: .continuous)) .shadow(color: .black.opacity(0.4), radius: 20) } } .padding(48) } .task { await startLink() } } private func startLink() async { error = nil code = nil status = "Verbinden…" await app.api.configure(baseUrl: app.store.apiBase, token: nil) do { let created = try await app.api.createDeviceCode(deviceName: AppModel.deviceName) code = created.code linkUrl = created.linkUrl ?? "" qrPayload = created.qrPayload ?? created.linkUrl ?? created.code status = "Wachten tot je de code bevestigt…" let interval = max(created.pollIntervalSeconds ?? 2, 2) while true { try await Task.sleep(nanoseconds: UInt64(interval) * 1_000_000_000) switch try await app.api.pollDeviceCode(code: created.code) { case .linked(let token, let name): status = "Welkom, \(name)" try await Task.sleep(nanoseconds: 700_000_000) await app.didLink(token: token) return case .expired: status = "Code verlopen" error = "Code verlopen — probeer opnieuw" return case .alreadyLinked: status = "Al gekoppeld" error = "Al gekoppeld — herstart de app" return case .pending: break } } } catch { self.error = error.localizedDescription status = "Mislukt" } } } struct PasswordLoginView: View { @EnvironmentObject private var app: AppModel @State private var email = "" @State private var password = "" @State private var error: String? @State private var busy = false var body: some View { ZStack { AuthBackground() VStack(spacing: 22) { Text("Inloggen met wachtwoord") .font(TvFonts.title) .foregroundStyle(TvColors.text) TextField("E-mail", text: $email) .font(TvFonts.body) .foregroundStyle(TvColors.text) .padding(20) .background(TvColors.surface.opacity(0.9), in: RoundedRectangle(cornerRadius: 16, style: .continuous)) .frame(maxWidth: 640) SecureField("Wachtwoord", text: $password) .font(TvFonts.body) .foregroundStyle(TvColors.text) .padding(20) .background(TvColors.surface.opacity(0.9), in: RoundedRectangle(cornerRadius: 16, style: .continuous)) .frame(maxWidth: 640) if let error { Text(error).font(TvFonts.body).foregroundStyle(TvColors.danger) } Button(busy ? "Bezig…" : "Inloggen") { Task { await submit() } } .buttonStyle(TvPrimaryButtonStyle()) .disabled(busy) Button("Terug") { app.phase = .auth } .buttonStyle(TvSecondaryButtonStyle()) } .padding(48) .frame(maxWidth: 900) .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 28, style: .continuous)) } } private func submit() async { guard !email.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, !password.isEmpty else { error = "E-mail en wachtwoord verplicht" return } busy = true error = nil await app.api.configure(baseUrl: app.store.apiBase, token: nil) do { let result = try await app.api.login( email: email.trimmingCharacters(in: .whitespacesAndNewlines), password: password, deviceName: AppModel.deviceName ) await app.didLink(token: result.refreshToken) } catch { self.error = error.localizedDescription } busy = false } }