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: String? 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: String? = 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? @State private var hideTask: Task? @FocusState private var focusedControl: PlayerControlFocus? @State private var liveStreamId: String? @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: String) 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 } } }