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.
823 lines
31 KiB
Swift
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
|
|
}
|
|
}
|
|
}
|