mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-01-15 23:59:08 +00:00
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:
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user