Wire FAAB schedule-events into master-api and admin IPTV settings, add Android TV Events UI, fix mobile ClearKey/stream playback (DASH + bump 1.0.23), and scaffold the Apple TV (VMCTV) client with docs/scripts.
271 lines
9.6 KiB
Swift
271 lines
9.6 KiB
Swift
import SwiftUI
|
||
|
||
enum RemoteImageRole {
|
||
/// Shelf/grid posters (~168pt breed) → TMDB w342
|
||
case poster
|
||
/// Hero / detail backdrop → TMDB w1280 i.p.v. original
|
||
case backdrop
|
||
/// Title logo → w500
|
||
case logo
|
||
/// Ongewijzigd
|
||
case raw
|
||
}
|
||
|
||
struct RemoteImage: View {
|
||
let url: String?
|
||
var contentMode: ContentMode = .fill
|
||
var opaqueBackground: Bool = true
|
||
/// Bij `.fill`: voorkomt dat gezichten/logo’s bovenin worden weggeknipt.
|
||
var alignment: Alignment = .center
|
||
var role: RemoteImageRole = .poster
|
||
|
||
var body: some View {
|
||
Group {
|
||
if let u = Self.tunedURL(url, role: role) {
|
||
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
|
||
}
|
||
}
|
||
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: alignment)
|
||
.clipped()
|
||
.background(opaqueBackground ? TvColors.surface : Color.clear)
|
||
}
|
||
|
||
private var placeholder: some View {
|
||
Rectangle().fill(TvColors.surface)
|
||
}
|
||
|
||
/// Zelfde look op TV, veel kleinere downloads (esp. home met veel posters + hero).
|
||
static func tunedURL(_ raw: String?, role: RemoteImageRole) -> URL? {
|
||
guard var s = raw?.trimmingCharacters(in: .whitespacesAndNewlines), !s.isEmpty else {
|
||
return nil
|
||
}
|
||
if s.contains("image.tmdb.org/t/p/") {
|
||
switch role {
|
||
case .poster:
|
||
s = s
|
||
.replacingOccurrences(of: "/t/p/original/", with: "/t/p/w342/")
|
||
.replacingOccurrences(of: "/t/p/w780/", with: "/t/p/w342/")
|
||
.replacingOccurrences(of: "/t/p/w500/", with: "/t/p/w342/")
|
||
case .backdrop:
|
||
s = s
|
||
.replacingOccurrences(of: "/t/p/original/", with: "/t/p/w1280/")
|
||
.replacingOccurrences(of: "/t/p/w1920/", with: "/t/p/w1280/")
|
||
case .logo:
|
||
s = s
|
||
.replacingOccurrences(of: "/t/p/original/", with: "/t/p/w500/")
|
||
.replacingOccurrences(of: "/t/p/w780/", with: "/t/p/w500/")
|
||
case .raw:
|
||
break
|
||
}
|
||
}
|
||
return URL(string: s)
|
||
}
|
||
}
|
||
|
||
struct TitleLogo: View {
|
||
let url: String?
|
||
var maxWidth: CGFloat = 420
|
||
var maxHeight: CGFloat = 120
|
||
|
||
var body: some View {
|
||
RemoteImage(url: url, contentMode: .fit, opaqueBackground: false, role: .logo)
|
||
.frame(maxWidth: maxWidth, maxHeight: maxHeight, alignment: .leading)
|
||
.shadow(color: .black.opacity(0.55), radius: 16, y: 6)
|
||
}
|
||
}
|
||
|
||
struct BrandLogo: View {
|
||
var size: CGFloat = 36
|
||
|
||
var body: some View {
|
||
HStack(spacing: 12) {
|
||
ZStack {
|
||
RoundedRectangle(cornerRadius: size * 0.28, style: .continuous)
|
||
.fill(
|
||
LinearGradient(
|
||
colors: [TvColors.accent, TvColors.accent.opacity(0.7)],
|
||
startPoint: .topLeading,
|
||
endPoint: .bottomTrailing
|
||
)
|
||
)
|
||
.frame(width: size, height: size)
|
||
.shadow(color: TvColors.accent.opacity(0.45), radius: 12, y: 3)
|
||
Text("V")
|
||
.font(.system(size: size * 0.55, weight: .black, design: .rounded))
|
||
.foregroundStyle(.white)
|
||
}
|
||
VStack(alignment: .leading, spacing: 0) {
|
||
Text("VMC")
|
||
.font(.system(size: size * 0.55, weight: .black, design: .rounded))
|
||
.foregroundStyle(TvColors.text)
|
||
Text("Media Cluster")
|
||
.font(.system(size: size * 0.28, weight: .medium, design: .rounded))
|
||
.foregroundStyle(TvColors.textDim)
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
struct PosterCard: View {
|
||
let item: MediaItem
|
||
var width: CGFloat = 180
|
||
var showTitle: Bool = true
|
||
@Environment(\.isFocused) private var isFocused
|
||
|
||
var body: some View {
|
||
VStack(alignment: .leading, spacing: showTitle ? 10 : 0) {
|
||
ZStack(alignment: .bottom) {
|
||
RemoteImage(url: item.posterUrl, alignment: .top, role: .poster)
|
||
.frame(width: width, height: width * 1.5)
|
||
.clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous))
|
||
|
||
if let pos = item.positionMs, let dur = item.durationMs, dur > 0, pos > 0 {
|
||
ProgressBar(fraction: min(CGFloat(pos) / CGFloat(dur), 1), height: 5)
|
||
.padding(8)
|
||
}
|
||
}
|
||
.overlay(alignment: .topTrailing) {
|
||
if let badge = item.badge, !badge.isEmpty {
|
||
Text(badge)
|
||
.font(.system(size: 16, weight: .bold))
|
||
.padding(.horizontal, 10)
|
||
.padding(.vertical, 5)
|
||
.background(TvColors.accent, in: Capsule())
|
||
.foregroundStyle(.white)
|
||
.padding(10)
|
||
}
|
||
}
|
||
.overlay {
|
||
if isFocused {
|
||
RoundedRectangle(cornerRadius: 14, style: .continuous)
|
||
.strokeBorder(TvColors.accent, lineWidth: 3.5)
|
||
.shadow(color: TvColors.accent.opacity(0.7), radius: 18, y: 0)
|
||
.allowsHitTesting(false)
|
||
}
|
||
}
|
||
.shadow(color: isFocused ? TvColors.accent.opacity(0.45) : .black.opacity(0.35), radius: isFocused ? 24 : 8, y: isFocused ? 12 : 4)
|
||
.animation(.easeOut(duration: 0.2), value: isFocused)
|
||
|
||
if showTitle {
|
||
Text(item.title)
|
||
.font(TvFonts.caption)
|
||
.foregroundStyle(isFocused ? TvColors.text : TvColors.textDim)
|
||
.lineLimit(2)
|
||
.frame(width: width, alignment: .leading)
|
||
}
|
||
}
|
||
.frame(width: width, alignment: .leading)
|
||
}
|
||
}
|
||
|
||
struct ProgressBar: View {
|
||
let fraction: CGFloat
|
||
var height: CGFloat = 6
|
||
|
||
var body: some View {
|
||
GeometryReader { geo in
|
||
ZStack(alignment: .leading) {
|
||
Capsule().fill(Color.white.opacity(0.25))
|
||
Capsule()
|
||
.fill(TvColors.accent)
|
||
.frame(width: max(0, geo.size.width * max(0, min(fraction, 1))))
|
||
}
|
||
}
|
||
.frame(height: height)
|
||
}
|
||
}
|
||
|
||
enum EndsAt {
|
||
static func formatEndsAt(remainingMs: Int64) -> String {
|
||
guard remainingMs >= 30_000 else { return "" }
|
||
let end = Date().addingTimeInterval(Double(remainingMs) / 1000.0)
|
||
let formatter = DateFormatter()
|
||
formatter.locale = Locale(identifier: "nl_NL")
|
||
formatter.dateFormat = "HH:mm"
|
||
return "Eindigt om \(formatter.string(from: end))"
|
||
}
|
||
|
||
/// Resterende speelduur op basis van runtime (minuten) + voortgang, zoals Android TV.
|
||
static func remainingMs(runtimeMin: Int?, positionMs: Int64?, completed: Bool?) -> Int64? {
|
||
guard let runtimeMin, runtimeMin > 0, completed != true else { return nil }
|
||
let total = Int64(runtimeMin) * 60_000
|
||
let remaining = max(0, total - max(0, positionMs ?? 0))
|
||
return remaining >= 30_000 ? remaining : nil
|
||
}
|
||
|
||
static func formatTime(_ ms: Int64) -> String {
|
||
let total = max(0, Int(ms / 1000))
|
||
let h = total / 3600
|
||
let m = (total % 3600) / 60
|
||
let s = total % 60
|
||
if h > 0 {
|
||
return String(format: "%d:%02d:%02d", h, m, s)
|
||
}
|
||
return String(format: "%d:%02d", m, s)
|
||
}
|
||
}
|
||
|
||
struct MediaRouter: View {
|
||
let item: MediaItem
|
||
|
||
var body: some View {
|
||
switch item.type.lowercased() {
|
||
case "movie":
|
||
MovieDetailView(movieId: item.id)
|
||
case "series", "show":
|
||
SeriesDetailView(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,
|
||
logoUrl: item.logoUrl
|
||
)
|
||
} else {
|
||
Text("Niet beschikbaar")
|
||
.font(TvFonts.title)
|
||
.foregroundStyle(TvColors.textDim)
|
||
}
|
||
default:
|
||
MovieDetailView(movieId: item.id)
|
||
}
|
||
}
|
||
}
|
||
|
||
struct MetaChip: View {
|
||
let text: String
|
||
var body: some View {
|
||
Text(text)
|
||
.font(TvFonts.caption)
|
||
.foregroundStyle(TvColors.textDim)
|
||
.padding(.horizontal, 16)
|
||
.padding(.vertical, 8)
|
||
.background(TvColors.surfaceSoft, in: Capsule())
|
||
}
|
||
}
|