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.
444 lines
19 KiB
Swift
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
|
|
}
|
|
}
|