Show end times on detail/player, tighten TV chrome, and let profiles toggle or upload a cropped photo via API and mobile/iOS.
211 lines
6.9 KiB
Swift
211 lines
6.9 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,
|
|
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
|
|
}
|
|
}
|