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.
502 lines
20 KiB
Swift
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
|
|
}
|
|
}
|