tv-anarchy/Sources/TVAnarchyiOS/VLCPlayerModel.swift
Natalie 17cf518418 feat(ios): downloads (DownloadManager/DownloadsView), remote control view + bridge/player refinements
(cherry picked from commit a1f7e44e17bb41f48976b69f4dbe5278cbad06b2)
2026-06-09 06:38:45 -07:00

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()
}
}