SwiftUI app against master API: link, profiles, browse, live TV, favorites sync, and TestFlight-ready assets. Co-authored-by: Cursor <cursoragent@cursor.com>
172 lines
6 KiB
Swift
172 lines
6 KiB
Swift
import SwiftUI
|
|
|
|
struct RemoteImage: View {
|
|
let url: String?
|
|
var contentMode: ContentMode = .fill
|
|
/// Zet uit voor transparante logo's (geen donker vlak).
|
|
var opaqueBackground: Bool = true
|
|
|
|
var body: some View {
|
|
Group {
|
|
if let url, let u = URL(string: url) {
|
|
AsyncImage(url: u) { phase in
|
|
switch phase {
|
|
case .success(let image):
|
|
image.resizable().aspectRatio(contentMode: contentMode)
|
|
case .failure:
|
|
if opaqueBackground { placeholder } else { Color.clear }
|
|
case .empty:
|
|
if opaqueBackground {
|
|
placeholder.overlay { ProgressView().tint(.white) }
|
|
} else {
|
|
Color.clear
|
|
}
|
|
@unknown default:
|
|
if opaqueBackground { placeholder } else { Color.clear }
|
|
}
|
|
}
|
|
} else if opaqueBackground {
|
|
placeholder
|
|
} else {
|
|
Color.clear
|
|
}
|
|
}
|
|
.clipped()
|
|
.background(opaqueBackground ? McColors.surface : Color.clear)
|
|
}
|
|
|
|
private var placeholder: some View {
|
|
Rectangle().fill(McColors.surface)
|
|
}
|
|
}
|
|
|
|
/// Title-card / logo zonder achtergrondvlak.
|
|
struct TitleLogo: View {
|
|
let url: String?
|
|
var maxWidth: CGFloat = 220
|
|
var maxHeight: CGFloat = 56
|
|
|
|
var body: some View {
|
|
RemoteImage(url: url, contentMode: .fit, opaqueBackground: false)
|
|
.frame(maxWidth: maxWidth, maxHeight: maxHeight, alignment: .leading)
|
|
.shadow(color: .black.opacity(0.55), radius: 10, y: 4)
|
|
}
|
|
}
|
|
|
|
struct PosterCard: View {
|
|
let item: MediaItem
|
|
var width: CGFloat = 112
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
RemoteImage(url: item.posterUrl)
|
|
.frame(width: width, height: width * 1.5)
|
|
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
|
|
.overlay(alignment: .topTrailing) {
|
|
if let badge = item.badge, !badge.isEmpty {
|
|
Text(badge)
|
|
.font(.system(size: 10, weight: .bold))
|
|
.padding(.horizontal, 6)
|
|
.padding(.vertical, 3)
|
|
.background(McColors.accent, in: Capsule())
|
|
.foregroundStyle(.white)
|
|
.padding(6)
|
|
}
|
|
}
|
|
.overlay(alignment: .bottom) {
|
|
if let pos = item.positionMs, let dur = item.durationMs, dur > 0, pos > 0 {
|
|
GeometryReader { geo in
|
|
Capsule()
|
|
.fill(McColors.accent)
|
|
.frame(width: geo.size.width * min(CGFloat(pos) / CGFloat(dur), 1), height: 3)
|
|
.frame(maxHeight: .infinity, alignment: .bottom)
|
|
}
|
|
.frame(height: 3)
|
|
.padding(4)
|
|
}
|
|
}
|
|
Text(item.title)
|
|
.font(McFonts.caption)
|
|
.foregroundStyle(McColors.text)
|
|
.lineLimit(2)
|
|
.multilineTextAlignment(.leading)
|
|
.frame(width: width, height: 36, alignment: .topLeading)
|
|
}
|
|
.frame(width: width, alignment: .leading)
|
|
}
|
|
}
|
|
|
|
struct MetaChip: View {
|
|
let text: String
|
|
var body: some View {
|
|
Text(text)
|
|
.font(McFonts.caption)
|
|
.foregroundStyle(McColors.textDim)
|
|
.padding(.horizontal, 10)
|
|
.padding(.vertical, 5)
|
|
.background(McColors.surfaceSoft, in: Capsule())
|
|
}
|
|
}
|
|
|
|
struct BrandHeader: View {
|
|
var subtitle: String? = nil
|
|
|
|
var body: some View {
|
|
HStack(spacing: 8) {
|
|
ZStack {
|
|
RoundedRectangle(cornerRadius: 7, style: .continuous)
|
|
.fill(
|
|
LinearGradient(
|
|
colors: [McColors.accent, McColors.accent.opacity(0.7)],
|
|
startPoint: .topLeading,
|
|
endPoint: .bottomTrailing
|
|
)
|
|
)
|
|
.frame(width: 26, height: 26)
|
|
.shadow(color: McColors.accent.opacity(0.45), radius: 8, y: 2)
|
|
Text("V")
|
|
.font(.system(size: 14, weight: .black, design: .rounded))
|
|
.foregroundStyle(.white)
|
|
}
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
Text("VMC")
|
|
.font(.system(size: 18, weight: .black, design: .rounded))
|
|
.foregroundStyle(McColors.text)
|
|
if let subtitle {
|
|
Text(subtitle)
|
|
.font(.system(size: 10, weight: .semibold, design: .rounded))
|
|
.foregroundStyle(McColors.textDim)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
struct MediaRouter: View {
|
|
let item: MediaItem
|
|
|
|
var body: some View {
|
|
switch item.type.lowercased() {
|
|
case "movie":
|
|
MovieDetailScreen(movieId: item.id)
|
|
case "series", "show":
|
|
SeriesDetailScreen(seriesId: item.id)
|
|
case "episode":
|
|
if let mediaFileId = item.mediaFileId {
|
|
PlayLauncher(
|
|
mediaFileId: mediaFileId,
|
|
title: item.title,
|
|
mediaType: "episode",
|
|
movieId: nil,
|
|
episodeId: item.id,
|
|
seriesId: item.seriesId,
|
|
startMs: item.positionMs ?? 0
|
|
)
|
|
} else {
|
|
ContentUnavailableView("Niet beschikbaar", systemImage: "play.slash")
|
|
}
|
|
default:
|
|
MovieDetailScreen(movieId: item.id)
|
|
}
|
|
}
|
|
}
|