stremio/apps/ios/VMC/UI/DetailScreens.swift
Jos Vooges | STH 89ceccb557 Restore working iOS 0.5.35 player and fix Odido redirected DASH on iOS (0.5.41).
Recovered the ClearKey AVPlayer/proxy stack from the Mac stash, keep later UI fixes, resolve DASH segments against the redirected manifest URL and XML-unescape MPD values.
2026-09-27 23:34:12 +02:00

444 lines
19 KiB
Swift

import SwiftUI
import UIKit
struct MovieDetailScreen: 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).foregroundStyle(McColors.danger).padding()
} else if let movie {
GeometryReader { geo in
ScrollView {
VStack(alignment: .leading, spacing: 0) {
DetailHero(
backdrop: movie.backdropUrl ?? movie.posterUrl,
logo: movie.logoUrl,
title: movie.title,
width: geo.size.width
)
VStack(alignment: .leading, spacing: 16) {
metaRow(year: movie.year, runtime: movie.runtime, rating: movie.rating, genres: movie.genres)
HStack(spacing: 12) {
if let mediaFileId = movie.preferredMediaFileId {
Button { playId = mediaFileId } label: {
Label("Afspelen", systemImage: "play.fill")
.frame(maxWidth: .infinity)
}
.buttonStyle(McPrimaryButtonStyle())
}
BookmarkToggleButton(
isOn: app.favorites.contains(type: "movie", id: movie.id)
) {
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
)
}
}
}
if let overview = movie.overview, !overview.isEmpty {
Text(overview)
.font(McFonts.body)
.foregroundStyle(McColors.textDim)
.fixedSize(horizontal: false, vertical: true)
.frame(maxWidth: .infinity, alignment: .leading)
}
peopleSection(cast: movie.cast, director: movie.director)
if let files = movie.mediaFiles, !files.isEmpty {
Text("Versies")
.font(McFonts.headline)
.foregroundStyle(McColors.text)
ForEach(files) { file in
versionRow(file) { playId = file.id }
}
}
}
.padding(.horizontal, 16)
.padding(.top, 8)
.padding(.bottom, 28)
.frame(width: geo.size.width, alignment: .leading)
}
}
}
.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(McColors.bg.ignoresSafeArea())
.navigationBarTitleDisplayMode(.inline)
.toolbarBackground(.hidden, for: .navigationBar)
.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 SeriesDetailScreen: 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).foregroundStyle(McColors.danger).padding()
} else if let series {
GeometryReader { geo in
ScrollView {
VStack(alignment: .leading, spacing: 0) {
DetailHero(
backdrop: series.backdropUrl ?? series.posterUrl,
logo: series.logoUrl,
title: series.title,
width: geo.size.width
)
VStack(alignment: .leading, spacing: 16) {
metaRow(year: series.year, runtime: nil, rating: series.rating, genres: series.genres)
Button {
UIImpactFeedbackGenerator(style: .light).impactOccurred()
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 favorieten"
: "Bewaar in favorieten",
systemImage: app.favorites.contains(type: "series", id: series.id)
? "bookmark.fill"
: "bookmark"
)
.frame(maxWidth: .infinity)
}
.buttonStyle(McSecondaryButtonStyle())
if let overview = series.overview, !overview.isEmpty {
Text(overview)
.font(McFonts.body)
.foregroundStyle(McColors.textDim)
.fixedSize(horizontal: false, vertical: true)
.frame(maxWidth: .infinity, alignment: .leading)
}
peopleSection(cast: series.cast, director: series.director)
if let seasons = series.seasons, !seasons.isEmpty {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
ForEach(Array(seasons.enumerated()), id: \.offset) { idx, season in
Button {
withAnimation(.easeOut(duration: 0.2)) { seasonIndex = idx }
} label: {
Text("S\(season.seasonNumber)")
.font(McFonts.caption)
.foregroundStyle(seasonIndex == idx ? .white : McColors.textDim)
.padding(.horizontal, 14)
.padding(.vertical, 8)
.background(seasonIndex == idx ? McColors.accent : McColors.surface, in: Capsule())
}
.buttonStyle(.plain)
}
}
}
let episodes = seasons[safe: seasonIndex]?.episodes ?? []
LazyVStack(spacing: 12) {
ForEach(episodes) { ep in
episodeCard(ep, seriesTitle: series.title, width: geo.size.width - 32)
}
}
}
}
.padding(.horizontal, 16)
.padding(.top, 8)
.padding(.bottom, 28)
.frame(width: geo.size.width, alignment: .leading)
}
}
}
.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: series.logoUrl
)
.environmentObject(app)
}
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(McColors.bg.ignoresSafeArea())
.navigationBarTitleDisplayMode(.inline)
.task { await load() }
}
@ViewBuilder
private func episodeCard(_ ep: Episode, seriesTitle: String, width: CGFloat) -> some View {
let title = (ep.title?.isEmpty == false) ? ep.title! : "Aflevering \(ep.episodeNumber ?? 0)"
let playable = ep.mediaFileId.map { !$0.isEmpty } ?? false
let watched = ep.completed == true
let progress = ep.progressFraction
Button {
guard let mediaFileId = ep.mediaFileId, playable else { return }
playTarget = EpisodePlay(
mediaFileId: mediaFileId,
episodeId: ep.id,
title: "\(seriesTitle) · \(title)",
startMs: ep.resumeMs
)
} label: {
HStack(alignment: .top, spacing: 12) {
ZStack(alignment: .bottomTrailing) {
RemoteImage(url: ep.stillUrl ?? series?.backdropUrl)
.frame(width: 128, height: 72)
.clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
.opacity(watched ? 0.55 : 1)
if watched {
Image(systemName: "checkmark.circle.fill")
.font(.system(size: 22))
.foregroundStyle(.white)
.shadow(radius: 3)
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else if progress > 0.02 {
GeometryReader { geo in
VStack {
Spacer()
ZStack(alignment: .leading) {
Rectangle()
.fill(Color.white.opacity(0.35))
.frame(height: 3)
Rectangle()
.fill(McColors.accent)
.frame(width: max(0, geo.size.width * progress), height: 3)
}
}
}
.frame(width: 128, height: 72)
.clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous))
} else if playable {
Image(systemName: "play.circle.fill")
.foregroundStyle(.white)
.shadow(radius: 4)
.padding(6)
}
}
.frame(width: 128, height: 72)
VStack(alignment: .leading, spacing: 4) {
Text("\(ep.episodeNumber ?? 0). \(title)")
.font(McFonts.headline)
.foregroundStyle(watched ? McColors.textDim : (playable ? McColors.text : McColors.textDim))
.lineLimit(2)
.multilineTextAlignment(.leading)
.frame(maxWidth: .infinity, alignment: .leading)
if let runtime = ep.runtime {
Text("\(runtime) min")
.font(McFonts.caption)
.foregroundStyle(McColors.textDim)
}
if let overview = ep.overview, !overview.isEmpty {
Text(overview)
.font(McFonts.caption)
.foregroundStyle(McColors.textDim)
.lineLimit(3)
.multilineTextAlignment(.leading)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
}
.padding(10)
.frame(width: width, alignment: .leading)
.background(McColors.bgElevated, in: RoundedRectangle(cornerRadius: 12, style: .continuous))
}
.buttonStyle(.plain)
.disabled(!playable)
}
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
let width: CGFloat
var body: some View {
ZStack(alignment: .bottomLeading) {
RemoteImage(url: backdrop)
.frame(width: width, height: 300)
.clipped()
LinearGradient(
colors: [.clear, .black.opacity(0.2), McColors.bg.opacity(0.95), McColors.bg],
startPoint: .top,
endPoint: .bottom
)
.frame(width: width, height: 300)
VStack(alignment: .leading, spacing: 10) {
if let logo, !logo.isEmpty {
TitleLogo(url: logo, maxWidth: min(width - 40, 260), maxHeight: 64)
} else {
Text(title)
.font(.system(size: 28, weight: .black, design: .rounded))
.foregroundStyle(.white)
.shadow(color: .black.opacity(0.5), radius: 8, y: 3)
.fixedSize(horizontal: false, vertical: true)
.frame(maxWidth: width - 32, alignment: .leading)
}
}
.padding(.horizontal, 16)
.padding(.bottom, 18)
}
.frame(width: width, height: 300)
.clipped()
}
}
@ViewBuilder
private func metaRow(year: Int?, runtime: Int?, rating: String?, genres: [String]?) -> some View {
ScrollView(.horizontal, showsIndicators: false) {
HStack(spacing: 8) {
if let year { MetaChip(text: "\(year)") }
if let runtime { MetaChip(text: "\(runtime) min") }
if let rating { MetaChip(text: "★ \(rating)") }
ForEach(genres ?? [], id: \.self) { MetaChip(text: $0) }
}
}
}
@ViewBuilder
private func peopleSection(cast: [String]?, director: [String]?) -> some View {
if let director, !director.isEmpty {
VStack(alignment: .leading, spacing: 4) {
Text("Regie").font(McFonts.caption).foregroundStyle(McColors.textDim)
Text(director.joined(separator: ", "))
.font(McFonts.body)
.foregroundStyle(McColors.text)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
if let cast, !cast.isEmpty {
VStack(alignment: .leading, spacing: 4) {
Text("Cast").font(McFonts.caption).foregroundStyle(McColors.textDim)
Text(cast.prefix(8).joined(separator: ", "))
.font(McFonts.body)
.foregroundStyle(McColors.text)
.fixedSize(horizontal: false, vertical: true)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
}
@ViewBuilder
private func versionRow(_ file: MediaFileInfo, onPlay: @escaping () -> Void) -> some View {
Button(action: onPlay) {
HStack(spacing: 12) {
QualityBadgeRow(file: file)
Image(systemName: "play.circle.fill")
.font(.title2)
.foregroundStyle(McColors.accent)
}
.padding(.horizontal, 14)
.padding(.vertical, 12)
.background(McColors.surface, in: RoundedRectangle(cornerRadius: 12, style: .continuous))
}
.buttonStyle(.plain)
}
private struct IdentifiedString: Identifiable { let id: String }
private struct EpisodePlay: Identifiable {
let id = UUID()
let mediaFileId: String
let episodeId: String
let title: String
let startMs: Int64
}
private extension Array {
subscript(safe index: Int) -> Element? {
indices.contains(index) ? self[index] : nil
}
}