Floatplane channel plugin for Jellyfin 10.10.x: browse, HLS proxy, cookie session, CI release
Some checks failed
release / build-release (push) Has been cancelled
Some checks failed
release / build-release (push) Has been cancelled
- IChannel: creators -> sub-channels -> video posts (public listing API, search mapped) - IRequiresMediaInfoCallback -> signed proxy URLs; stream controller rewrites HLS so the AES-128 key is fetched server-side with the session cookie, segments stay on the CDN - delivery/info cached per token lifetime and throttled (~2/min) - config page: paste sails.sid + validate, extra creators, quality, search test - scripts/package.sh -> plugin folder + zip + md5 + manifest.json; Gitea release workflow - research docs, HAR analysis (HAR itself gitignored), install-pi5.sh Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
863
doc/research/reference-sources/fn_ios_AVPlayerManager.swift
Normal file
863
doc/research/reference-sources/fn_ios_AVPlayerManager.swift
Normal file
@@ -0,0 +1,863 @@
|
||||
//
|
||||
// AVPlayerManager.swift
|
||||
// FloatNative
|
||||
//
|
||||
// Advanced video player manager with background audio and PIP support
|
||||
// Created by Claude on 2025-10-08.
|
||||
//
|
||||
|
||||
import AVKit
|
||||
import AVFoundation
|
||||
import Combine
|
||||
import SwiftUI
|
||||
import MediaPlayer
|
||||
import MediaAccessibility
|
||||
|
||||
// MARK: - Player State
|
||||
|
||||
enum PlayerState: Equatable {
|
||||
case idle
|
||||
case loading
|
||||
case playing
|
||||
case paused
|
||||
case buffering
|
||||
case failed(Error)
|
||||
|
||||
static func == (lhs: PlayerState, rhs: PlayerState) -> Bool {
|
||||
switch (lhs, rhs) {
|
||||
case (.idle, .idle),
|
||||
(.loading, .loading),
|
||||
(.playing, .playing),
|
||||
(.paused, .paused),
|
||||
(.buffering, .buffering):
|
||||
return true
|
||||
case (.failed, .failed):
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - AVPlayer Manager
|
||||
|
||||
@MainActor
|
||||
class AVPlayerManager: NSObject, ObservableObject {
|
||||
|
||||
// MARK: - Singleton
|
||||
|
||||
static let shared = AVPlayerManager()
|
||||
|
||||
// MARK: - Published Properties
|
||||
|
||||
@Published private(set) var player: AVPlayer?
|
||||
@Published private(set) var playerState: PlayerState = .idle
|
||||
@Published private(set) var currentTime: Double = 0
|
||||
@Published private(set) var duration: Double = 0
|
||||
@Published private(set) var isPlaying = false
|
||||
@Published private(set) var availableQualities: [QualityVariant] = []
|
||||
@Published private(set) var currentQuality: QualityVariant?
|
||||
|
||||
// Captions (GH #11). The synthetic-master HLS path didn't survive
|
||||
// AVPlayer's parser, so we render captions ourselves as a SwiftUI
|
||||
// overlay driven by currentTime + cues. The CC button in the player
|
||||
// chrome toggles `captionsEnabled`.
|
||||
@Published private(set) var captionCues: [VTTCue] = []
|
||||
@Published var captionsEnabled: Bool = false
|
||||
|
||||
/// Cached system caption preference. We seed `captionsEnabled` from
|
||||
/// this when loading a video that has captions, so users with system
|
||||
/// captions on don't have to tap the in-app CC button on every video.
|
||||
static var systemCaptionsEnabled: Bool {
|
||||
MACaptionAppearanceGetDisplayType(.user) != .automatic
|
||||
}
|
||||
|
||||
// MARK: - PIP State (managed by CustomVideoPlayer)
|
||||
|
||||
@Published var isPIPActive = false // True only while PiP window is actively displayed
|
||||
@Published var isPIPSupported = true // AVPlayerViewController always supports PiP if device supports it
|
||||
|
||||
// Track if we have a PiP session that can be restored (true even when paused)
|
||||
// This stays true as long as playerViewController/delegate are stored for restoration
|
||||
@Published var hasPIPSession = false
|
||||
|
||||
// Keep playerViewController and its delegate alive during PiP
|
||||
// This prevents the delegate from being deallocated when the view is popped
|
||||
var playerViewController: AVPlayerViewController?
|
||||
var playerViewControllerDelegate: NSObject? // Stores the Coordinator
|
||||
|
||||
/// True the entire time AVPlayerViewController owns the fullscreen
|
||||
/// presentation — flipped on at `willBegin`, off at exit-complete.
|
||||
/// Used by VideoPlayerView.onDisappear to skip the singleton-player
|
||||
/// teardown during landscape rotation: SwiftUI fires .onDisappear on
|
||||
/// the host AFTER the fullscreen animation completes, so a "true only
|
||||
/// during the animation" flag (the original attempt) didn't catch the
|
||||
/// teardown window. Resetting the player while AVPlayerViewController
|
||||
/// is still presenting it produces the PlayerRemoteXPC -12860 black
|
||||
/// screen.
|
||||
var isInFullScreenTransition: Bool = false
|
||||
|
||||
/// tvOS: whether AVPlayerViewController is currently showing its
|
||||
/// transport bar / playback controls. Fed by the AVPlayerViewController
|
||||
/// delegate in CustomVideoPlayer. Published because the tvOS player
|
||||
/// views decide whether to claim the Menu press based on it — see
|
||||
/// VideoPlayerTvosView.exitCommandAction. It only flips on user
|
||||
/// interaction (a few times a minute at most), so the extra body
|
||||
/// evaluations are cheap.
|
||||
@Published var isTransportBarVisible: Bool = false
|
||||
|
||||
// MARK: - Current Video Info
|
||||
|
||||
private(set) var currentVideoId: String?
|
||||
private(set) var currentVideoTitle: String?
|
||||
@Published var currentPost: BlogPost?
|
||||
@Published var shouldRestoreVideoPlayer: Bool = false
|
||||
private(set) var isLiveStream: Bool = false
|
||||
|
||||
// MARK: - Observers
|
||||
|
||||
private var timeObserver: Any?
|
||||
private var statusObserver: AnyCancellable?
|
||||
private var itemObserver: AnyCancellable?
|
||||
private var cancellables = Set<AnyCancellable>()
|
||||
private var progressTimer: DispatchSourceTimer?
|
||||
private var backgroundObserver: NSObjectProtocol?
|
||||
private var videoEndObserver: NSObjectProtocol?
|
||||
|
||||
// MARK: - Buffer State
|
||||
|
||||
@Published private(set) var isBuffering = false
|
||||
@Published private(set) var bufferProgress: Double = 0
|
||||
private var lastBufferUpdate: TimeInterval = 0
|
||||
|
||||
// MARK: - Audio Session
|
||||
|
||||
private let audioSession = AVAudioSession.sharedInstance()
|
||||
|
||||
// MARK: - Persisted Playback Rate
|
||||
|
||||
/// UserDefaults-backed playback rate that sticks across videos and app
|
||||
/// launches. Mirrors what YouTube / the official Floatplane app do.
|
||||
private static let playbackRateKey = "playbackRate"
|
||||
static let availablePlaybackRates: [Float] = [0.5, 0.75, 1.0, 1.25, 1.5, 1.75, 2.0]
|
||||
|
||||
var persistedPlaybackRate: Float {
|
||||
get {
|
||||
let raw = UserDefaults.standard.float(forKey: Self.playbackRateKey)
|
||||
return raw > 0 ? raw : 1.0
|
||||
}
|
||||
set {
|
||||
UserDefaults.standard.set(newValue, forKey: Self.playbackRateKey)
|
||||
objectWillChange.send()
|
||||
}
|
||||
}
|
||||
|
||||
private var rateObservation: NSKeyValueObservation?
|
||||
|
||||
// MARK: - Initialization
|
||||
|
||||
private override init() {
|
||||
super.init()
|
||||
setupAudioSession()
|
||||
setupRemoteCommandCenter()
|
||||
setupBackgroundObserver()
|
||||
}
|
||||
|
||||
// MARK: - Audio Session Setup
|
||||
|
||||
private func setupAudioSession() {
|
||||
do {
|
||||
// Configure audio session for background playback (needed for
|
||||
// PiP and AirPlay to behave correctly).
|
||||
try audioSession.setCategory(.playback, mode: .moviePlayback)
|
||||
try audioSession.setActive(true)
|
||||
} catch {
|
||||
print("⚠️ Failed to setup audio session: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Remote Command Center (Lock Screen Controls)
|
||||
|
||||
private func setupRemoteCommandCenter() {
|
||||
let commandCenter = MPRemoteCommandCenter.shared()
|
||||
|
||||
// Play command
|
||||
commandCenter.playCommand.isEnabled = true
|
||||
commandCenter.playCommand.addTarget { [weak self] _ in
|
||||
self?.play()
|
||||
return .success
|
||||
}
|
||||
|
||||
// Pause command
|
||||
commandCenter.pauseCommand.isEnabled = true
|
||||
commandCenter.pauseCommand.addTarget { [weak self] _ in
|
||||
self?.pause()
|
||||
return .success
|
||||
}
|
||||
|
||||
// Skip forward
|
||||
commandCenter.skipForwardCommand.isEnabled = true
|
||||
commandCenter.skipForwardCommand.preferredIntervals = [15]
|
||||
commandCenter.skipForwardCommand.addTarget { [weak self] _ in
|
||||
self?.seek(by: 15)
|
||||
return .success
|
||||
}
|
||||
|
||||
// Skip backward
|
||||
commandCenter.skipBackwardCommand.isEnabled = true
|
||||
commandCenter.skipBackwardCommand.preferredIntervals = [15]
|
||||
commandCenter.skipBackwardCommand.addTarget { [weak self] _ in
|
||||
self?.seek(by: -15)
|
||||
return .success
|
||||
}
|
||||
|
||||
// Seek command
|
||||
commandCenter.changePlaybackPositionCommand.isEnabled = true
|
||||
commandCenter.changePlaybackPositionCommand.addTarget { [weak self] event in
|
||||
guard let event = event as? MPChangePlaybackPositionCommandEvent else {
|
||||
return .commandFailed
|
||||
}
|
||||
self?.seek(to: event.positionTime)
|
||||
return .success
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Background & Lifecycle Observers
|
||||
|
||||
private func setupBackgroundObserver() {
|
||||
backgroundObserver = NotificationCenter.default.addObserver(
|
||||
forName: UIApplication.willResignActiveNotification,
|
||||
object: nil,
|
||||
queue: .main
|
||||
) { [weak self] _ in
|
||||
Task { @MainActor in
|
||||
await self?.saveProgress()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func setupVideoEndObserver() {
|
||||
guard let playerItem = player?.currentItem else { return }
|
||||
|
||||
videoEndObserver = NotificationCenter.default.addObserver(
|
||||
forName: .AVPlayerItemDidPlayToEndTime,
|
||||
object: playerItem,
|
||||
queue: .main
|
||||
) { [weak self] _ in
|
||||
Task { @MainActor in
|
||||
await self?.saveProgress()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Now Playing Info
|
||||
|
||||
private func updateNowPlayingInfo() {
|
||||
#if os(tvOS)
|
||||
// AVPlayerViewController owns Now Playing on tvOS: it overrides the
|
||||
// duration/rate/elapsed fields, drops our title, and logs a warning
|
||||
// burst on every write — and this runs on every time-observer tick.
|
||||
// The title goes in via `externalMetadata` instead (see loadStream).
|
||||
return
|
||||
#else
|
||||
var nowPlayingInfo = [String: Any]()
|
||||
|
||||
nowPlayingInfo[MPMediaItemPropertyTitle] = currentVideoTitle ?? "Floatplane Video"
|
||||
nowPlayingInfo[MPMediaItemPropertyPlaybackDuration] = duration
|
||||
nowPlayingInfo[MPNowPlayingInfoPropertyElapsedPlaybackTime] = currentTime
|
||||
nowPlayingInfo[MPNowPlayingInfoPropertyPlaybackRate] = player?.rate ?? 0
|
||||
|
||||
// TODO: Add artwork from thumbnail
|
||||
// if let thumbnail = currentThumbnail {
|
||||
// nowPlayingInfo[MPMediaItemPropertyArtwork] = MPMediaItemArtwork(...)
|
||||
// }
|
||||
|
||||
MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo
|
||||
#endif
|
||||
}
|
||||
|
||||
#if os(tvOS)
|
||||
/// Title metadata for AVKit's Now Playing / info panel on tvOS.
|
||||
private static func externalMetadata(title: String?) -> [AVMetadataItem] {
|
||||
guard let title, !title.isEmpty else { return [] }
|
||||
let item = AVMutableMetadataItem()
|
||||
item.identifier = .commonIdentifierTitle
|
||||
item.value = title as NSString
|
||||
item.extendedLanguageTag = "und"
|
||||
return [item]
|
||||
}
|
||||
#endif
|
||||
|
||||
// MARK: - Load Video
|
||||
|
||||
func loadVideo(
|
||||
videoId: String,
|
||||
title: String,
|
||||
post: BlogPost? = nil,
|
||||
startTime: Double = 0,
|
||||
qualities: [QualityVariant],
|
||||
isLive: Bool = false,
|
||||
// Caption cues parsed from Floatplane's WebVTT (GH #11). Stored on
|
||||
// the manager so CaptionsOverlayView can render the active cue based
|
||||
// on currentTime. Empty for videos without captions.
|
||||
captionCues: [VTTCue] = []
|
||||
) async throws {
|
||||
self.currentVideoId = videoId
|
||||
self.currentVideoTitle = title
|
||||
self.currentPost = post
|
||||
self.availableQualities = qualities
|
||||
self.playerState = .loading
|
||||
self.isLiveStream = isLive
|
||||
self.captionCues = captionCues
|
||||
// Auto-enable when the user has system captions on AND the post
|
||||
// ships a caption track. Otherwise leave the in-app toggle off.
|
||||
self.captionsEnabled = !captionCues.isEmpty && Self.systemCaptionsEnabled
|
||||
|
||||
// Use highest quality by default
|
||||
guard let quality = qualities.first else {
|
||||
throw FloatplaneAPIError.invalidResponse
|
||||
}
|
||||
|
||||
self.currentQuality = quality
|
||||
|
||||
print("🎬 [AVPlayerManager] Loading video: \(title) (Live: \(isLive)) cues: \(captionCues.count)")
|
||||
try await loadStream(url: quality.url, startTime: startTime, isLive: isLive)
|
||||
}
|
||||
|
||||
private let resourceLoader = VideoResourceLoader()
|
||||
|
||||
/// Load stream from URL
|
||||
private func loadStream(url: String, startTime: Double = 0, isLive: Bool) async throws {
|
||||
// Clean up old player
|
||||
cleanupPlayer()
|
||||
|
||||
// Re-activate the audio session in case it was deactivated by a
|
||||
// previous reset() (GH #40). setupAudioSession() only runs once at
|
||||
// init, so without this a video loaded after a reset would play
|
||||
// silent on background routes.
|
||||
try? audioSession.setActive(true)
|
||||
|
||||
let asset: AVURLAsset
|
||||
|
||||
if isLive {
|
||||
// Bypass VideoResourceLoader for Live Streams
|
||||
// Use the original URL directly so AVPlayer handles HLS natively
|
||||
guard let streamURL = URL(string: url) else {
|
||||
throw FloatplaneAPIError.invalidURL
|
||||
}
|
||||
print("📡 [AVPlayerManager] Loading LIVE stream directly: \(streamURL.redactingQuery)")
|
||||
|
||||
// Create asset without custom resource loader
|
||||
asset = AVURLAsset(url: streamURL)
|
||||
// No resourceLoader delegate set for live streams
|
||||
} else {
|
||||
// Convert HTTP/HTTPS to custom scheme to force interception via VideoResourceLoader.
|
||||
// This is what gives DPoP + key-rewrite a hook into the HLS pipeline. Captions
|
||||
// no longer ride this path — they're rendered as a SwiftUI overlay (GH #11).
|
||||
guard var components = URLComponents(string: url) else {
|
||||
throw FloatplaneAPIError.invalidURL
|
||||
}
|
||||
components.scheme = "floatnative" // Must match VideoResourceLoader.customScheme
|
||||
guard let streamURL = components.url else {
|
||||
throw FloatplaneAPIError.invalidURL
|
||||
}
|
||||
print("📼 [AVPlayerManager] Loading VOD stream with interception: \(streamURL.redactingQuery)")
|
||||
|
||||
// Create new player with Interceptor
|
||||
// We do NOT pass headers here because the ResourceLoader will handle the request.
|
||||
asset = AVURLAsset(url: streamURL)
|
||||
asset.resourceLoader.setDelegate(resourceLoader, queue: DispatchQueue.global(qos: .userInitiated))
|
||||
}
|
||||
|
||||
let playerItem = AVPlayerItem(asset: asset)
|
||||
#if os(tvOS)
|
||||
playerItem.externalMetadata = Self.externalMetadata(title: currentVideoTitle)
|
||||
#endif
|
||||
|
||||
// Configure buffer limits for tvOS to prevent memory issues during long playback
|
||||
#if os(tvOS)
|
||||
// Limit buffer to 30 seconds ahead to reduce memory pressure on tvOS
|
||||
playerItem.preferredForwardBufferDuration = 30
|
||||
#else
|
||||
// iOS can handle larger buffers
|
||||
playerItem.preferredForwardBufferDuration = 60
|
||||
#endif
|
||||
|
||||
|
||||
let newPlayer = AVPlayer(playerItem: playerItem)
|
||||
newPlayer.allowsExternalPlayback = true
|
||||
newPlayer.appliesMediaSelectionCriteriaAutomatically = true
|
||||
|
||||
// Configure player to minimize stalling while respecting buffer limits
|
||||
if #available(iOS 10.0, tvOS 10.0, *) {
|
||||
newPlayer.automaticallyWaitsToMinimizeStalling = true
|
||||
}
|
||||
|
||||
self.player = newPlayer
|
||||
|
||||
// Apply the user's preferred playback speed before the first play()
|
||||
// so they don't see a 1.0× flash. defaultRate is iOS/tvOS 16+; our
|
||||
// deployment target is 18+.
|
||||
newPlayer.defaultRate = persistedPlaybackRate
|
||||
|
||||
// Persist any future rate changes the user makes via the
|
||||
// AVPlayerViewController chrome (iOS) or the transport bar menu
|
||||
// (tvOS). Only persist while playing — rate goes to 0 on pause,
|
||||
// we don't want to lose the user's selection. Also push the new
|
||||
// rate into defaultRate so a subsequent pause → play resumes at
|
||||
// the chosen speed instead of snapping back to 1.0×.
|
||||
rateObservation = newPlayer.observe(\.rate, options: [.new]) { [weak self] player, change in
|
||||
guard let newRate = change.newValue, newRate > 0 else { return }
|
||||
self?.persistedPlaybackRate = newRate
|
||||
if abs(player.defaultRate - newRate) > 0.01 {
|
||||
player.defaultRate = newRate
|
||||
}
|
||||
}
|
||||
|
||||
// Add observers for detailed logging
|
||||
addDebugObservers(to: playerItem)
|
||||
|
||||
// Observe player status
|
||||
observePlayer()
|
||||
|
||||
// Setup video end observer
|
||||
setupVideoEndObserver()
|
||||
|
||||
// Seek to start time if specified
|
||||
if startTime > 0 && !isLive {
|
||||
seek(to: startTime)
|
||||
}
|
||||
|
||||
// Update state
|
||||
playerState = .paused
|
||||
}
|
||||
|
||||
private func addDebugObservers(to item: AVPlayerItem) {
|
||||
NotificationCenter.default.addObserver(forName: .AVPlayerItemNewErrorLogEntry, object: item, queue: .main) { notification in
|
||||
guard let playerItem = notification.object as? AVPlayerItem,
|
||||
let errorLog = playerItem.errorLog()?.events.last else { return }
|
||||
print("🚨 [AVPlayer] Error Log: \(errorLog.errorDomain) \(errorLog.errorStatusCode) — \(errorLog.errorComment ?? "(no comment)") @ \((errorLog.uri ?? "?").redactingURLQuery)")
|
||||
}
|
||||
|
||||
NotificationCenter.default.addObserver(forName: .AVPlayerItemNewAccessLogEntry, object: item, queue: .main) { notification in
|
||||
guard let playerItem = notification.object as? AVPlayerItem,
|
||||
let accessLog = playerItem.accessLog()?.events.last else { return }
|
||||
print("ℹ️ [AVPlayer] Access Log: URI: \((accessLog.uri ?? "").redactingURLQuery) | Bitrate: \(accessLog.indicatedBitrate)")
|
||||
}
|
||||
|
||||
NotificationCenter.default.addObserver(forName: .AVPlayerItemFailedToPlayToEndTime, object: item, queue: .main) { notification in
|
||||
if let error = notification.userInfo?[AVPlayerItemFailedToPlayToEndTimeErrorKey] as? Error {
|
||||
print("❌ [AVPlayer] Failed to play to end: \(error.localizedDescription)")
|
||||
TVLog.log(.player, "failed to play to end", detail: error.localizedDescription, notable: true)
|
||||
}
|
||||
}
|
||||
|
||||
// A stall is one of the plausible explanations for "the remote did
|
||||
// nothing for ten seconds" — the UI is alive but nothing visibly
|
||||
// reacts. Timestamped here so it can be lined up against the press
|
||||
// and hang traces.
|
||||
NotificationCenter.default.addObserver(forName: AVPlayerItem.playbackStalledNotification, object: item, queue: .main) { _ in
|
||||
TVLog.log(.player, "playback STALLED", notable: true)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Change Quality
|
||||
|
||||
func changeQuality(_ quality: QualityVariant) async throws {
|
||||
guard let player = player else { return }
|
||||
|
||||
let currentTime = player.currentTime().seconds
|
||||
self.currentQuality = quality
|
||||
|
||||
try await loadStream(url: quality.url, startTime: currentTime, isLive: isLiveStream)
|
||||
|
||||
if isPlaying {
|
||||
play()
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Picture in Picture
|
||||
// PiP is now handled by CustomVideoPlayer (AVPlayerViewController)
|
||||
// which has native PiP support built-in
|
||||
|
||||
// MARK: - Player Observation
|
||||
|
||||
private func observePlayer() {
|
||||
guard let player = player else { return }
|
||||
|
||||
// Time observer
|
||||
let interval = CMTime(seconds: 0.5, preferredTimescale: CMTimeScale(NSEC_PER_SEC))
|
||||
timeObserver = player.addPeriodicTimeObserver(forInterval: interval, queue: .main) { [weak self] time in
|
||||
Task { @MainActor in
|
||||
// Update without animation to avoid AttributeGraph cycles
|
||||
var transaction = Transaction()
|
||||
transaction.disablesAnimations = true
|
||||
withTransaction(transaction) {
|
||||
self?.currentTime = time.seconds
|
||||
}
|
||||
self?.updateNowPlayingInfo()
|
||||
}
|
||||
}
|
||||
|
||||
// Duration observer
|
||||
player.currentItem?.publisher(for: \.duration)
|
||||
.sink { [weak self] duration in
|
||||
Task { @MainActor in
|
||||
if duration.isNumeric {
|
||||
self?.duration = duration.seconds
|
||||
self?.updateNowPlayingInfo()
|
||||
}
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
// Status observer
|
||||
player.publisher(for: \.timeControlStatus)
|
||||
.sink { [weak self] status in
|
||||
Task { @MainActor in
|
||||
switch status {
|
||||
case .playing:
|
||||
self?.isPlaying = true
|
||||
self?.playerState = .playing
|
||||
case .paused:
|
||||
self?.isPlaying = false
|
||||
if self?.playerState == .playing {
|
||||
self?.playerState = .paused
|
||||
}
|
||||
case .waitingToPlayAtSpecifiedRate:
|
||||
let reason = player.reasonForWaitingToPlay?.rawValue ?? "unknown"
|
||||
TVLog.log(.player, "waitingToPlayAtSpecifiedRate reason=\(reason)", notable: true)
|
||||
self?.playerState = .buffering
|
||||
@unknown default:
|
||||
break
|
||||
}
|
||||
self?.updateNowPlayingInfo()
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
// Item status observer
|
||||
player.currentItem?.publisher(for: \.status)
|
||||
.sink { [weak self] status in
|
||||
Task { @MainActor in
|
||||
switch status {
|
||||
case .failed:
|
||||
if let error = player.currentItem?.error {
|
||||
TVLog.log(.player, "item status FAILED", detail: String(describing: error), notable: true)
|
||||
self?.playerState = .failed(error)
|
||||
}
|
||||
case .readyToPlay:
|
||||
if self?.playerState == .loading {
|
||||
self?.playerState = .paused
|
||||
}
|
||||
default:
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
// Buffer empty observer - critical for detecting stalling
|
||||
player.currentItem?.publisher(for: \.isPlaybackBufferEmpty)
|
||||
.sink { [weak self] isEmpty in
|
||||
Task { @MainActor in
|
||||
if isEmpty {
|
||||
TVLog.log(.player, "playback buffer EMPTY — stalling", notable: true)
|
||||
self?.isBuffering = true
|
||||
self?.playerState = .buffering
|
||||
}
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
// Buffer likely to keep up observer
|
||||
player.currentItem?.publisher(for: \.isPlaybackLikelyToKeepUp)
|
||||
.sink { [weak self] likelyToKeepUp in
|
||||
Task { @MainActor in
|
||||
if likelyToKeepUp {
|
||||
if self?.isBuffering == true {
|
||||
TVLog.log(.player, "playback likely to keep up — buffering cleared", notable: true)
|
||||
}
|
||||
self?.isBuffering = false
|
||||
if self?.isPlaying == true {
|
||||
self?.playerState = .playing
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
// Buffer full observer
|
||||
player.currentItem?.publisher(for: \.isPlaybackBufferFull)
|
||||
.sink { [weak self] isFull in
|
||||
Task { @MainActor in
|
||||
// Log removed as per instruction
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
|
||||
// Loaded time ranges observer - track buffer progress
|
||||
player.currentItem?.publisher(for: \.loadedTimeRanges)
|
||||
.sink { [weak self] timeRanges in
|
||||
Task { @MainActor in
|
||||
guard let self = self,
|
||||
let currentTime = self.player?.currentTime(),
|
||||
let timeRange = timeRanges.first?.timeRangeValue else {
|
||||
return
|
||||
}
|
||||
|
||||
// Throttle updates to once every 2 seconds to prevent excessive view redraws
|
||||
// This is critical for tvOS performance where view updates are expensive
|
||||
let now = Date().timeIntervalSince1970
|
||||
guard now - self.lastBufferUpdate > 2.0 else { return }
|
||||
|
||||
let bufferEnd = CMTimeGetSeconds(CMTimeRangeGetEnd(timeRange))
|
||||
let currentTimeSeconds = CMTimeGetSeconds(currentTime)
|
||||
let bufferedAhead = bufferEnd - currentTimeSeconds
|
||||
|
||||
self.bufferProgress = max(0, bufferedAhead)
|
||||
self.lastBufferUpdate = now
|
||||
|
||||
#if os(tvOS)
|
||||
// Log buffer status on tvOS to help diagnose issues
|
||||
if Int(currentTimeSeconds) % 60 == 0 && Int(currentTimeSeconds) > 0 {
|
||||
print("📊 tvOS Buffer Status at \(Int(currentTimeSeconds))s: \(bufferedAhead)s ahead")
|
||||
}
|
||||
#endif
|
||||
}
|
||||
}
|
||||
.store(in: &cancellables)
|
||||
}
|
||||
|
||||
// MARK: - Playback Controls
|
||||
|
||||
func play() {
|
||||
player?.play()
|
||||
updateNowPlayingInfo()
|
||||
startProgressTimer()
|
||||
setIdleTimerDisabled(true) // Keep screen awake during playback
|
||||
}
|
||||
|
||||
func pause() {
|
||||
player?.pause()
|
||||
updateNowPlayingInfo()
|
||||
stopProgressTimer()
|
||||
setIdleTimerDisabled(false) // Allow screen to sleep when paused
|
||||
}
|
||||
|
||||
// MARK: - Idle Timer Management
|
||||
|
||||
private func setIdleTimerDisabled(_ disabled: Bool) {
|
||||
DispatchQueue.main.async {
|
||||
UIApplication.shared.isIdleTimerDisabled = disabled
|
||||
}
|
||||
}
|
||||
|
||||
func togglePlayPause() {
|
||||
if isPlaying {
|
||||
pause()
|
||||
} else {
|
||||
play()
|
||||
}
|
||||
}
|
||||
|
||||
func seek(to time: Double) {
|
||||
let cmTime = CMTime(seconds: time, preferredTimescale: CMTimeScale(NSEC_PER_SEC))
|
||||
player?.seek(to: cmTime) { [weak self] _ in
|
||||
Task { @MainActor in
|
||||
self?.updateNowPlayingInfo()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func seek(by seconds: Double) {
|
||||
let newTime = currentTime + seconds
|
||||
let clampedTime = max(0, min(newTime, duration))
|
||||
seek(to: clampedTime)
|
||||
}
|
||||
|
||||
func setRate(_ rate: Float) {
|
||||
player?.rate = rate
|
||||
}
|
||||
|
||||
// MARK: - Progress Tracking
|
||||
|
||||
/// Start periodic progress saving (every 2 minutes)
|
||||
private func startProgressTimer() {
|
||||
stopProgressTimer()
|
||||
|
||||
// Use DispatchSourceTimer on background queue to avoid blocking main thread
|
||||
let timer = DispatchSource.makeTimerSource(queue: DispatchQueue.global(qos: .utility))
|
||||
timer.schedule(deadline: .now() + 120, repeating: 120)
|
||||
timer.setEventHandler { [weak self] in
|
||||
Task { @MainActor in
|
||||
await self?.saveProgressWithTimeout()
|
||||
}
|
||||
}
|
||||
timer.resume()
|
||||
progressTimer = timer
|
||||
}
|
||||
|
||||
/// Stop periodic progress saving
|
||||
private func stopProgressTimer() {
|
||||
progressTimer?.cancel()
|
||||
progressTimer = nil
|
||||
}
|
||||
|
||||
/// Save progress to Floatplane API with timeout protection
|
||||
private func saveProgressWithTimeout() async {
|
||||
// Use Task.withTimeout-like pattern to prevent hanging
|
||||
await withTaskGroup(of: Void.self) { group in
|
||||
group.addTask {
|
||||
await self.saveProgress()
|
||||
}
|
||||
|
||||
// Add timeout task (10 seconds)
|
||||
group.addTask {
|
||||
try? await Task.sleep(nanoseconds: 10_000_000_000)
|
||||
}
|
||||
|
||||
// Wait for first to complete, then cancel others
|
||||
await group.next()
|
||||
group.cancelAll()
|
||||
}
|
||||
}
|
||||
|
||||
/// Save progress to Floatplane API
|
||||
func saveProgress() async {
|
||||
guard let videoId = currentVideoId else { return }
|
||||
|
||||
let progressInSeconds = Int(currentTime)
|
||||
|
||||
do {
|
||||
_ = try await FloatplaneAPI.shared.updateProgress(
|
||||
videoId: videoId,
|
||||
contentType: "video",
|
||||
progress: progressInSeconds
|
||||
)
|
||||
} catch {
|
||||
print("Failed to save progress: \(error)")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Cleanup
|
||||
|
||||
private func cleanupPlayer() {
|
||||
// FIRST: Immediately stop playback and audio to prevent overlap
|
||||
player?.pause()
|
||||
player?.replaceCurrentItem(with: nil)
|
||||
|
||||
// Re-enable idle timer (allow screen to sleep)
|
||||
setIdleTimerDisabled(false)
|
||||
|
||||
// Remove time observer
|
||||
if let timeObserver = timeObserver {
|
||||
player?.removeTimeObserver(timeObserver)
|
||||
self.timeObserver = nil
|
||||
}
|
||||
|
||||
// Remove background observer
|
||||
if let backgroundObserver = backgroundObserver {
|
||||
NotificationCenter.default.removeObserver(backgroundObserver)
|
||||
self.backgroundObserver = nil
|
||||
}
|
||||
|
||||
// Remove video end observer
|
||||
if let videoEndObserver = videoEndObserver {
|
||||
NotificationCenter.default.removeObserver(videoEndObserver)
|
||||
self.videoEndObserver = nil
|
||||
}
|
||||
|
||||
// Stop progress timer
|
||||
stopProgressTimer()
|
||||
|
||||
// Stop observing rate changes
|
||||
rateObservation?.invalidate()
|
||||
rateObservation = nil
|
||||
|
||||
// Cancel all subscriptions
|
||||
cancellables.removeAll()
|
||||
|
||||
player = nil
|
||||
}
|
||||
|
||||
// MARK: - Fullscreen Control
|
||||
|
||||
func enterFullScreen(animated: Bool = true) {
|
||||
guard let playerViewController = playerViewController else { return }
|
||||
let selector = NSSelectorFromString("enterFullScreenAnimated:completionHandler:")
|
||||
if playerViewController.responds(to: selector) {
|
||||
playerViewController.perform(selector, with: animated, with: nil)
|
||||
}
|
||||
}
|
||||
|
||||
func exitFullScreen(animated: Bool = true) {
|
||||
guard let playerViewController = playerViewController else { return }
|
||||
let selector = NSSelectorFromString("exitFullScreenAnimated:completionHandler:")
|
||||
if playerViewController.responds(to: selector) {
|
||||
playerViewController.perform(selector, with: animated, with: nil)
|
||||
} else {
|
||||
// Fallback: Try private API selector for older/other versions if the standard one fails
|
||||
let privateSelector = NSSelectorFromString("_transitionFromFullScreenAnimated:completionHandler:")
|
||||
if playerViewController.responds(to: privateSelector) {
|
||||
playerViewController.perform(privateSelector, with: animated, with: nil)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#if !os(tvOS)
|
||||
/// Force the device into landscape orientation and enter native fullscreen,
|
||||
/// overriding the system rotation lock. Mirrors what YouTube / the official
|
||||
/// Floatplane app do when you tap their rotate-to-landscape button.
|
||||
///
|
||||
/// Uses iOS 16's `requestGeometryUpdate` API; deployment target is 18.5+
|
||||
/// so the availability check is just defensive.
|
||||
func forceLandscape() {
|
||||
requestOrientation(.landscape)
|
||||
enterFullScreen()
|
||||
}
|
||||
|
||||
/// Mirror of `forceLandscape()` — invoked when the user exits fullscreen
|
||||
/// or otherwise wants to return to portrait while rotation lock is on.
|
||||
func forcePortrait() {
|
||||
requestOrientation(.portrait)
|
||||
}
|
||||
|
||||
private func requestOrientation(_ mask: UIInterfaceOrientationMask) {
|
||||
guard let scene = UIApplication.shared.connectedScenes
|
||||
.compactMap({ $0 as? UIWindowScene })
|
||||
.first(where: { $0.activationState == .foregroundActive })
|
||||
else { return }
|
||||
|
||||
if #available(iOS 16.0, *) {
|
||||
scene.requestGeometryUpdate(.iOS(interfaceOrientations: mask)) { _ in
|
||||
// Errors here (e.g. user-locked rotation refusing) are non-fatal.
|
||||
}
|
||||
}
|
||||
}
|
||||
#endif
|
||||
|
||||
func reset() {
|
||||
cleanupPlayer()
|
||||
currentVideoId = nil
|
||||
currentVideoTitle = nil
|
||||
currentPost = nil
|
||||
shouldRestoreVideoPlayer = false
|
||||
availableQualities = []
|
||||
currentQuality = nil
|
||||
currentTime = 0
|
||||
duration = 0
|
||||
isPlaying = false
|
||||
playerState = .idle
|
||||
playerViewController = nil
|
||||
playerViewControllerDelegate = nil
|
||||
hasPIPSession = false
|
||||
|
||||
// Release audio focus so we don't keep ducking other apps after the
|
||||
// user has fully left the player. setActive(true) is re-issued from
|
||||
// setupAudioSession() the next time a video loads (GH #40).
|
||||
try? audioSession.setActive(false, options: .notifyOthersOnDeactivation)
|
||||
}
|
||||
|
||||
// Note: deinit cannot call async methods, so cleanup happens via onDisappear in views
|
||||
}
|
||||
Reference in New Issue
Block a user