SwiftUI app against master API: link, profiles, browse, live TV, favorites sync, and TestFlight-ready assets. Co-authored-by: Cursor <cursoragent@cursor.com>
205 lines
6.5 KiB
Swift
205 lines
6.5 KiB
Swift
import Foundation
|
|
import SwiftUI
|
|
import UIKit
|
|
|
|
@MainActor
|
|
final class FavoritesStore: ObservableObject {
|
|
@Published private(set) var items: [MediaItem] = []
|
|
|
|
private var profileId: String?
|
|
private let defaults = UserDefaults.standard
|
|
|
|
private func cacheKey(for profileId: String) -> String {
|
|
"vmc.favorites.cache.\(profileId)"
|
|
}
|
|
|
|
private func migratedKey(for profileId: String) -> String {
|
|
"vmc.favorites.migrated.\(profileId)"
|
|
}
|
|
|
|
/// Oude lokale key (vóór server-sync) — eenmalig uploaden.
|
|
private func legacyKey(for profileId: String) -> String {
|
|
"vmc.favorites.\(profileId)"
|
|
}
|
|
|
|
func bind(profileId: String?) {
|
|
self.profileId = profileId
|
|
guard let profileId, !profileId.isEmpty else {
|
|
items = []
|
|
return
|
|
}
|
|
items = loadCache(profileId: profileId)
|
|
}
|
|
|
|
func contains(type: String, id: String) -> Bool {
|
|
let want = normalizedType(type)
|
|
return items.contains {
|
|
$0.id == id && normalizedType($0.type) == want
|
|
}
|
|
}
|
|
|
|
private func normalizedType(_ type: String) -> String {
|
|
let t = type.lowercased()
|
|
return (t == "series" || t == "show") ? "series" : "movie"
|
|
}
|
|
|
|
func replaceAll(_ next: [MediaItem]) {
|
|
items = next
|
|
persistCache()
|
|
}
|
|
|
|
/// Optimistic + server sync. Retourneert of het item nu favoriet is.
|
|
@discardableResult
|
|
func toggle(
|
|
type: String,
|
|
id: String,
|
|
title: String,
|
|
year: Int?,
|
|
posterUrl: String?,
|
|
backdropUrl: String?,
|
|
logoUrl: String?,
|
|
api: ApiClient
|
|
) async -> Bool {
|
|
let normalized = normalizedType(type)
|
|
let wasOn = contains(type: normalized, id: id)
|
|
|
|
if wasOn {
|
|
items.removeAll { $0.id == id && normalizedType($0.type) == normalized }
|
|
} else {
|
|
let item = MediaItem(
|
|
type: normalized.uppercased() == "SERIES" ? "SERIES" : "MOVIE",
|
|
id: id,
|
|
title: title,
|
|
subtitle: nil,
|
|
year: year,
|
|
posterUrl: posterUrl,
|
|
backdropUrl: backdropUrl,
|
|
logoUrl: logoUrl,
|
|
mediaFileId: nil,
|
|
seriesId: nil,
|
|
positionMs: nil,
|
|
durationMs: nil,
|
|
progressId: nil,
|
|
badge: nil
|
|
)
|
|
items.insert(item, at: 0)
|
|
}
|
|
persistCache()
|
|
|
|
do {
|
|
let result = try await api.toggleFavorite(type: normalized, id: id)
|
|
if let remote = result.items {
|
|
replaceAll(remote)
|
|
} else {
|
|
try await refresh(using: api)
|
|
}
|
|
return result.favorited
|
|
} catch {
|
|
// Rollback optimistic update
|
|
if wasOn {
|
|
let item = MediaItem(
|
|
type: normalized.uppercased() == "SERIES" ? "SERIES" : "MOVIE",
|
|
id: id,
|
|
title: title,
|
|
subtitle: nil,
|
|
year: year,
|
|
posterUrl: posterUrl,
|
|
backdropUrl: backdropUrl,
|
|
logoUrl: logoUrl,
|
|
mediaFileId: nil,
|
|
seriesId: nil,
|
|
positionMs: nil,
|
|
durationMs: nil,
|
|
progressId: nil,
|
|
badge: nil
|
|
)
|
|
items.insert(item, at: 0)
|
|
} else {
|
|
items.removeAll { $0.id == id && normalizedType($0.type) == normalized }
|
|
}
|
|
persistCache()
|
|
return wasOn
|
|
}
|
|
}
|
|
|
|
func refresh(using api: ApiClient) async {
|
|
guard let profileId, !profileId.isEmpty else {
|
|
items = []
|
|
return
|
|
}
|
|
do {
|
|
await migrateLegacyIfNeeded(profileId: profileId, api: api)
|
|
let remote = try await api.listFavorites()
|
|
replaceAll(remote)
|
|
} catch {
|
|
// Houd cache aan bij netwerkfout
|
|
if items.isEmpty {
|
|
items = loadCache(profileId: profileId)
|
|
}
|
|
}
|
|
}
|
|
|
|
var mediaItems: [MediaItem] { items }
|
|
|
|
private func migrateLegacyIfNeeded(profileId: String, api: ApiClient) async {
|
|
let flag = migratedKey(for: profileId)
|
|
guard !defaults.bool(forKey: flag) else { return }
|
|
defer { defaults.set(true, forKey: flag) }
|
|
|
|
guard let data = defaults.data(forKey: legacyKey(for: profileId)),
|
|
let legacy = try? JSONDecoder().decode([LegacyFavorite].self, from: data),
|
|
!legacy.isEmpty
|
|
else { return }
|
|
|
|
for item in legacy {
|
|
let t = item.type.lowercased() == "series" ? "series" : "movie"
|
|
_ = try? await api.addFavorite(type: t, id: item.mediaId)
|
|
}
|
|
defaults.removeObject(forKey: legacyKey(for: profileId))
|
|
}
|
|
|
|
private func loadCache(profileId: String) -> [MediaItem] {
|
|
guard let data = defaults.data(forKey: cacheKey(for: profileId)),
|
|
let decoded = try? JSONDecoder().decode([MediaItem].self, from: data)
|
|
else { return [] }
|
|
return decoded
|
|
}
|
|
|
|
private func persistCache() {
|
|
guard let profileId, !profileId.isEmpty else { return }
|
|
if let data = try? JSONEncoder().encode(items) {
|
|
defaults.set(data, forKey: cacheKey(for: profileId))
|
|
}
|
|
}
|
|
}
|
|
|
|
private struct LegacyFavorite: Codable {
|
|
let type: String
|
|
let mediaId: String
|
|
}
|
|
|
|
struct BookmarkToggleButton: View {
|
|
let isOn: Bool
|
|
let action: () -> Void
|
|
|
|
var body: some View {
|
|
Button {
|
|
UIImpactFeedbackGenerator(style: .light).impactOccurred()
|
|
withAnimation(.spring(response: 0.32, dampingFraction: 0.7)) {
|
|
action()
|
|
}
|
|
} label: {
|
|
Image(systemName: isOn ? "bookmark.fill" : "bookmark")
|
|
.font(.system(size: 18, weight: .semibold))
|
|
.foregroundStyle(isOn ? McColors.accent : McColors.text)
|
|
.frame(width: 52, height: 52)
|
|
.background(McColors.surface, in: RoundedRectangle(cornerRadius: 14, style: .continuous))
|
|
.overlay {
|
|
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
|
.strokeBorder(isOn ? McColors.accent.opacity(0.45) : Color.white.opacity(0.08), lineWidth: 1)
|
|
}
|
|
}
|
|
.buttonStyle(.plain)
|
|
.accessibilityLabel(isOn ? "Verwijder uit favorieten" : "Bewaar in favorieten")
|
|
}
|
|
}
|