mirror of
https://github.com/streamyfin/streamyfin.git
synced 2026-01-15 15:48:05 +00:00
Co-authored-by: Alex Kim <alexkim@Alexs-MacBook-Pro.local> Co-authored-by: Alex <111128610+Alexk2309@users.noreply.github.com> Co-authored-by: Simon-Eklundh <simon.eklundh@proton.me>
233 lines
8.7 KiB
Swift
233 lines
8.7 KiB
Swift
import AVKit
|
|
import AVFoundation
|
|
|
|
protocol PiPControllerDelegate: AnyObject {
|
|
func pipController(_ controller: PiPController, willStartPictureInPicture: Bool)
|
|
func pipController(_ controller: PiPController, didStartPictureInPicture: Bool)
|
|
func pipController(_ controller: PiPController, willStopPictureInPicture: Bool)
|
|
func pipController(_ controller: PiPController, didStopPictureInPicture: Bool)
|
|
func pipController(_ controller: PiPController, restoreUserInterfaceForPictureInPictureStop completionHandler: @escaping (Bool) -> Void)
|
|
func pipControllerPlay(_ controller: PiPController)
|
|
func pipControllerPause(_ controller: PiPController)
|
|
func pipController(_ controller: PiPController, skipByInterval interval: CMTime)
|
|
func pipControllerIsPlaying(_ controller: PiPController) -> Bool
|
|
func pipControllerDuration(_ controller: PiPController) -> Double
|
|
func pipControllerCurrentPosition(_ controller: PiPController) -> Double
|
|
}
|
|
|
|
final class PiPController: NSObject {
|
|
private var pipController: AVPictureInPictureController?
|
|
private weak var sampleBufferDisplayLayer: AVSampleBufferDisplayLayer?
|
|
|
|
weak var delegate: PiPControllerDelegate?
|
|
|
|
// Timebase for PiP progress tracking
|
|
private var timebase: CMTimebase?
|
|
|
|
// Track current time for PiP progress
|
|
private var currentTime: CMTime = .zero
|
|
private var currentDuration: Double = 0
|
|
|
|
var isPictureInPictureSupported: Bool {
|
|
return AVPictureInPictureController.isPictureInPictureSupported()
|
|
}
|
|
|
|
var isPictureInPictureActive: Bool {
|
|
return pipController?.isPictureInPictureActive ?? false
|
|
}
|
|
|
|
var isPictureInPicturePossible: Bool {
|
|
return pipController?.isPictureInPicturePossible ?? false
|
|
}
|
|
|
|
init(sampleBufferDisplayLayer: AVSampleBufferDisplayLayer) {
|
|
self.sampleBufferDisplayLayer = sampleBufferDisplayLayer
|
|
super.init()
|
|
setupTimebase()
|
|
setupPictureInPicture()
|
|
}
|
|
|
|
private func setupTimebase() {
|
|
// Create a timebase for tracking playback time
|
|
var newTimebase: CMTimebase?
|
|
let status = CMTimebaseCreateWithSourceClock(
|
|
allocator: kCFAllocatorDefault,
|
|
sourceClock: CMClockGetHostTimeClock(),
|
|
timebaseOut: &newTimebase
|
|
)
|
|
|
|
if status == noErr, let tb = newTimebase {
|
|
timebase = tb
|
|
CMTimebaseSetTime(tb, time: .zero)
|
|
CMTimebaseSetRate(tb, rate: 0) // Start paused
|
|
|
|
// Set the control timebase on the display layer
|
|
sampleBufferDisplayLayer?.controlTimebase = tb
|
|
}
|
|
}
|
|
|
|
private func setupPictureInPicture() {
|
|
guard isPictureInPictureSupported,
|
|
let displayLayer = sampleBufferDisplayLayer else {
|
|
return
|
|
}
|
|
|
|
let contentSource = AVPictureInPictureController.ContentSource(
|
|
sampleBufferDisplayLayer: displayLayer,
|
|
playbackDelegate: self
|
|
)
|
|
|
|
pipController = AVPictureInPictureController(contentSource: contentSource)
|
|
pipController?.delegate = self
|
|
pipController?.requiresLinearPlayback = false
|
|
#if !os(tvOS)
|
|
pipController?.canStartPictureInPictureAutomaticallyFromInline = true
|
|
#endif
|
|
}
|
|
|
|
func startPictureInPicture() {
|
|
guard let pipController = pipController,
|
|
pipController.isPictureInPicturePossible else {
|
|
return
|
|
}
|
|
|
|
pipController.startPictureInPicture()
|
|
}
|
|
|
|
func stopPictureInPicture() {
|
|
pipController?.stopPictureInPicture()
|
|
}
|
|
|
|
func invalidate() {
|
|
if Thread.isMainThread {
|
|
pipController?.invalidatePlaybackState()
|
|
} else {
|
|
DispatchQueue.main.async { [weak self] in
|
|
self?.pipController?.invalidatePlaybackState()
|
|
}
|
|
}
|
|
}
|
|
|
|
func updatePlaybackState() {
|
|
// Only invalidate when PiP is active to avoid "no context menu visible" warnings
|
|
guard isPictureInPictureActive else { return }
|
|
|
|
if Thread.isMainThread {
|
|
pipController?.invalidatePlaybackState()
|
|
} else {
|
|
DispatchQueue.main.async { [weak self] in
|
|
self?.pipController?.invalidatePlaybackState()
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Updates the current playback time for PiP progress display
|
|
func setCurrentTime(_ time: CMTime) {
|
|
currentTime = time
|
|
|
|
// Update the timebase to reflect current position
|
|
if let tb = timebase {
|
|
CMTimebaseSetTime(tb, time: time)
|
|
}
|
|
|
|
// Only invalidate when PiP is active to avoid unnecessary updates
|
|
if isPictureInPictureActive {
|
|
updatePlaybackState()
|
|
}
|
|
}
|
|
|
|
/// Updates the current playback time from seconds
|
|
func setCurrentTimeFromSeconds(_ seconds: Double, duration: Double) {
|
|
guard seconds >= 0 else { return }
|
|
currentDuration = duration
|
|
let time = CMTime(seconds: seconds, preferredTimescale: 1000)
|
|
setCurrentTime(time)
|
|
}
|
|
|
|
/// Updates the playback rate on the timebase (1.0 = playing, 0.0 = paused)
|
|
func setPlaybackRate(_ rate: Float) {
|
|
if let tb = timebase {
|
|
CMTimebaseSetRate(tb, rate: Float64(rate))
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - AVPictureInPictureControllerDelegate
|
|
|
|
extension PiPController: AVPictureInPictureControllerDelegate {
|
|
func pictureInPictureControllerWillStartPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) {
|
|
delegate?.pipController(self, willStartPictureInPicture: true)
|
|
}
|
|
|
|
func pictureInPictureControllerDidStartPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) {
|
|
delegate?.pipController(self, didStartPictureInPicture: true)
|
|
}
|
|
|
|
func pictureInPictureController(_ pictureInPictureController: AVPictureInPictureController, failedToStartPictureInPictureWithError error: Error) {
|
|
print("Failed to start PiP: \(error)")
|
|
delegate?.pipController(self, didStartPictureInPicture: false)
|
|
}
|
|
|
|
func pictureInPictureControllerWillStopPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) {
|
|
delegate?.pipController(self, willStopPictureInPicture: true)
|
|
}
|
|
|
|
func pictureInPictureControllerDidStopPictureInPicture(_ pictureInPictureController: AVPictureInPictureController) {
|
|
delegate?.pipController(self, didStopPictureInPicture: true)
|
|
}
|
|
|
|
func pictureInPictureController(_ pictureInPictureController: AVPictureInPictureController, restoreUserInterfaceForPictureInPictureStopWithCompletionHandler completionHandler: @escaping (Bool) -> Void) {
|
|
delegate?.pipController(self, restoreUserInterfaceForPictureInPictureStop: completionHandler)
|
|
}
|
|
}
|
|
|
|
// MARK: - AVPictureInPictureSampleBufferPlaybackDelegate
|
|
|
|
extension PiPController: AVPictureInPictureSampleBufferPlaybackDelegate {
|
|
|
|
func pictureInPictureController(_ pictureInPictureController: AVPictureInPictureController, setPlaying playing: Bool) {
|
|
if playing {
|
|
delegate?.pipControllerPlay(self)
|
|
} else {
|
|
delegate?.pipControllerPause(self)
|
|
}
|
|
}
|
|
|
|
func pictureInPictureController(_ pictureInPictureController: AVPictureInPictureController, didTransitionToRenderSize newRenderSize: CMVideoDimensions) {
|
|
}
|
|
|
|
func pictureInPictureController(_ pictureInPictureController: AVPictureInPictureController, skipByInterval skipInterval: CMTime, completion completionHandler: @escaping () -> Void) {
|
|
delegate?.pipController(self, skipByInterval: skipInterval)
|
|
completionHandler()
|
|
}
|
|
|
|
var isPlaying: Bool {
|
|
return delegate?.pipControllerIsPlaying(self) ?? false
|
|
}
|
|
|
|
var timeRangeForPlayback: CMTimeRange {
|
|
let duration = delegate?.pipControllerDuration(self) ?? 0
|
|
if duration > 0 {
|
|
let cmDuration = CMTime(seconds: duration, preferredTimescale: 1000)
|
|
return CMTimeRange(start: .zero, duration: cmDuration)
|
|
}
|
|
return CMTimeRange(start: .zero, duration: .positiveInfinity)
|
|
}
|
|
|
|
func pictureInPictureControllerTimeRangeForPlayback(_ pictureInPictureController: AVPictureInPictureController) -> CMTimeRange {
|
|
return timeRangeForPlayback
|
|
}
|
|
|
|
func pictureInPictureControllerIsPlaybackPaused(_ pictureInPictureController: AVPictureInPictureController) -> Bool {
|
|
return !isPlaying
|
|
}
|
|
|
|
func pictureInPictureController(_ pictureInPictureController: AVPictureInPictureController, setPlaying playing: Bool, completion: @escaping () -> Void) {
|
|
if playing {
|
|
delegate?.pipControllerPlay(self)
|
|
} else {
|
|
delegate?.pipControllerPause(self)
|
|
}
|
|
completion()
|
|
}
|
|
} |