// Thin SwiftUI-facing wrapper over VLCMediaPlayer. VLCKit (not AVPlayer) because // the library is torrent rips — mostly mkv / x265 with embedded subs and // multiple audio tracks — which AVPlayer cannot open. VLCKit plays the raw file // the bridge range-serves, so there is zero transcoding anywhere. // // State is polled on a 0.5s main-thread timer rather than via VLCMediaPlayerDelegate // to keep everything @MainActor-clean (the delegate fires on VLCKit's own queue). import Foundation import MobileVLCKit @MainActor final class VLCPlayerModel: ObservableObject { let player = VLCMediaPlayer() @Published var isPlaying = false @Published var position: Double = 0 // 0...1 along the media @Published var elapsed = "00:00" @Published var remaining = "00:00" @Published var buffering = true @Published var positionSeconds: Double = 0 // for progress reporting @Published var durationSeconds: Double = 0 private var timer: Timer? private var scrubbing = false private var pendingSeekSeconds: Double = 0 private var didSeek = true func start(url: URL, networkCachingMs: Int, startAt: Double = 0) { let media = VLCMedia(url: url) media.addOption("--network-caching=\(networkCachingMs)") player.media = media pendingSeekSeconds = startAt didSeek = startAt <= 1 // nothing to restore player.play() timer?.invalidate() timer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { [weak self] _ in Task { @MainActor in self?.tick() } } } private func tick() { isPlaying = player.isPlaying let state = player.state buffering = (state == .buffering || state == .opening) if !scrubbing { position = Double(player.position) } elapsed = player.time.stringValue // remainingTime is negative ("-12:34"); show it as-is, it reads naturally. remaining = player.remainingTime?.stringValue ?? "" let elapsedMs = Double(player.time.intValue) positionSeconds = elapsedMs / 1000 if let length = player.media?.length.intValue, length > 0 { durationSeconds = Double(length) / 1000 } else if let rem = player.remainingTime?.intValue { durationSeconds = (elapsedMs - Double(rem)) / 1000 // rem is negative } // Restore the saved resume position once the media is actually seekable. if !didSeek, player.isSeekable, durationSeconds > 0 { player.time = VLCTime(int: Int32(pendingSeekSeconds * 1000)) didSeek = true } } /// Apply a resume position fetched after playback already started, so the /// first frame is never blocked on a network round-trip (offline-safe). func requestResume(seconds: Double) { guard seconds > 1 else { return } pendingSeekSeconds = seconds didSeek = false } func togglePlay() { if player.isPlaying { player.pause() } else { player.play() } } func skip(seconds: Int32) { if seconds >= 0 { player.jumpForward(seconds) } else { player.jumpBackward(-seconds) } } func beginScrub() { scrubbing = true } func commitScrub(to fraction: Double) { player.position = Float(fraction) scrubbing = false } func teardown() { timer?.invalidate() timer = nil player.stop() } }