stremio/apps/ios/VMCTV/Playback/TVPlayerView.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

823 lines
31 KiB
Swift

import SwiftUI
struct PlayRequest: Identifiable {
let id = UUID()
let url: URL
let title: String
let mediaType: String
let mediaFileId: String?
let movieId: String?
let episodeId: String?
let seriesId: String?
let startMs: Int64
let isLive: Bool
let fallbackUrl: URL?
let subtitleFileURL: URL?
let logoUrl: String?
/// Live-only context for zap / zenderlijst / EPG.
let liveStreamId: Int?
let liveChannels: [IptvChannel]
let liveCategoryId: String?
static func vod(
url: URL,
title: String,
mediaType: String,
mediaFileId: String,
movieId: String?,
episodeId: String?,
seriesId: String?,
startMs: Int64,
subtitleFileURL: URL? = nil,
logoUrl: String? = nil
) -> PlayRequest {
PlayRequest(
url: url, title: title, mediaType: mediaType, mediaFileId: mediaFileId,
movieId: movieId, episodeId: episodeId, seriesId: seriesId,
startMs: startMs, isLive: false, fallbackUrl: nil,
subtitleFileURL: subtitleFileURL, logoUrl: logoUrl,
liveStreamId: nil, liveChannels: [], liveCategoryId: nil
)
}
static func live(
url: URL,
title: String,
fallback: URL?,
logoUrl: String? = nil,
streamId: Int? = nil,
channels: [IptvChannel] = [],
categoryId: String? = nil
) -> PlayRequest {
PlayRequest(
url: url, title: title, mediaType: "live", mediaFileId: nil,
movieId: nil, episodeId: nil, seriesId: nil,
startMs: 0, isLive: true, fallbackUrl: fallback,
subtitleFileURL: nil, logoUrl: logoUrl,
liveStreamId: streamId, liveChannels: channels, liveCategoryId: categoryId
)
}
}
private enum PlayerPanel {
case none
case tracks
case subtitles
case liveGuide
}
private enum PlayerControlFocus: Hashable {
case audio
case captions
case rewind
case play
case forward
}
struct TVPlayerView: View {
@EnvironmentObject private var app: AppModel
@Environment(\.dismiss) private var dismiss
let request: PlayRequest
@StateObject private var player = VLCPlayerController()
@State private var controlsVisible = true
@State private var panel: PlayerPanel = .none
@State private var subtitleHits: [SubtitleHit] = []
@State private var subsLoading = false
@State private var subsError: String?
@State private var progressTask: Task<Void, Never>?
@State private var hideTask: Task<Void, Never>?
@FocusState private var focusedControl: PlayerControlFocus?
@State private var liveStreamId: Int?
@State private var liveTitle: String = ""
@State private var liveLogoUrl: String?
@State private var liveEpg: IptvEpg?
@State private var liveZapBusy = false
var body: some View {
ZStack {
Color.black.ignoresSafeArea()
VLCVideoView(controller: player)
.ignoresSafeArea()
if controlsVisible && panel == .none {
controlsOverlay
.transition(.opacity)
} else if panel == .none {
Color.clear
.contentShape(Rectangle())
.focusable()
.onMoveCommand { direction in
if request.isLive {
handleLiveMove(direction)
} else {
showControls()
focusedControl = .play
switch direction {
case .left: player.seek(byMs: -10_000)
case .right: player.seek(byMs: 30_000)
default: break
}
}
}
}
if panel == .tracks {
tracksPanel
.transition(.move(edge: .trailing).combined(with: .opacity))
}
if panel == .subtitles {
subtitlesPanel
.transition(.move(edge: .trailing).combined(with: .opacity))
}
if panel == .liveGuide {
liveGuidePanel
.transition(.move(edge: .trailing).combined(with: .opacity))
}
if player.hasError {
errorOverlay(player.errorMessage ?? "Afspeelfout")
}
}
.onPlayPauseCommand {
player.togglePlay()
showControls()
focusedControl = .play
}
.onExitCommand {
if panel != .none {
withAnimation(.easeOut(duration: 0.2)) { panel = .none }
showControls()
focusedControl = .play
} else if controlsVisible {
hideControls()
} else {
dismiss()
}
}
.onAppear { app.enterPlayback() }
.task {
await start()
focusedControl = .play
scheduleHide()
}
.onDisappear {
app.leavePlayback()
hideTask?.cancel()
progressTask?.cancel()
Task { await saveProgress() }
player.stop()
}
.animation(.easeOut(duration: 0.2), value: panel)
.animation(.easeOut(duration: 0.2), value: controlsVisible)
}
private var controlsOverlay: some View {
VStack(spacing: 0) {
HStack(alignment: .top) {
titleBlock
Spacer(minLength: 24)
if request.isLive {
Button {
withAnimation { panel = .liveGuide }
scheduleHide(cancelOnly: true)
} label: {
Label("Zenders", systemImage: "list.bullet")
.font(TvFonts.caption)
.foregroundStyle(.white)
.padding(.horizontal, 18)
.padding(.vertical, 14)
.background(.ultraThinMaterial, in: Capsule())
}
.buttonStyle(FocusableCardStyle(scale: 1.08))
} else {
HStack(spacing: 18) {
Button {
player.refreshTracks()
withAnimation { panel = .tracks }
scheduleHide(cancelOnly: true)
} label: {
Label("Audio", systemImage: "waveform")
.font(TvFonts.caption)
.foregroundStyle(.white)
.padding(.horizontal, 18)
.padding(.vertical, 14)
.background(.ultraThinMaterial, in: Capsule())
}
.buttonStyle(FocusableCardStyle(scale: 1.08))
.focused($focusedControl, equals: .audio)
Button {
withAnimation { panel = .subtitles }
scheduleHide(cancelOnly: true)
Task { await loadSubtitles() }
} label: {
Label("Ondertitel", systemImage: "captions.bubble.fill")
.font(TvFonts.caption)
.foregroundStyle(.white)
.padding(.horizontal, 18)
.padding(.vertical, 14)
.background(.ultraThinMaterial, in: Capsule())
}
.buttonStyle(FocusableCardStyle(scale: 1.08))
.focused($focusedControl, equals: .captions)
}
.focusSection()
}
}
.padding(.horizontal, 48)
.padding(.top, 36)
.focusSection()
Spacer()
VStack(spacing: 20) {
if !request.isLive {
ProgressBar(fraction: CGFloat(player.position), height: 8)
HStack {
Text(player.timeText)
.font(TvFonts.caption)
.foregroundStyle(.white.opacity(0.85))
.monospacedDigit()
Spacer()
Text(player.lengthText)
.font(TvFonts.caption)
.foregroundStyle(.white.opacity(0.85))
.monospacedDigit()
}
}
HStack(spacing: 48) {
if !request.isLive {
Button {
player.seek(byMs: -10_000)
showControls()
} label: {
Image(systemName: "gobackward.10")
.font(.system(size: 36))
.foregroundStyle(.white)
}
.buttonStyle(FocusableCardStyle())
.focused($focusedControl, equals: .rewind)
}
Button {
player.togglePlay()
showControls()
} label: {
Image(systemName: player.isPlaying ? "pause.circle.fill" : "play.circle.fill")
.font(.system(size: 72))
.foregroundStyle(.white)
.shadow(radius: 12)
}
.buttonStyle(FocusableCardStyle())
.focused($focusedControl, equals: .play)
if !request.isLive {
Button {
player.seek(byMs: 30_000)
showControls()
} label: {
Image(systemName: "goforward.30")
.font(.system(size: 36))
.foregroundStyle(.white)
}
.buttonStyle(FocusableCardStyle())
.focused($focusedControl, equals: .forward)
}
}
.focusSection()
}
.padding(40)
.background(
LinearGradient(colors: [.clear, .black.opacity(0.85)], startPoint: .top, endPoint: .bottom)
)
}
}
@ViewBuilder
private var titleBlock: some View {
VStack(alignment: .leading, spacing: 10) {
if request.isLive {
HStack(spacing: 16) {
if let logo = liveLogoUrl ?? request.logoUrl, !logo.isEmpty {
RemoteImage(url: logo, contentMode: .fit, opaqueBackground: false)
.frame(width: 72, height: 72)
.padding(8)
.background(Color.white.opacity(0.08), in: RoundedRectangle(cornerRadius: 14, style: .continuous))
}
VStack(alignment: .leading, spacing: 6) {
Text("LIVE")
.font(.system(size: 16, weight: .heavy, design: .rounded))
.tracking(1.2)
.foregroundStyle(TvColors.accent)
Text(liveTitle.isEmpty ? request.title : liveTitle)
.font(TvFonts.headline)
.foregroundStyle(.white)
.lineLimit(1)
Text(player.stateText)
.font(TvFonts.caption)
.foregroundStyle(.white.opacity(0.7))
}
}
if let now = liveEpg?.now?.title {
Text("Nu · \(now)")
.font(TvFonts.body)
.foregroundStyle(.white.opacity(0.92))
.lineLimit(1)
}
if let next = liveEpg?.next?.title {
Text("Straks · \(next)")
.font(TvFonts.caption)
.foregroundStyle(.white.opacity(0.65))
.lineLimit(1)
}
Text("↑↓ zappen · ← zenderlijst · OK info")
.font(.system(size: 16, weight: .medium, design: .rounded))
.foregroundStyle(.white.opacity(0.45))
} else {
if let logo = request.logoUrl, !logo.isEmpty {
TitleLogo(url: logo, maxWidth: 360, maxHeight: 72)
} else {
Text(request.title)
.font(TvFonts.headline)
.foregroundStyle(.white)
.lineLimit(1)
}
Text(player.stateText)
.font(TvFonts.caption)
.foregroundStyle(.white.opacity(0.7))
if player.durationMs() > 0 {
let remaining = max(0, player.durationMs() - player.currentPositionMs())
Text(EndsAt.formatEndsAt(remainingMs: remaining))
.font(TvFonts.caption)
.foregroundStyle(.white.opacity(0.75))
}
}
}
}
private var liveGuidePanel: some View {
HStack {
Spacer()
VStack(alignment: .leading, spacing: 0) {
Text("Zenders")
.font(TvFonts.headline)
.foregroundStyle(.white)
.padding(.bottom, 18)
ScrollView {
LazyVStack(alignment: .leading, spacing: 10) {
ForEach(request.liveChannels) { ch in
Button {
Task { await zapTo(ch) }
} label: {
HStack(spacing: 14) {
RemoteImage(url: ch.logoUrl, contentMode: .fit, opaqueBackground: false)
.frame(width: 44, height: 44)
VStack(alignment: .leading, spacing: 4) {
Text(ch.name)
.font(TvFonts.body)
.foregroundStyle(.white)
.lineLimit(1)
if let now = ch.nowTitle, !now.isEmpty {
Text(now)
.font(TvFonts.caption)
.foregroundStyle(TvColors.textDim)
.lineLimit(1)
}
}
Spacer()
if ch.streamId == liveStreamId {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(TvColors.accent)
}
}
.padding(14)
.background(
(ch.streamId == liveStreamId ? TvColors.accent.opacity(0.2) : TvColors.surface.opacity(0.9)),
in: RoundedRectangle(cornerRadius: 12, style: .continuous)
)
}
.buttonStyle(FocusableCardStyle(scale: 1.03))
}
}
}
.focusSection()
Button("Sluiten") {
withAnimation { panel = .none }
showControls()
}
.buttonStyle(TvSecondaryButtonStyle())
.padding(.top, 20)
}
.padding(32)
.frame(width: 560)
.frame(maxHeight: .infinity, alignment: .top)
.background { Rectangle().fill(.ultraThinMaterial) }
.focusSection()
}
.background(Color.black.opacity(0.45).ignoresSafeArea())
}
private var tracksPanel: some View {
HStack {
Spacer()
VStack(alignment: .leading, spacing: 0) {
Text("Audio & ondertitels")
.font(TvFonts.headline)
.foregroundStyle(.white)
.padding(.bottom, 20)
ScrollView {
VStack(alignment: .leading, spacing: 22) {
trackSection(title: "Audio", tracks: player.audioTracks, currentId: player.currentAudioId) { id in
player.selectAudio(id: id)
withAnimation { panel = .none }
showControls()
focusedControl = .play
}
trackSection(title: "Ingebedde ondertitels", tracks: player.subtitleTracks, currentId: player.currentSubtitleId) { id in
player.selectSubtitle(id: id)
withAnimation { panel = .none }
showControls()
focusedControl = .play
}
}
}
Button("Sluiten") {
withAnimation { panel = .none }
showControls()
focusedControl = .audio
}
.buttonStyle(TvSecondaryButtonStyle())
.padding(.top, 24)
}
.padding(36)
.frame(width: 620)
.frame(maxHeight: .infinity, alignment: .top)
.background {
Rectangle().fill(.ultraThinMaterial)
}
.focusSection()
}
.background(Color.black.opacity(0.45).ignoresSafeArea())
}
private func trackSection(
title: String,
tracks: [PlayerTrack],
currentId: Int32?,
onSelect: @escaping (Int32) -> Void
) -> some View {
VStack(alignment: .leading, spacing: 12) {
Text(title)
.font(TvFonts.caption)
.foregroundStyle(TvColors.textDim)
if tracks.isEmpty {
Text("Geen tracks")
.font(TvFonts.body)
.foregroundStyle(TvColors.textDim)
} else {
ForEach(tracks) { track in
Button {
onSelect(track.id)
} label: {
HStack {
Text(track.name)
.font(TvFonts.body)
.foregroundStyle(.white)
Spacer()
if track.id == currentId {
Image(systemName: "checkmark.circle.fill")
.foregroundStyle(TvColors.accent)
}
}
.padding(.horizontal, 18)
.padding(.vertical, 14)
.background(TvColors.surface.opacity(0.9), in: RoundedRectangle(cornerRadius: 12, style: .continuous))
}
.buttonStyle(FocusableCardStyle(scale: 1.04))
}
}
}
}
private var subtitlesPanel: some View {
HStack {
Spacer()
VStack(alignment: .leading, spacing: 0) {
Text("OpenSubtitles")
.font(TvFonts.headline)
.foregroundStyle(.white)
.padding(.bottom, 20)
Group {
if subsLoading {
ProgressView("Zoeken…").tint(.white).padding(.top, 40)
} else if let subsError {
Text(subsError).foregroundStyle(TvColors.danger).padding(.top, 20)
} else if subtitleHits.isEmpty {
Text("Geen ondertitels gevonden")
.font(TvFonts.body)
.foregroundStyle(TvColors.textDim)
.padding(.top, 20)
} else {
ScrollView {
LazyVStack(alignment: .leading, spacing: 12) {
ForEach(subtitleHits) { hit in
Button {
Task { await applySubtitle(hit) }
} label: {
VStack(alignment: .leading, spacing: 6) {
Text(hit.release ?? hit.fileName ?? "Ondertitel")
.font(TvFonts.body)
.foregroundStyle(.white)
.lineLimit(2)
HStack(spacing: 12) {
Text((hit.language ?? "?").uppercased())
if hit.hearingImpaired == true { Text("SDH") }
if let n = hit.downloadCount { Text("↓\(n)") }
Text((hit.source ?? "").uppercased())
}
.font(TvFonts.caption)
.foregroundStyle(TvColors.textDim)
}
.padding(16)
.frame(maxWidth: .infinity, alignment: .leading)
.background(TvColors.surface.opacity(0.9), in: RoundedRectangle(cornerRadius: 12, style: .continuous))
}
.buttonStyle(FocusableCardStyle(scale: 1.03))
}
}
}
.focusSection()
}
}
.frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading)
Button("Sluiten") {
withAnimation { panel = .none }
showControls()
focusedControl = .captions
}
.buttonStyle(TvSecondaryButtonStyle())
.padding(.top, 24)
}
.padding(36)
.frame(width: 680)
.frame(maxHeight: .infinity, alignment: .top)
.background {
Rectangle().fill(.ultraThinMaterial)
}
.focusSection()
}
.background(Color.black.opacity(0.45).ignoresSafeArea())
}
private func errorOverlay(_ message: String) -> some View {
VStack(spacing: 20) {
Image(systemName: "exclamationmark.triangle.fill")
.font(.system(size: 48))
.foregroundStyle(TvColors.danger)
Text(message)
.font(TvFonts.body)
.foregroundStyle(.white)
.multilineTextAlignment(.center)
Button("Sluiten") { dismiss() }
.buttonStyle(TvPrimaryButtonStyle())
}
.padding(40)
.background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: 24, style: .continuous))
}
private func showControls(cancelHide: Bool = false) {
controlsVisible = true
if cancelHide {
hideTask?.cancel()
} else {
scheduleHide()
}
}
private func hideControls() {
guard panel == .none else { return }
controlsVisible = false
focusedControl = nil
}
private func scheduleHide(cancelOnly: Bool = false) {
hideTask?.cancel()
if cancelOnly { return }
hideTask = Task {
try? await Task.sleep(nanoseconds: 5_000_000_000)
guard !Task.isCancelled, panel == .none else { return }
await MainActor.run { hideControls() }
}
}
private func start() async {
liveStreamId = request.liveStreamId
liveTitle = request.title
liveLogoUrl = request.logoUrl
if request.isLive {
player.playLive(url: request.url, fallback: request.fallbackUrl)
if let id = request.liveStreamId {
await refreshLiveEpg(streamId: id)
}
} else {
player.play(url: request.url, startMs: request.startMs, subtitleFileURL: request.subtitleFileURL)
progressTask = Task {
while !Task.isCancelled {
try? await Task.sleep(nanoseconds: 12_000_000_000)
await saveProgress()
}
}
}
}
private func handleLiveMove(_ direction: MoveCommandDirection) {
showControls()
switch direction {
case .up:
Task { await zap(delta: -1) }
case .down:
Task { await zap(delta: 1) }
case .left:
withAnimation { panel = .liveGuide }
scheduleHide(cancelOnly: true)
case .right:
break
@unknown default:
break
}
}
private func zap(delta: Int) async {
let list = request.liveChannels
guard !list.isEmpty, !liveZapBusy else { return }
let current = liveStreamId ?? request.liveStreamId
guard let current,
let idx = list.firstIndex(where: { $0.streamId == current })
else { return }
let nextIdx = (idx + delta + list.count) % list.count
await zapTo(list[nextIdx])
}
private func zapTo(_ ch: IptvChannel) async {
guard !liveZapBusy else { return }
liveZapBusy = true
defer { liveZapBusy = false }
do {
let info = try await app.api.iptvPlay(streamId: ch.streamId)
guard let url = URL(string: info.streamUrl) else { return }
let fallback = info.fallbackStreamUrl.flatMap(URL.init(string:))
liveStreamId = ch.streamId
liveTitle = info.name
liveLogoUrl = info.logoUrl ?? ch.logoUrl
app.store.setLastIptvStream(ch.streamId)
player.playLive(url: url, fallback: fallback)
withAnimation { panel = .none }
showControls()
await refreshLiveEpg(streamId: ch.streamId)
} catch {
// keep current channel
}
}
private func refreshLiveEpg(streamId: Int) async {
liveEpg = try? await app.api.iptvEpg(streamId: streamId)
}
private func saveProgress() async {
guard !request.isLive else { return }
let pos = player.currentPositionMs()
let dur = player.durationMs()
guard pos > 0 else { return }
try? await app.api.postProgress(
mediaType: request.mediaType,
movieId: request.movieId,
episodeId: request.episodeId,
seriesId: request.seriesId,
positionMs: pos,
durationMs: max(dur, 0)
)
}
private func loadSubtitles() async {
guard let mediaFileId = request.mediaFileId else { return }
subsLoading = true
subsError = nil
do {
let result = try await app.api.searchSubtitles(mediaFileId: mediaFileId)
subtitleHits = result.results
if result.configured == false {
subsError = "OpenSubtitles is niet geconfigureerd op de master."
}
subsLoading = false
} catch {
subsError = error.localizedDescription
subtitleHits = []
subsLoading = false
}
}
private func applySubtitle(_ hit: SubtitleHit) async {
do {
let fetched = try await app.api.fetchSubtitle(fileId: hit.fileId, source: hit.source ?? "org")
guard let data = Data(base64Encoded: fetched.contentBase64), !data.isEmpty else {
subsError = "Lege ondertitel"
return
}
let ext = (fetched.format?.isEmpty == false ? fetched.format! : "srt")
let url = FileManager.default.temporaryDirectory
.appendingPathComponent("vmc-tv-sub-\(hit.fileId).\(ext)")
try data.write(to: url, options: .atomic)
player.addExternalSubtitle(url: url)
withAnimation { panel = .none }
showControls()
focusedControl = .captions
} catch {
subsError = error.localizedDescription
}
}
}
struct PlayLauncher: View {
@EnvironmentObject private var app: AppModel
let mediaFileId: String
let title: String
let mediaType: String
let movieId: String?
let episodeId: String?
let seriesId: String?
let startMs: Int64
var logoUrl: String? = nil
@State private var request: PlayRequest?
@State private var error: String?
@State private var loading = true
var body: some View {
ZStack {
Color.black.ignoresSafeArea()
if let request {
TVPlayerView(request: request)
} else if loading {
ProgressView("Stream voorbereiden…")
.tint(.white)
.foregroundStyle(.white)
.font(TvFonts.body)
} else if let error {
VStack(spacing: 16) {
Text(error).font(TvFonts.body).foregroundStyle(TvColors.danger).multilineTextAlignment(.center)
Button("Opnieuw") { Task { await prepare() } }.buttonStyle(TvPrimaryButtonStyle())
}
}
}
.task { await prepare() }
.onAppear { app.enterPlayback() }
.onDisappear { app.leavePlayback() }
}
private func prepare() async {
loading = true
error = nil
do {
let play = try await app.api.play(mediaFileId: mediaFileId)
guard let url = URL(string: play.streamUrl) else {
throw ApiError.message("Ongeldige stream-URL")
}
var subtitleURL: URL?
if let sub = play.defaultSubtitle,
let data = Data(base64Encoded: sub.contentBase64),
!data.isEmpty {
let ext: String = {
let f = (sub.format ?? "").lowercased()
if f == "vtt" { return "vtt" }
if f == "ass" || f == "ssa" { return "ass" }
return "srt"
}()
let fileId = sub.fileId ?? 0
let path = FileManager.default.temporaryDirectory
.appendingPathComponent("vmc-tv-auto-sub-\(fileId).\(ext)")
try data.write(to: path, options: .atomic)
subtitleURL = path
}
request = .vod(
url: url, title: title, mediaType: mediaType, mediaFileId: mediaFileId,
movieId: movieId, episodeId: episodeId, seriesId: seriesId, startMs: startMs,
subtitleFileURL: subtitleURL, logoUrl: logoUrl
)
loading = false
} catch {
self.error = error.localizedDescription
loading = false
}
}
}