stremio/apps/ios/VMC/UI/Components.swift
Jos Vooges | STH 6ffeb50733 Add in-app device approve/QR pairing and polish iOS browse.
Owners can list/revoke devices and approve TV codes in the iOS app; TV shows a vmc:// QR, and Films/Series get visible chips, titles, and a non-overlapping A–Z index.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-09-10 03:18:38 +02:00

177 lines
6.2 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
/// Grid-bibliotheek: geen titel onder poster (zoals Android Browse).
var showTitle: Bool = true
var body: some View {
VStack(alignment: .leading, spacing: showTitle ? 6 : 0) {
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)
}
}
if showTitle {
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)
.accessibilityLabel(item.title)
}
}
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)
}
}
}