stremio/apps/ios/VMCTV/UI/DetailViews.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

502 lines
20 KiB
Swift

import SwiftUI
struct MovieDetailView: View {
@EnvironmentObject private var app: AppModel
let movieId: String
@State private var movie: MovieDetail?
@State private var error: String?
@State private var loading = true
@State private var playId: String?
var body: some View {
Group {
if loading {
ProgressView().tint(.white)
} else if let error {
Text(error).font(TvFonts.body).foregroundStyle(TvColors.danger).padding()
} else if let movie {
ScrollView {
VStack(alignment: .leading, spacing: 0) {
DetailHero(
backdrop: movie.backdropUrl ?? movie.posterUrl,
logo: movie.logoUrl,
title: movie.title
)
VStack(alignment: .leading, spacing: 22) {
metaRow(year: movie.year, runtime: movie.runtime, rating: movie.rating, genres: movie.genres)
HStack(spacing: 20) {
if let mediaFileId = movie.preferredMediaFileId {
Button { playId = mediaFileId } label: {
Label("Afspelen", systemImage: "play.fill")
}
.buttonStyle(TvPrimaryButtonStyle())
}
Button {
Task {
_ = await app.favorites.toggle(
type: "movie",
id: movie.id,
title: movie.title,
year: movie.year,
posterUrl: movie.posterUrl,
backdropUrl: movie.backdropUrl,
logoUrl: movie.logoUrl,
api: app.api
)
}
} label: {
Label(
app.favorites.contains(type: "movie", id: movie.id)
? "In watchlist"
: "Watchlist",
systemImage: app.favorites.contains(type: "movie", id: movie.id)
? "bookmark.fill"
: "bookmark"
)
}
.buttonStyle(TvSecondaryButtonStyle())
Button {
Task {
try? await app.api.markWatched(movieId: movie.id)
}
} label: {
Label("Gezien", systemImage: "checkmark.circle")
}
.buttonStyle(TvSecondaryButtonStyle())
}
.focusSection()
if let overview = movie.overview, !overview.isEmpty {
Text(overview)
.font(TvFonts.body)
.foregroundStyle(TvColors.textDim)
.frame(maxWidth: 1100, alignment: .leading)
}
peopleSection(cast: movie.cast, director: movie.director)
if let files = movie.mediaFiles, files.count > 1 {
Text("Versies")
.font(TvFonts.headline)
.foregroundStyle(TvColors.text)
ForEach(files) { file in
Button { playId = file.id } label: {
HStack(spacing: 16) {
Text(file.label ?? file.resolution ?? "Versie")
.font(TvFonts.body)
.foregroundStyle(TvColors.text)
if let res = file.resolution {
MetaChip(text: res)
}
if file.nodeOnline == false {
Text("offline").foregroundStyle(TvColors.danger).font(TvFonts.caption)
}
Spacer()
Image(systemName: "play.circle.fill")
.font(.system(size: 32))
.foregroundStyle(TvColors.accent)
}
.padding(20)
.background(TvColors.surface, in: RoundedRectangle(cornerRadius: 16, style: .continuous))
}
.buttonStyle(FocusableCardStyle())
}
}
}
.padding(.horizontal, 56)
.padding(.top, 12)
.padding(.bottom, 48)
}
}
.fullScreenCover(item: Binding(
get: { playId.map { IdentifiedString(id: $0) } },
set: { playId = $0?.id }
)) { item in
PlayLauncher(
mediaFileId: item.id,
title: movie.title,
mediaType: "movie",
movieId: movie.id,
episodeId: nil,
seriesId: nil,
startMs: 0,
logoUrl: movie.logoUrl
)
.environmentObject(app)
}
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(TvColors.bg.ignoresSafeArea())
.task { await load() }
}
private func load() async {
loading = true
do {
movie = try await app.api.movie(id: movieId)
loading = false
} catch {
self.error = error.localizedDescription
loading = false
}
}
}
struct SeriesDetailView: View {
@EnvironmentObject private var app: AppModel
let seriesId: String
@State private var series: SeriesDetail?
@State private var error: String?
@State private var loading = true
@State private var seasonIndex = 0
@State private var playTarget: EpisodePlay?
var body: some View {
Group {
if loading {
ProgressView().tint(.white)
} else if let error {
Text(error).font(TvFonts.body).foregroundStyle(TvColors.danger).padding()
} else if let series {
ScrollView {
VStack(alignment: .leading, spacing: 0) {
DetailHero(
backdrop: series.backdropUrl ?? series.posterUrl,
logo: series.logoUrl,
title: series.title
)
VStack(alignment: .leading, spacing: 22) {
metaRow(year: series.year, runtime: nil, rating: series.rating, genres: series.genres)
Button {
Task {
_ = await app.favorites.toggle(
type: "series",
id: series.id,
title: series.title,
year: series.year,
posterUrl: series.posterUrl,
backdropUrl: series.backdropUrl,
logoUrl: series.logoUrl,
api: app.api
)
}
} label: {
Label(
app.favorites.contains(type: "series", id: series.id)
? "In watchlist"
: "Watchlist",
systemImage: app.favorites.contains(type: "series", id: series.id)
? "bookmark.fill"
: "bookmark"
)
}
.buttonStyle(TvSecondaryButtonStyle())
if let overview = series.overview, !overview.isEmpty {
Text(overview)
.font(TvFonts.body)
.foregroundStyle(TvColors.textDim)
.frame(maxWidth: 1100, alignment: .leading)
}
peopleSection(cast: series.cast, director: series.director)
if let seasons = series.seasons, !seasons.isEmpty {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 12) {
ForEach(Array(seasons.enumerated()), id: \.offset) { idx, season in
Button {
withAnimation(.easeOut(duration: 0.2)) { seasonIndex = idx }
} label: {
Text("Seizoen \(season.seasonNumber)")
.font(TvFonts.caption)
.foregroundStyle(seasonIndex == idx ? .white : TvColors.textDim)
.padding(.horizontal, 22)
.padding(.vertical, 12)
.background(seasonIndex == idx ? TvColors.accent : TvColors.surface, in: Capsule())
}
.buttonStyle(FocusableCardStyle())
}
}
}
.focusSection()
let episodes = seasons[safe: seasonIndex]?.episodes ?? []
LazyVStack(spacing: 14) {
ForEach(episodes) { ep in
episodeRow(ep, seriesTitle: series.title)
}
}
.focusSection()
}
}
.padding(.horizontal, 56)
.padding(.top, 12)
.padding(.bottom, 48)
}
}
.fullScreenCover(item: $playTarget) { target in
PlayLauncher(
mediaFileId: target.mediaFileId,
title: target.title,
mediaType: "episode",
movieId: nil,
episodeId: target.episodeId,
seriesId: seriesId,
startMs: target.startMs,
logoUrl: target.logoUrl
)
.environmentObject(app)
}
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(TvColors.bg.ignoresSafeArea())
.task { await load() }
}
@ViewBuilder
private func episodeRow(_ ep: Episode, seriesTitle: String) -> some View {
EpisodeRowButton(
ep: ep,
seriesTitle: seriesTitle,
seriesBackdrop: series?.backdropUrl,
seriesLogo: series?.logoUrl
) { play in
playTarget = play
}
}
private func load() async {
loading = true
do {
series = try await app.api.series(id: seriesId)
loading = false
} catch {
self.error = error.localizedDescription
loading = false
}
}
}
struct DetailHero: View {
let backdrop: String?
let logo: String?
let title: String
private let height: CGFloat = 560
var body: some View {
ZStack(alignment: .bottomLeading) {
RemoteImage(url: backdrop, alignment: .top, role: .backdrop)
.frame(maxWidth: .infinity)
.frame(height: height)
.clipped()
LinearGradient(
colors: [.clear, .black.opacity(0.15), TvColors.bg.opacity(0.9), TvColors.bg],
startPoint: .top,
endPoint: .bottom
)
VStack(alignment: .leading, spacing: 12) {
if let logo, !logo.isEmpty {
TitleLogo(url: logo, maxWidth: 480, maxHeight: 100)
} else {
Text(title)
.font(TvFonts.hero)
.foregroundStyle(.white)
.shadow(color: .black.opacity(0.5), radius: 10, y: 4)
.lineLimit(2)
}
}
.padding(.horizontal, 56)
.padding(.bottom, 28)
}
.frame(height: height)
}
}
private struct EpisodeRowButton: View {
@EnvironmentObject private var app: AppModel
let ep: Episode
let seriesTitle: String
let seriesBackdrop: String?
let seriesLogo: String?
let onPlay: (EpisodePlay) -> Void
private var title: String {
(ep.title?.isEmpty == false) ? ep.title! : "Aflevering \(ep.episodeNumber ?? 0)"
}
private var playable: Bool {
ep.mediaFileId.map { !$0.isEmpty } ?? false
}
var body: some View {
Button {
guard let mediaFileId = ep.mediaFileId, playable else { return }
onPlay(
EpisodePlay(
mediaFileId: mediaFileId,
episodeId: ep.id,
title: "\(seriesTitle) · \(title)",
startMs: ep.resumeMs,
logoUrl: seriesLogo
)
)
} label: {
EpisodeRowLabel(
ep: ep,
title: title,
playable: playable,
seriesBackdrop: seriesBackdrop
)
}
.buttonStyle(FocusableCardStyle(scale: 1.03))
.disabled(!playable)
.contextMenu {
Button("Markeer als gezien") {
Task { try? await app.api.markWatched(episodeId: ep.id) }
}
}
}
}
private struct EpisodeRowLabel: View {
let ep: Episode
let title: String
let playable: Bool
let seriesBackdrop: String?
@Environment(\.isFocused) private var isFocused
private var endsAtText: String? {
guard isFocused,
let remaining = EndsAt.remainingMs(
runtimeMin: ep.runtime,
positionMs: ep.positionMs,
completed: ep.completed
)
else { return nil }
let text = EndsAt.formatEndsAt(remainingMs: remaining)
return text.isEmpty ? nil : text
}
var body: some View {
HStack(spacing: 20) {
ZStack(alignment: .bottom) {
RemoteImage(url: ep.stillUrl ?? seriesBackdrop, alignment: .top, role: .backdrop)
.frame(width: 220, height: 124)
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
.opacity(ep.completed == true ? 0.55 : 1)
if ep.progressFraction > 0.02 && ep.completed != true {
ProgressBar(fraction: ep.progressFraction, height: 5)
.padding(8)
}
}
VStack(alignment: .leading, spacing: 8) {
Text("\(ep.episodeNumber ?? 0). \(title)")
.font(TvFonts.headline)
.foregroundStyle(playable ? TvColors.text : TvColors.textDim)
.lineLimit(2)
HStack(spacing: 14) {
if let runtime = ep.runtime {
Text("\(runtime) min")
.font(TvFonts.caption)
.foregroundStyle(TvColors.textDim)
}
if let endsAtText {
Text(endsAtText)
.font(TvFonts.caption)
.foregroundStyle(Color(red: 1.0, green: 0.84, blue: 0.2))
}
}
.animation(.easeOut(duration: 0.15), value: isFocused)
if let overview = ep.overview, !overview.isEmpty {
Text(overview)
.font(TvFonts.caption)
.foregroundStyle(TvColors.textDim)
.lineLimit(2)
}
}
Spacer()
if playable {
Image(systemName: "play.circle.fill")
.font(.system(size: 40))
.foregroundStyle(TvColors.accent)
}
}
.padding(16)
.background(
(isFocused ? TvColors.surface : TvColors.bgElevated),
in: RoundedRectangle(cornerRadius: 16, style: .continuous)
)
.overlay {
if isFocused {
RoundedRectangle(cornerRadius: 16, style: .continuous)
.strokeBorder(TvColors.accent.opacity(0.7), lineWidth: 2)
}
}
}
}
private struct EpisodePlay: Identifiable {
let id = UUID()
let mediaFileId: String
let episodeId: String
let title: String
let startMs: Int64
let logoUrl: String?
}
@ViewBuilder
private func metaRow(year: Int?, runtime: Int?, rating: String?, genres: [String]?) -> some View {
HStack(spacing: 12) {
if let year { MetaChip(text: "\(year)") }
if let runtime { MetaChip(text: "\(runtime) min") }
if let rating { MetaChip(text: "★ \(rating)") }
ForEach((genres ?? []).prefix(5), id: \.self) { MetaChip(text: $0) }
}
}
@ViewBuilder
private func peopleSection(cast: [String]?, director: [String]?) -> some View {
if let director, !director.isEmpty {
VStack(alignment: .leading, spacing: 6) {
Text("Regie").font(TvFonts.caption).foregroundStyle(TvColors.textDim)
Text(director.joined(separator: ", "))
.font(TvFonts.body)
.foregroundStyle(TvColors.text)
}
}
if let cast, !cast.isEmpty {
VStack(alignment: .leading, spacing: 6) {
Text("Cast").font(TvFonts.caption).foregroundStyle(TvColors.textDim)
Text(cast.prefix(10).joined(separator: ", "))
.font(TvFonts.body)
.foregroundStyle(TvColors.text)
.frame(maxWidth: 1100, alignment: .leading)
}
}
}
private struct IdentifiedString: Identifiable { let id: String }
private extension Array {
subscript(safe index: Int) -> Element? {
indices.contains(index) ? self[index] : nil
}
}