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.
347 lines
13 KiB
Swift
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
|
|
}
|
|
}
|