stremio/apps/ios/VMC/Data/AppModel.swift
Jos Vooges | STH 6ffeb50733 Add in-app device approve/QR pairing and polish iOS browse.
Owners can list/revoke devices and approve TV codes in the iOS app; TV shows a vmc:// QR, and Films/Series get visible chips, titles, and a non-overlapping A–Z index.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-10 03:18:38 +02:00

200 lines
6.6 KiB
Swift

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<AnyCancellable>()
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)
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)
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 {
let i = cleaned.index(cleaned.startIndex, offsetBy: 4)
return "\(cleaned[..i])-\(cleaned[i...])"
}
return cleaned
}
static var deviceName: String {
UIDevice.current.name.isEmpty ? "iPhone" : UIDevice.current.name
}
}