refactor: downloads to minimize prop drilling and improve layout and design (#1337)

Co-authored-by: Alex Kim <alexkim@Alexs-MacBook-Pro.local>
Co-authored-by: Fredrik Burmester <fredrik.burmester@gmail.com>
Co-authored-by: Simon-Eklundh <simon.eklundh@proton.me>
This commit is contained in:
Alex
2026-01-12 03:38:41 +11:00
committed by GitHub
parent cfa638afc6
commit ad54823f96
82 changed files with 948 additions and 809 deletions

View File

@@ -507,7 +507,8 @@ class MPVLayerRenderer(private val context: Context) : MPVLib.EventObserver {
MPVLib.MPV_EVENT_FILE_LOADED -> {
// Add external subtitles now that file is loaded
if (pendingExternalSubtitles.isNotEmpty()) {
for (subUrl in pendingExternalSubtitles) {
pendingExternalSubtitles.forEachIndexed { index, subUrl ->
android.util.Log.d("MPVRenderer", "Adding external subtitle [$index]: $subUrl")
MPVLib.command(arrayOf("sub-add", subUrl))
}
pendingExternalSubtitles = emptyList()

View File

@@ -36,6 +36,9 @@ final class MPVLayerRenderer {
private var isRunning = false
private var isStopping = false
// KVO observation for display layer status
private var statusObservation: NSKeyValueObservation?
weak var delegate: MPVLayerRendererDelegate?
// Thread-safe state for playback
@@ -78,6 +81,37 @@ final class MPVLayerRenderer {
init(displayLayer: AVSampleBufferDisplayLayer) {
self.displayLayer = displayLayer
observeDisplayLayerStatus()
}
/// Watches for display layer failures and auto-recovers.
///
/// iOS aggressively kills VideoToolbox decoder sessions when the app is
/// backgrounded, the screen is locked, or system resources are low.
/// This causes the video to go black - especially problematic for PiP.
///
/// This KVO observer detects when the display layer status becomes `.failed`
/// and automatically reinitializes the hardware decoder to restore video.
private func observeDisplayLayerStatus() {
statusObservation = displayLayer.observe(\.status, options: [.new]) { [weak self] layer, _ in
guard let self else { return }
if layer.status == .failed {
print("🔧 Display layer failed - auto-resetting decoder")
self.queue.async {
self.performDecoderReset()
}
}
}
}
/// Actually performs the decoder reset (called by observer or manually)
private func performDecoderReset() {
guard let handle = mpv else { return }
print("🔧 Resetting decoder: status=\(displayLayer.status.rawValue), requiresFlush=\(displayLayer.requiresFlushToResumeDecoding)")
commandSync(handle, ["set", "hwdec", "no"])
commandSync(handle, ["set", "hwdec", "auto"])
}
deinit {
@@ -150,6 +184,10 @@ final class MPVLayerRenderer {
isRunning = false
isStopping = true
// Stop observing display layer status
statusObservation?.invalidate()
statusObservation = nil
queue.sync { [weak self] in
guard let self, let handle = self.mpv else { return }
@@ -339,8 +377,10 @@ final class MPVLayerRenderer {
// Add external subtitles now that the file is loaded
let hadExternalSubs = !pendingExternalSubtitles.isEmpty
if hadExternalSubs, let handle = mpv {
for subUrl in pendingExternalSubtitles {
command(handle, ["sub-add", subUrl])
for (index, subUrl) in pendingExternalSubtitles.enumerated() {
print("🔧 Adding external subtitle [\(index)]: \(subUrl)")
// Use commandSync to ensure subs are added in exact order (not async)
commandSync(handle, ["sub-add", subUrl])
}
pendingExternalSubtitles = []
// Set subtitle after external subs are added
@@ -531,7 +571,9 @@ final class MPVLayerRenderer {
cachedPosition = clamped
commandSync(handle, ["seek", String(clamped), "absolute"])
}
func seek(by seconds: Double) {
guard let handle = mpv else { return }
let newPosition = max(0, cachedPosition + seconds)

View File

@@ -51,12 +51,13 @@ class MpvPlayerView: ExpoView {
private var currentURL: URL?
private var cachedPosition: Double = 0
private var cachedDuration: Double = 0
private var intendedPlayState: Bool = false // For PiP - ignores transient states during seek
private var intendedPlayState: Bool = false
private var _isZoomedToFill: Bool = false
required init(appContext: AppContext? = nil) {
super.init(appContext: appContext)
setupView()
// Note: Decoder reset is handled automatically via KVO in MPVLayerRenderer
}
private func setupView() {
@@ -361,6 +362,11 @@ extension MpvPlayerView: PiPControllerDelegate {
renderer?.syncTimebase()
// Set current time for PiP progress bar
pipController?.setCurrentTimeFromSeconds(cachedPosition, duration: cachedDuration)
// Reset to fit for PiP (zoomed video doesn't display correctly in PiP)
if _isZoomedToFill {
displayLayer.videoGravity = .resizeAspect
}
}
func pipController(_ controller: PiPController, didStartPictureInPicture: Bool) {
@@ -380,6 +386,11 @@ extension MpvPlayerView: PiPControllerDelegate {
// Ensure timebase is synced after PiP ends
renderer?.syncTimebase()
pipController?.updatePlaybackState()
// Restore the user's zoom preference
if _isZoomedToFill {
displayLayer.videoGravity = .resizeAspectFill
}
}
func pipController(_ controller: PiPController, restoreUserInterfaceForPictureInPictureStop completionHandler: @escaping (Bool) -> Void) {

View File

@@ -1,72 +0,0 @@
import UIKit
import AVFoundation
final class SampleBufferDisplayView: UIView {
override class var layerClass: AnyClass { AVSampleBufferDisplayLayer.self }
var displayLayer: AVSampleBufferDisplayLayer {
return layer as! AVSampleBufferDisplayLayer
}
private(set) var pipController: PiPController?
weak var pipDelegate: PiPControllerDelegate? {
didSet {
pipController?.delegate = pipDelegate
}
}
override init(frame: CGRect) {
super.init(frame: frame)
commonInit()
}
required init?(coder: NSCoder) {
super.init(coder: coder)
commonInit()
}
private func commonInit() {
backgroundColor = .black
displayLayer.videoGravity = .resizeAspect
#if !os(tvOS)
#if compiler(>=6.0)
if #available(iOS 26.0, *) {
displayLayer.preferredDynamicRange = .automatic
} else if #available(iOS 17.0, *) {
displayLayer.wantsExtendedDynamicRangeContent = true
}
#endif
if #available(iOS 17.0, *) {
displayLayer.wantsExtendedDynamicRangeContent = true
}
#endif
setupPictureInPicture()
}
private func setupPictureInPicture() {
pipController = PiPController(sampleBufferDisplayLayer: displayLayer)
}
// MARK: - PiP Control Methods
func startPictureInPicture() {
pipController?.startPictureInPicture()
}
func stopPictureInPicture() {
pipController?.stopPictureInPicture()
}
var isPictureInPictureSupported: Bool {
return pipController?.isPictureInPictureSupported ?? false
}
var isPictureInPictureActive: Bool {
return pipController?.isPictureInPictureActive ?? false
}
var isPictureInPicturePossible: Bool {
return pipController?.isPictureInPicturePossible ?? false
}
}