import Foundation import UIKit import Combine @MainActor final class AppModel: ObservableObject { enum Phase: Equatable { case boot case server case auth case password case link case profiles case main } enum MainTab: String, CaseIterable, Identifiable { case home, movies, series, tv, search, account var id: String { rawValue } var title: String { switch self { case .home: return "Home" case .movies: return "Films" case .series: return "Series" case .tv: return "TV" case .search: return "Zoeken" case .account: return "Account" } } var icon: String { switch self { case .home: return "house.fill" case .movies: return "film" case .series: return "rectangle.stack.fill" case .tv: return "tv.fill" case .search: return "magnifyingglass" case .account: return "person.crop.circle.fill" } } } let store = SessionStore() let api = ApiClient() let favorites = FavoritesStore() @Published var phase: Phase = .boot @Published var tab: MainTab = .home @Published var iptvEnabled = false @Published var canManageProfiles = false @Published var bootError: String? /// Code van QR/deep link om een ander apparaat goed te keuren (hoofdprofiel). @Published var pendingApproveCode: String? /// > 0 terwijl een speler (VOD/live) open is — verbergt de footer. @Published private(set) var playbackDepth = 0 var isPlaybackActive: Bool { playbackDepth > 0 } func enterPlayback() { playbackDepth += 1 } func leavePlayback() { playbackDepth = max(0, playbackDepth - 1) } private var cancellables = Set() init() { favorites.objectWillChange .receive(on: RunLoop.main) .sink { [weak self] _ in self?.objectWillChange.send() } .store(in: &cancellables) Task { await bootstrap() } } func bootstrap() async { phase = .boot bootError = nil await api.configure(baseUrl: store.apiBase, token: store.refreshToken) guard store.isAuthed else { phase = .server return } do { let me = try await api.clientMe() iptvEnabled = me.iptvEnabled if let profile = me.profile { store.setProfile( id: profile.id, name: profile.name, avatarId: profile.avatarId, showAvatar: profile.showsAvatar, avatarUrl: profile.avatarUrl ?? "" ) favorites.bind(profileId: profile.id) await favorites.refresh(using: api) if let payload = try? await api.listProfiles() { canManageProfiles = payload.canManage == true } else { canManageProfiles = profile.isOwner == true } phase = .main } else { store.setProfile(id: nil, name: nil, avatarId: nil) favorites.bind(profileId: nil) canManageProfiles = false phase = .profiles } } catch { bootError = error.localizedDescription store.setToken(nil) favorites.bind(profileId: nil) await api.configure(baseUrl: store.apiBase, token: nil) phase = .server } } func didLink(token: String) async { store.setToken(token) await api.configure(baseUrl: store.apiBase, token: token) store.setProfile(id: nil, name: nil, avatarId: nil) if let me = try? await api.clientMe() { iptvEnabled = me.iptvEnabled } phase = .profiles } func didSelectProfile(_ profile: ViewerProfile) { store.setProfile( id: profile.id, name: profile.name, avatarId: profile.avatarId, showAvatar: profile.showsAvatar, avatarUrl: profile.avatarUrl ?? "" ) favorites.bind(profileId: profile.id) Task { await favorites.refresh(using: api) if let payload = try? await api.listProfiles() { canManageProfiles = payload.canManage == true } else { canManageProfiles = profile.isOwner == true } } tab = .home phase = .main } func switchProfile() async { try? await api.clearProfile() store.setProfile(id: nil, name: nil, avatarId: nil) favorites.bind(profileId: nil) phase = .profiles } func logout() async { store.setToken(nil) await api.configure(baseUrl: store.apiBase, token: nil) favorites.bind(profileId: nil) iptvEnabled = false pendingApproveCode = nil phase = .server } /// Deep link: `vmc://link?code=ABCD-EFGH` of `vmc://link/ABCD-EFGH` func handleOpenURL(_ url: URL) { guard let code = Self.extractLinkCode(from: url) else { return } pendingApproveCode = code if phase == .main { tab = .account } } func clearPendingApprove() { pendingApproveCode = nil } static func extractLinkCode(from url: URL) -> String? { let scheme = (url.scheme ?? "").lowercased() if scheme == "vmc" { if let items = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems, let code = items.first(where: { $0.name.lowercased() == "code" })?.value, !code.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { return normalizeLinkCode(code) } let parts = url.path.split(separator: "/").map(String.init) if let last = parts.last, !last.isEmpty { return normalizeLinkCode(last) } if let host = url.host, host.lowercased() != "link", !host.isEmpty { return normalizeLinkCode(host) } } // Web fallback: https://…/link?code= if let items = URLComponents(url: url, resolvingAgainstBaseURL: false)?.queryItems, let code = items.first(where: { $0.name.lowercased() == "code" })?.value { return normalizeLinkCode(code) } return nil } static func normalizeLinkCode(_ raw: String) -> String { let cleaned = raw.uppercased().filter { $0.isLetter || $0.isNumber || $0 == "-" } if cleaned.contains("-") { return cleaned } if cleaned.count == 8 { return "\(cleaned.prefix(4))-\(cleaned.suffix(4))" } return cleaned } static var deviceName: String { UIDevice.current.name.isEmpty ? "iPhone" : UIDevice.current.name } }