stremio/apps/ios/VMCTV/UI/AuthViews.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

347 lines
13 KiB
Swift

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
}
}