stremio/apps/ios/VMCTV/UI/Components.swift
Jos Vooges | STH ac951617cb Add Live Events across API/admin/Android TV, mobile ClearKey/DASH fixes, and tvOS app.
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.
2026-09-18 23:34:42 +02:00

271 lines
9.6 KiB
Swift
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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())
}
}