96 lines
3.4 KiB
Swift
96 lines
3.4 KiB
Swift
// 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()
|
|
}
|
|
}
|