mirror of
https://github.com/yattee/yattee.git
synced 2026-06-05 06:14:18 +00:00
The proxy auto-detect path (when proxiesVideos is off) HEADed a googlevideo URL with a 5 s timeout on every video. The verdict is a property of the network, not the video, so the cost was paid for no reason on videos 2..N. On a network where the CDN is blocked the full 5 s timeout was added to playback startup every single time. Two changes: 1) ProxyDetectionCache (actor, per-instance, 10 min TTL). First miss pays the HEAD once and caches the verdict; subsequent videos hit the cache synchronously. Concurrent callers share one in-flight probe. The last-seen sample CDN URL is retained so future probes don't need a fresh URL from the current video. 2) PlayerService kicks off InvidiousAPI.prewarmProxyDetection() in parallel with the videoWith... API call. By the time streams come back, the verdict is usually already cached and proxyStreamsIfNeeded is a sync lookup. Cheap when there's nothing to prewarm. Cache invalidation: - on InstancesManager.update (URL change, proxy toggle flip) - on InstancesManager.remove - TTL covers the network-change case for now (no NWPathMonitor yet)
2684 lines
121 KiB
Swift
2684 lines
121 KiB
Swift
//
|
|
// PlayerService.swift
|
|
// Yattee
|
|
//
|
|
// Service managing video playback with pluggable backends.
|
|
//
|
|
|
|
import Foundation
|
|
import AVFoundation
|
|
import Combine
|
|
import SwiftUI
|
|
|
|
/// Protocol for player service delegate callbacks.
|
|
@MainActor
|
|
protocol PlayerServiceDelegate: AnyObject {
|
|
func playerService(_ service: PlayerService, didUpdateTime time: TimeInterval)
|
|
func playerService(_ service: PlayerService, didChangeState state: PlaybackState)
|
|
func playerService(_ service: PlayerService, didEncounterError error: Error)
|
|
func playerService(_ service: PlayerService, shouldSkipSegment segment: SponsorBlockSegment) -> Bool
|
|
func playerServiceDidFinishPlaying(_ service: PlayerService)
|
|
}
|
|
|
|
/// Main service for video playback.
|
|
@MainActor
|
|
@Observable
|
|
final class PlayerService {
|
|
// MARK: - Properties
|
|
|
|
/// The current player backend.
|
|
private(set) var currentBackend: (any PlayerBackend)?
|
|
|
|
/// The type of backend currently in use.
|
|
var currentBackendType: PlayerBackendType {
|
|
currentBackend?.backendType ?? preferredBackendType
|
|
}
|
|
|
|
/// MPV version information (available when MPV backend is initialized).
|
|
var mpvVersionInfo: MPVVersionInfo? {
|
|
(currentBackend as? MPVBackend)?.versionInfo
|
|
}
|
|
|
|
/// User's preferred backend type (reads directly from settings for live updates).
|
|
var preferredBackendType: PlayerBackendType {
|
|
settingsManager?.preferredBackend ?? .mpv
|
|
}
|
|
|
|
/// Observable player state.
|
|
let state: PlayerState
|
|
|
|
/// Delegate for callbacks.
|
|
weak var delegate: PlayerServiceDelegate?
|
|
|
|
/// Available streams for the current video.
|
|
private(set) var availableStreams: [Stream] = []
|
|
|
|
/// Original (unproxied) streams from the API, used to re-apply proxy when settings change.
|
|
private var originalStreams: [Stream] = []
|
|
|
|
/// Available captions for the current video.
|
|
private(set) var availableCaptions: [Caption] = []
|
|
|
|
/// Currently loaded caption.
|
|
private(set) var currentCaption: Caption?
|
|
|
|
/// The current download being played, if any.
|
|
private(set) var currentDownload: Download?
|
|
|
|
/// Whether we're currently playing downloaded content.
|
|
var isPlayingDownloadedContent: Bool {
|
|
currentDownload != nil
|
|
}
|
|
|
|
/// Whether online streams are currently being loaded.
|
|
private(set) var isLoadingOnlineStreams: Bool = false
|
|
|
|
// MARK: - Dependencies
|
|
|
|
private let httpClient: HTTPClient
|
|
private let contentService: ContentService
|
|
private let sponsorBlockAPI: SponsorBlockAPI
|
|
private let returnYouTubeDislikeAPI: ReturnYouTubeDislikeAPI
|
|
private let dataManager: DataManager
|
|
private let backendFactory: BackendFactory
|
|
private let backendSwitcher: BackendSwitcher
|
|
private weak var settingsManager: SettingsManager?
|
|
private weak var downloadManager: DownloadManager?
|
|
private weak var navigationCoordinator: NavigationCoordinator?
|
|
private weak var connectivityMonitor: ConnectivityMonitor?
|
|
private weak var toastManager: ToastManager?
|
|
private weak var queueManager: QueueManager?
|
|
private weak var playerControlsLayoutService: PlayerControlsLayoutService?
|
|
private weak var handoffManager: HandoffManager?
|
|
|
|
// MARK: - Private State
|
|
|
|
/// Current scene phase, updated by handleScenePhase
|
|
private var currentScenePhase: ScenePhase = .active
|
|
|
|
private var progressSaveTimer: Timer?
|
|
private let progressSaveInterval: TimeInterval = 5
|
|
|
|
/// Now Playing service for Control Center/Lock Screen.
|
|
private let nowPlayingService = NowPlayingService()
|
|
|
|
/// Service to prevent system sleep during playback.
|
|
private let sleepPreventionService = SleepPreventionService()
|
|
|
|
/// Tracks the last skipped segment to prevent duplicate skips.
|
|
private var lastSkippedSegmentID: String?
|
|
|
|
/// Tracks if the current video ended naturally (reached EOF).
|
|
/// Used to prevent saving progress when switching to next video after natural completion.
|
|
private var videoEndedNaturally = false
|
|
|
|
/// Current playback task - cancelled when video is closed or new video is opened.
|
|
private var currentPlayTask: Task<Void, Never>?
|
|
|
|
/// Video ID that we're currently loading - used to ignore stale time updates from previous video.
|
|
private var loadingVideoID: VideoID?
|
|
|
|
/// Counter for stream refresh attempts to prevent infinite refresh loops.
|
|
private var streamRefreshAttempts = 0
|
|
private let maxStreamRefreshAttempts = 2
|
|
|
|
/// Tracks if playback was interrupted by system (phone call, alarm, etc.)
|
|
/// Used to auto-resume when interruption ends.
|
|
private var wasInterrupted = false
|
|
|
|
/// Tracks if audio session interruption observer has been registered.
|
|
private var hasRegisteredInterruptionObserver = false
|
|
|
|
// MARK: - Initialization
|
|
|
|
init(
|
|
httpClient: HTTPClient,
|
|
contentService: ContentService,
|
|
dataManager: DataManager,
|
|
backendFactory: BackendFactory? = nil
|
|
) {
|
|
self.httpClient = httpClient
|
|
self.contentService = contentService
|
|
self.dataManager = dataManager
|
|
self.sponsorBlockAPI = SponsorBlockAPI(httpClient: httpClient)
|
|
self.returnYouTubeDislikeAPI = ReturnYouTubeDislikeAPI(httpClient: httpClient)
|
|
self.state = PlayerState()
|
|
|
|
let factory = backendFactory ?? BackendFactory()
|
|
self.backendFactory = factory
|
|
// Note: settingsManager is set later via setSettingsManager, backendSwitcher will use it once available
|
|
self.backendSwitcher = BackendSwitcher(backendFactory: factory, settingsManager: nil)
|
|
|
|
// Link now playing service back to player
|
|
nowPlayingService.playerService = self
|
|
|
|
// Pre-warm backends asynchronously (non-blocking)
|
|
// This starts OpenGL initialization in background immediately at app launch
|
|
Task {
|
|
await factory.prewarmAllBackends()
|
|
}
|
|
|
|
// Listen for memory warnings to drain backend pool
|
|
#if os(iOS) || os(tvOS)
|
|
NotificationCenter.default.addObserver(
|
|
forName: .memoryWarning,
|
|
object: nil,
|
|
queue: .main
|
|
) { [weak factory] _ in
|
|
Task { @MainActor [weak factory] in
|
|
factory?.drainPool()
|
|
}
|
|
}
|
|
#endif
|
|
}
|
|
|
|
deinit {
|
|
MainActor.assumeIsolated {
|
|
cleanup()
|
|
}
|
|
}
|
|
|
|
// MARK: - Public Methods
|
|
|
|
/// Prepares a video for playback without loading streams.
|
|
/// Shows the video in the player with a play button overlay.
|
|
/// Call `play(video:)` to actually start loading and playing.
|
|
/// - Parameter video: The video to prepare
|
|
func prepare(video: Video) {
|
|
LoggingService.shared.logPlayer("Preparing video: \(video.id.id)")
|
|
|
|
// Clear streams
|
|
availableStreams = []
|
|
originalStreams = []
|
|
|
|
// Set video in state but keep idle (not loading)
|
|
state.setCurrentVideo(video, stream: nil)
|
|
state.setPlaybackState(.idle)
|
|
}
|
|
|
|
/// Plays a video with optional starting time.
|
|
/// - Parameters:
|
|
/// - video: The video to play
|
|
/// - stream: Optional specific stream to use (if provided, skips fetching streams from API)
|
|
/// - audioStream: Optional separate audio stream (for video-only streams)
|
|
/// - startTime: Optional start time in seconds
|
|
func play(video: Video, stream: Stream? = nil, audioStream: Stream? = nil, startTime: TimeInterval? = nil) async {
|
|
|
|
// Set up audio session when playback actually starts (not at app launch)
|
|
setupAudioSession()
|
|
|
|
// Save progress and sync for previous video before switching
|
|
// Skip if video ended naturally - 100% was already saved in backendDidFinishPlaying
|
|
if state.currentVideo != nil && state.currentVideo?.id != video.id && !videoEndedNaturally {
|
|
saveProgressAndSync()
|
|
}
|
|
|
|
// Reset flag for the new video
|
|
videoEndedNaturally = false
|
|
|
|
// Clear sponsor block state from previous video
|
|
state.sponsorSegments = []
|
|
state.currentSegment = nil
|
|
lastSkippedSegmentID = nil
|
|
|
|
// Reset stream refresh counter for new video
|
|
streamRefreshAttempts = 0
|
|
|
|
// Clear caption state from previous video
|
|
// (will be set later if this video has captions)
|
|
currentCaption = nil
|
|
availableCaptions = []
|
|
|
|
// Mark that we're loading this video - time updates will be ignored until loading completes
|
|
loadingVideoID = video.id
|
|
|
|
state.setPlaybackState(.loading)
|
|
state.isFirstFrameReady = false // Reset until first frame of new video is rendered
|
|
state.isBufferReady = false // Reset until buffer is ready for smooth playback
|
|
state.setCurrentVideo(video, stream: stream, audioStream: audioStream)
|
|
|
|
// Start fetching SponsorBlock segments early (in parallel with stream loading)
|
|
// so we have them ready before playback starts
|
|
var sponsorBlockTask: Task<Void, Never>?
|
|
if stream == nil, case .global = video.id.source, settingsManager?.sponsorBlockEnabled == true {
|
|
sponsorBlockTask = Task {
|
|
await fetchSponsorBlockSegments(for: video.id.videoID)
|
|
}
|
|
}
|
|
|
|
do {
|
|
let selectedStream: Stream
|
|
let selectedAudioStream: Stream?
|
|
let backendType: PlayerBackendType
|
|
|
|
// If a stream is provided (e.g., local/downloaded file or quality switch), use it directly
|
|
if let providedStream = stream {
|
|
selectedStream = providedStream
|
|
selectedAudioStream = audioStream
|
|
backendType = preferredBackendType
|
|
// Only set availableStreams if empty (preserve existing streams during quality switch)
|
|
if availableStreams.isEmpty {
|
|
self.availableStreams = [providedStream]
|
|
}
|
|
state.setCurrentVideo(video, stream: providedStream, audioStream: audioStream)
|
|
// Mark details as loaded since we're using the video as-is
|
|
state.videoDetailsState = .loaded
|
|
lockDurationIfNeeded(for: video, stream: providedStream)
|
|
|
|
// Load local storyboard if this is a downloaded video
|
|
if let downloadManager,
|
|
let download = downloadManager.download(for: video.id),
|
|
let storyboard = download.storyboard,
|
|
let storyboardPath = download.localStoryboardPath {
|
|
let storyboardDir = downloadManager.downloadsDirectory().appendingPathComponent(storyboardPath)
|
|
let localStoryboard = Storyboard.localStoryboard(from: storyboard, localDirectory: storyboardDir)
|
|
Task { await StoryboardService.shared.clearCache() }
|
|
state.storyboards = [localStoryboard]
|
|
LoggingService.shared.logPlayer("Loaded local storyboard for \(video.id.id)")
|
|
}
|
|
} else {
|
|
// Mark that we're loading video details from API
|
|
state.videoDetailsState = .loading
|
|
|
|
// Fetch full video details (includes description), streams, captions, and storyboards in one API call
|
|
let (fullVideo, streams, captions, storyboards) = try await fetchVideoStreamsAndCaptionsAndStoryboards(for: video)
|
|
|
|
// Check for cancellation after network fetch
|
|
try Task.checkCancellation()
|
|
|
|
self.availableStreams = streams
|
|
self.availableCaptions = captions
|
|
|
|
// Update storyboards and clear cached sprite sheets
|
|
Task { await StoryboardService.shared.clearCache() }
|
|
state.storyboards = storyboards
|
|
LoggingService.shared.logPlayer("Loaded \(storyboards.count) storyboards, preferred: \(state.preferredStoryboard?.width ?? 0)x\(state.preferredStoryboard?.height ?? 0)")
|
|
|
|
// Select best stream for preferred backend
|
|
let selection = selectStreamAndBackend(from: streams)
|
|
|
|
guard let selected = selection.stream else {
|
|
throw APIError.noStreams
|
|
}
|
|
|
|
selectedStream = selected
|
|
selectedAudioStream = selection.audioStream
|
|
backendType = selection.backend
|
|
|
|
// Update state with full video details and selected stream
|
|
state.setCurrentVideo(fullVideo, stream: selectedStream, audioStream: selectedAudioStream)
|
|
CachedChannelData.cacheAuthor(fullVideo.author)
|
|
state.videoDetailsState = .loaded
|
|
lockDurationIfNeeded(for: fullVideo, stream: selectedStream)
|
|
|
|
// Notify observers that full video details are now available
|
|
NotificationCenter.default.post(name: .videoDetailsDidLoad, object: nil)
|
|
|
|
// Auto-load preferred subtitle if set and will use MPV backend
|
|
if backendType == .mpv,
|
|
let preferredLanguage = settingsManager?.preferredSubtitlesLanguage,
|
|
!preferredLanguage.isEmpty {
|
|
// Find matching caption (will be loaded after backend is ready)
|
|
if let preferredCaption = captions.first(where: { caption in
|
|
caption.baseLanguageCode == preferredLanguage ||
|
|
caption.languageCode.hasPrefix(preferredLanguage)
|
|
}) {
|
|
// Store for later loading after backend is created
|
|
currentCaption = preferredCaption
|
|
}
|
|
}
|
|
}
|
|
|
|
// Check for cancellation before loading stream
|
|
try Task.checkCancellation()
|
|
|
|
// Calculate seek time BEFORE loading to know if we need to prepare for initial seek
|
|
// This prevents the thumbnail from hiding before the resume position is reached
|
|
let seekTime: TimeInterval
|
|
// Use state.duration as fallback for quality switching when video.duration might be 0
|
|
let effectiveDuration = video.duration > 0 ? video.duration : state.duration
|
|
let completionThreshold = effectiveDuration * 0.9
|
|
let savedProgress = dataManager.watchProgress(for: video.id.videoID)
|
|
LoggingService.shared.logPlayer("Replay check: savedProgress=\(savedProgress ?? -1), startTime=\(startTime ?? -1), duration=\(video.duration), threshold=\(completionThreshold)")
|
|
|
|
if let startTime {
|
|
// Explicit startTime provided - use it (0 means play from beginning, >0 means resume)
|
|
// For quality switching with startTime > 0, honor the time unless video was completed
|
|
if startTime > 0 && completionThreshold > 0 && startTime >= completionThreshold {
|
|
seekTime = 0 // Video was completed, start over
|
|
} else {
|
|
seekTime = startTime
|
|
}
|
|
} else if let savedProgress, savedProgress > 0, savedProgress < completionThreshold {
|
|
// No explicit startTime - use saved progress if video wasn't completed
|
|
seekTime = savedProgress
|
|
} else {
|
|
// No saved progress or video was completed
|
|
seekTime = 0
|
|
}
|
|
|
|
// Create or switch backend if needed
|
|
let backend = try await ensureBackend(type: backendType)
|
|
|
|
// If we're going to seek after load, tell the backend to defer ready callbacks
|
|
// until the seek completes. This prevents showing a flash of the video at position 0
|
|
// before jumping to the resume position.
|
|
if seekTime > 0 {
|
|
backend.prepareForInitialSeek()
|
|
}
|
|
|
|
// Apply volume based on volume mode setting from active preset
|
|
let volumeMode: VolumeMode
|
|
if let layoutService = playerControlsLayoutService {
|
|
let layout = await layoutService.activeLayout()
|
|
volumeMode = layout.globalSettings.volumeMode
|
|
} else {
|
|
volumeMode = GlobalLayoutSettings.cached.volumeMode
|
|
}
|
|
|
|
if volumeMode == .mpv {
|
|
// In-app mode: use persisted volume
|
|
backend.volume = settingsManager?.playerVolume ?? 1.0
|
|
state.volume = backend.volume
|
|
} else {
|
|
// System mode: set MPV to max, let device control volume
|
|
backend.volume = 1.0
|
|
state.volume = 1.0
|
|
}
|
|
|
|
// Load stream
|
|
// Pass EDL setting (MPV-specific: combines video+audio into single virtual file for unified caching)
|
|
let useEDL = settingsManager?.mpvUseEDLStreams ?? true
|
|
try await backend.load(stream: selectedStream, audioStream: selectedAudioStream, autoplay: false, useEDL: useEDL)
|
|
|
|
// Check for cancellation after stream load completes
|
|
try Task.checkCancellation()
|
|
|
|
await backend.seek(to: seekTime, showLoading: false)
|
|
|
|
// Wait for player sheet animation to complete before starting playback
|
|
await navigationCoordinator?.waitForPlayerSheetAnimation()
|
|
|
|
// Wait for SponsorBlock segments (started earlier in parallel with stream loading)
|
|
// This ensures we have segments before playback starts, so intro skips can show loading
|
|
await sponsorBlockTask?.value
|
|
|
|
// Resolve chapters after SponsorBlock segments are available
|
|
// Uses hierarchy: SponsorBlock chapters > description parsing
|
|
let videoForChapters = state.currentVideo ?? video
|
|
resolveChapters(for: videoForChapters)
|
|
|
|
// Ensure audio session is active before setting Now Playing (required for tvOS MPV)
|
|
#if os(iOS) || os(tvOS)
|
|
if let mpvBackend = backend as? MPVBackend {
|
|
LoggingService.shared.logPlayer("Ensuring audio session active before Now Playing setup")
|
|
mpvBackend.ensureAudioSessionActive()
|
|
}
|
|
#endif
|
|
|
|
LoggingService.shared.logPlayer("Setting up Now Playing info before backend.play()")
|
|
// Update Now Playing info BEFORE starting playback (tvOS requires this order)
|
|
// The system listens to the play event to trigger Now Playing display,
|
|
// so metadata must be established before playback begins.
|
|
let videoForNowPlaying = state.currentVideo ?? video
|
|
nowPlayingService.updateNowPlaying(
|
|
video: videoForNowPlaying,
|
|
currentTime: seekTime,
|
|
duration: videoForNowPlaying.duration,
|
|
isPlaying: true
|
|
)
|
|
|
|
// Load artwork asynchronously (can happen in parallel with playback)
|
|
// For downloaded videos, use local thumbnail if available for offline playback
|
|
Task { [downloadManager] in
|
|
var localThumbnailURL: URL?
|
|
if let downloadManager,
|
|
let download = downloadManager.download(for: videoForNowPlaying.id),
|
|
let localThumbnailPath = download.localThumbnailPath {
|
|
localThumbnailURL = downloadManager.downloadsDirectory().appendingPathComponent(localThumbnailPath)
|
|
}
|
|
await nowPlayingService.loadArtwork(
|
|
from: videoForNowPlaying.bestThumbnail?.url,
|
|
localPath: localThumbnailURL
|
|
)
|
|
}
|
|
|
|
// Start playback
|
|
LoggingService.shared.logPlayer("Calling backend.play(), playbackState: \(state.playbackState)")
|
|
loadingVideoID = nil // Clear loading flag - time updates are now valid
|
|
sleepPreventionService.preventSleep()
|
|
|
|
// For MPV backend, wait for sufficient buffer before starting playback
|
|
// This prevents the brief pause/stutter that occurs when MPV starts playing
|
|
// before enough content is buffered
|
|
// Skip buffer wait for local files - they don't need network buffering
|
|
let isLocalFile = selectedStream.url.isFileURL
|
|
if !isLocalFile, let mpvBackend = backend as? MPVBackend {
|
|
let bufferTime = settingsManager?.mpvBufferSeconds ?? SettingsManager.defaultMpvBufferSeconds
|
|
_ = await mpvBackend.waitForBuffer(minimumBuffer: bufferTime)
|
|
}
|
|
|
|
// Buffer is ready - thumbnail can now hide
|
|
state.isBufferReady = true
|
|
state.bufferProgress = nil // Clear buffer progress since buffering is complete
|
|
|
|
// Give SwiftUI time to process the isBufferReady state change and complete
|
|
// the thumbnail fade-out animation (200ms) before audio starts playing.
|
|
// Without this, audio may start while thumbnail is still visible.
|
|
try? await Task.sleep(nanoseconds: 250_000_000) // 250ms (animation is 200ms)
|
|
|
|
backend.play()
|
|
|
|
// Update Handoff activity for cross-device continuation
|
|
handoffManager?.updateActivity(for: .video(.id(video.id)))
|
|
|
|
// Fetch dislike counts for YouTube videos (only for online content)
|
|
// Note: SponsorBlock is fetched earlier in parallel with stream loading
|
|
// Note: Captions are now fetched together with video details and streams
|
|
if stream == nil, case .global = video.id.source {
|
|
await fetchReturnYouTubeDislikeCounts(for: video.id.videoID)
|
|
}
|
|
|
|
// Load preferred subtitle if one was selected earlier (for MPV backend)
|
|
if let caption = currentCaption, let mpvBackend = backend as? MPVBackend {
|
|
mpvBackend.loadCaption(caption)
|
|
LoggingService.shared.logPlayer("Auto-loaded preferred subtitle: \(caption.displayName)")
|
|
}
|
|
|
|
// Start progress saving
|
|
startProgressSaveTimer()
|
|
|
|
// Notify queue manager that video started (for proactive continuation loading)
|
|
notifyVideoStarted()
|
|
|
|
LoggingService.shared.logPlayer("Playback started: \(video.id.id)", details: "Stream: \(selectedStream.qualityLabel)")
|
|
|
|
} catch is CancellationError {
|
|
// Load was cancelled because a new video was selected - don't report as error
|
|
LoggingService.shared.logPlayer("Playback cancelled: \(video.id.id)")
|
|
sleepPreventionService.allowSleep()
|
|
} catch {
|
|
LoggingService.shared.logPlayerError("Playback failed: \(video.id.id)", error: error)
|
|
sleepPreventionService.allowSleep()
|
|
state.videoDetailsState = .error
|
|
state.setPlaybackState(.failed(error))
|
|
delegate?.playerService(self, didEncounterError: error)
|
|
|
|
// Auto-skip to next video if queue is not empty and player is not visible
|
|
await handlePlaybackErrorAutoSkip()
|
|
}
|
|
}
|
|
|
|
/// Pauses playback.
|
|
/// - Parameter shouldSaveProgress: Whether to save watch progress. Set to `false` when video has
|
|
/// already ended (100% was saved in `backendDidFinishPlaying`).
|
|
func pause(saveProgress shouldSaveProgress: Bool = true) {
|
|
sleepPreventionService.allowSleep()
|
|
currentBackend?.pause()
|
|
state.setPlaybackState(.paused)
|
|
if shouldSaveProgress {
|
|
saveProgress()
|
|
}
|
|
nowPlayingService.updatePlaybackRate(isPlaying: false, currentTime: state.currentTime)
|
|
}
|
|
|
|
/// Resumes playback.
|
|
func resume() {
|
|
sleepPreventionService.preventSleep()
|
|
currentBackend?.play()
|
|
state.setPlaybackState(.playing)
|
|
nowPlayingService.updatePlaybackRate(isPlaying: true, currentTime: state.currentTime)
|
|
}
|
|
|
|
/// Toggles play/pause.
|
|
func togglePlayPause() {
|
|
if state.playbackState == .playing {
|
|
pause()
|
|
} else if state.playbackState == .ended {
|
|
// Restart from beginning when video has ended
|
|
Task {
|
|
await seek(to: 0)
|
|
resume()
|
|
}
|
|
} else {
|
|
resume()
|
|
}
|
|
}
|
|
|
|
/// Stops playback and clears the player.
|
|
func stop() {
|
|
sleepPreventionService.allowSleep()
|
|
|
|
if let video = state.currentVideo {
|
|
LoggingService.shared.logPlayer("Stopping playback: \(video.id.id)")
|
|
}
|
|
|
|
// Cancel any ongoing stream loading
|
|
currentPlayTask?.cancel()
|
|
currentPlayTask = nil
|
|
|
|
saveProgressAndSync()
|
|
cleanup()
|
|
|
|
// Clear Handoff activity
|
|
handoffManager?.invalidateCurrentActivity()
|
|
|
|
// Clear video first, then reset (reset clears isClosingVideo)
|
|
state.setCurrentVideo(nil, stream: nil)
|
|
state.reset()
|
|
state.clearHistory()
|
|
availableStreams = []
|
|
originalStreams = []
|
|
availableCaptions = []
|
|
folderFilesCache.removeAll()
|
|
downloadedSubtitlesCache.removeAll()
|
|
preDownloadedSubtitleFolders.removeAll()
|
|
cleanupAllTempSubtitles()
|
|
currentCaption = nil
|
|
lastSkippedSegmentID = nil
|
|
nowPlayingService.clearNowPlaying()
|
|
}
|
|
|
|
/// Seeks to a specific time.
|
|
/// - Parameters:
|
|
/// - time: The time to seek to in seconds
|
|
/// - showLoading: If true, show loading state during seek (e.g., for early SponsorBlock skips)
|
|
func seek(to time: TimeInterval, showLoading: Bool = false) async {
|
|
state.isSeeking = true
|
|
await currentBackend?.seek(to: time, showLoading: showLoading)
|
|
state.isSeeking = false
|
|
state.currentTime = time
|
|
}
|
|
|
|
/// Seeks forward by a duration.
|
|
func seekForward(by seconds: TimeInterval = 10) {
|
|
state.isSeeking = true
|
|
let newTime = state.currentTime + seconds
|
|
Task {
|
|
await currentBackend?.seek(to: newTime, showLoading: false)
|
|
state.isSeeking = false
|
|
}
|
|
}
|
|
|
|
/// Seeks backward by a duration.
|
|
func seekBackward(by seconds: TimeInterval = 10) {
|
|
state.isSeeking = true
|
|
let newTime = max(state.currentTime - seconds, 0)
|
|
Task {
|
|
await currentBackend?.seek(to: newTime, showLoading: false)
|
|
state.isSeeking = false
|
|
}
|
|
}
|
|
|
|
/// Seeks by a duration in the specified direction.
|
|
/// - Parameters:
|
|
/// - seconds: Number of seconds to seek.
|
|
/// - direction: Direction to seek (forward or backward).
|
|
func seek(seconds: TimeInterval, direction: SeekDirection) {
|
|
switch direction {
|
|
case .forward:
|
|
seekForward(by: seconds)
|
|
case .backward:
|
|
seekBackward(by: seconds)
|
|
}
|
|
}
|
|
|
|
/// Handles scene phase changes for background playback support.
|
|
func handleScenePhase(_ phase: ScenePhase) {
|
|
currentScenePhase = phase
|
|
|
|
// Allow sleep when entering background, re-enable when returning to foreground if playing
|
|
if phase == .background {
|
|
sleepPreventionService.allowSleep()
|
|
} else if phase == .active && state.playbackState == .playing {
|
|
sleepPreventionService.preventSleep()
|
|
}
|
|
|
|
#if os(macOS)
|
|
// On macOS, don't treat main window closing as background when the player window is still visible
|
|
// The scenePhase changes to .background when the main window closes, but if the player window
|
|
// is still open, we should continue playing video normally
|
|
if phase == .background && ExpandedPlayerWindowManager.shared.isPresented {
|
|
LoggingService.shared.debug("PlayerService: Ignoring background phase - player window is still visible", category: .player)
|
|
return
|
|
}
|
|
#endif
|
|
|
|
let backgroundEnabled = settingsManager?.backgroundPlaybackEnabled ?? true
|
|
#if os(iOS)
|
|
let isPiPActive = (currentBackend as? MPVBackend)?.isPiPActive ?? false
|
|
#else
|
|
let isPiPActive = false
|
|
#endif
|
|
currentBackend?.handleScenePhase(phase, backgroundEnabled: backgroundEnabled, isPiPActive: isPiPActive)
|
|
}
|
|
|
|
/// Plays the next video in queue, respecting the current queue mode.
|
|
func playNext() async {
|
|
// Pause without saving progress if video ended - 100% was already saved in backendDidFinishPlaying
|
|
pause(saveProgress: state.playbackState != .ended)
|
|
|
|
// Handle queue mode behavior
|
|
switch state.queueMode {
|
|
case .repeatOne:
|
|
// Restart current video
|
|
await seek(to: 0)
|
|
resume()
|
|
return
|
|
|
|
case .repeatAll:
|
|
// If queue is empty but we have history, recycle history back to queue
|
|
if state.queue.isEmpty && !state.history.isEmpty {
|
|
state.recycleHistoryToQueue()
|
|
}
|
|
// Fall through to normal advance behavior
|
|
|
|
case .shuffle:
|
|
// Pick random video from queue
|
|
guard let next = state.advanceQueueShuffle() else { return }
|
|
pushCurrentToHistoryIfNeeded()
|
|
await playQueuedVideo(next)
|
|
return
|
|
|
|
case .normal:
|
|
break // Use default behavior below
|
|
}
|
|
|
|
// Normal/repeatAll advance behavior
|
|
guard let next = state.advanceQueue() else { return }
|
|
pushCurrentToHistoryIfNeeded()
|
|
await playQueuedVideo(next)
|
|
}
|
|
|
|
/// Pushes current video to history if not in incognito mode.
|
|
private func pushCurrentToHistoryIfNeeded() {
|
|
guard let currentVideo = state.currentVideo,
|
|
settingsManager?.incognitoModeEnabled != true,
|
|
settingsManager?.saveWatchHistory != false else { return }
|
|
let historyItem = QueuedVideo(
|
|
video: currentVideo,
|
|
stream: state.currentStream,
|
|
audioStream: state.currentAudioStream,
|
|
startTime: state.currentTime
|
|
)
|
|
state.pushToHistory(historyItem)
|
|
}
|
|
|
|
/// Plays a queued video with its stream and caption info.
|
|
/// Always starts from the beginning (0) since queue items should play fresh.
|
|
/// Always prefers local downloaded content over pre-loaded network streams.
|
|
/// For media browser videos, resolves stream and captions on-demand.
|
|
private func playQueuedVideo(_ queuedVideo: QueuedVideo) async {
|
|
// Check if this is a media source video needing on-demand resolution
|
|
// Uses unified method that fetches folder contents dynamically - works from any playback source
|
|
LoggingService.shared.debug("[SubtitleDebug] playQueuedVideo called, isFromMediaSource=\(queuedVideo.video.isFromMediaSource), videoID=\(queuedVideo.video.id.videoID)", category: .player)
|
|
if queuedVideo.video.isFromMediaSource {
|
|
LoggingService.shared.debug("[SubtitleDebug] Calling resolveMediaSourceStream", category: .player)
|
|
|
|
do {
|
|
let (stream, captions) = try await resolveMediaSourceStream(for: queuedVideo.video)
|
|
LoggingService.shared.debug("[SubtitleDebug] resolveMediaSourceStream succeeded with \(captions.count) captions", category: .player)
|
|
currentDownload = nil
|
|
await play(video: queuedVideo.video, stream: stream, audioStream: nil, startTime: 0)
|
|
|
|
// Set available captions and auto-select preferred
|
|
if !captions.isEmpty {
|
|
self.availableCaptions = captions
|
|
if let preferred = settingsManager?.preferredSubtitlesLanguage,
|
|
let match = captions.first(where: { $0.baseLanguageCode == preferred || $0.languageCode.hasPrefix(preferred) }) {
|
|
loadCaption(match)
|
|
}
|
|
}
|
|
return
|
|
} catch {
|
|
LoggingService.shared.error("[SubtitleDebug] Failed to resolve media source stream: \(error.localizedDescription)", category: .player)
|
|
// Fall through to try other methods
|
|
}
|
|
}
|
|
|
|
LoggingService.shared.debug("[SubtitleDebug] Using fallback path, queuedVideo has \(queuedVideo.captions.count) pre-loaded captions", category: .player)
|
|
// Always check for downloads first - prefer local files over pre-loaded network streams
|
|
var playedFromDownload = false
|
|
let videoID = queuedVideo.video.id
|
|
|
|
if let downloadManager {
|
|
if let download = downloadManager.download(for: videoID) {
|
|
if download.status == .completed {
|
|
if let (downloadedVideo, localStream, audioStream, captionURL, dislikeCount) = downloadManager.videoAndStream(for: download) {
|
|
currentDownload = download
|
|
playedFromDownload = true
|
|
LoggingService.shared.debug("[Player] Playing queued video \(videoID.videoID) from local download", category: .player)
|
|
await play(video: downloadedVideo, stream: localStream, audioStream: audioStream, startTime: 0)
|
|
if let dislikeCount {
|
|
state.dislikeCount = dislikeCount
|
|
}
|
|
if let captionURL {
|
|
loadLocalCaption(url: captionURL)
|
|
}
|
|
} else {
|
|
LoggingService.shared.warning("[Player] Download for \(videoID.videoID) is completed but videoAndStream returned nil (local file missing?)", category: .player)
|
|
toastManager?.show(
|
|
category: .download,
|
|
title: String(localized: "toast.download.fileMissing.title"),
|
|
icon: "exclamationmark.triangle",
|
|
iconColor: .orange,
|
|
autoDismissDelay: 4.0
|
|
)
|
|
}
|
|
} else {
|
|
LoggingService.shared.warning("[Player] Download found for \(videoID.videoID) but status is \(download.status) (not completed)", category: .player)
|
|
}
|
|
} else {
|
|
let completed = downloadManager.completedDownloads.count
|
|
let active = downloadManager.activeDownloads.count
|
|
LoggingService.shared.warning("[Player] No download record found for \(videoID.videoID) (completed: \(completed), active: \(active))", category: .player)
|
|
}
|
|
} else {
|
|
LoggingService.shared.warning("[Player] downloadManager is nil when trying to play \(videoID.videoID)", category: .player)
|
|
}
|
|
|
|
if !playedFromDownload {
|
|
currentDownload = nil
|
|
await play(video: queuedVideo.video, stream: queuedVideo.stream, audioStream: queuedVideo.audioStream, startTime: 0)
|
|
// Load captions if available
|
|
if !queuedVideo.captions.isEmpty {
|
|
self.availableCaptions = queuedVideo.captions
|
|
// Auto-select preferred language
|
|
if let preferred = settingsManager?.preferredSubtitlesLanguage,
|
|
let match = queuedVideo.captions.first(where: { $0.baseLanguageCode == preferred || $0.languageCode.hasPrefix(preferred) }) {
|
|
loadCaption(match)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Plays a video, preferring local downloaded content if available.
|
|
/// If the video has been downloaded, plays the local file instead of streaming.
|
|
/// For media browser videos, resolves stream and captions on-demand.
|
|
/// If not downloaded, uses the provided fallback streams or fetches from API.
|
|
/// - Parameters:
|
|
/// - video: The video to play
|
|
/// - fallbackStream: Optional stream to use if video is not downloaded
|
|
/// - fallbackAudioStream: Optional audio stream to use if video is not downloaded
|
|
/// - startTime: Optional start time in seconds
|
|
func playPreferringDownloaded(
|
|
video: Video,
|
|
fallbackStream: Stream? = nil,
|
|
fallbackAudioStream: Stream? = nil,
|
|
startTime: TimeInterval? = nil
|
|
) async {
|
|
// Check if this is a media source video needing on-demand resolution
|
|
// Uses unified method that fetches folder contents dynamically - works from any playback source
|
|
if video.isFromMediaSource {
|
|
do {
|
|
let (stream, captions) = try await resolveMediaSourceStream(for: video)
|
|
currentDownload = nil
|
|
await play(video: video, stream: stream, audioStream: nil, startTime: startTime)
|
|
|
|
// Set available captions and auto-select preferred
|
|
if !captions.isEmpty {
|
|
self.availableCaptions = captions
|
|
if let preferred = settingsManager?.preferredSubtitlesLanguage,
|
|
let match = captions.first(where: { $0.baseLanguageCode == preferred || $0.languageCode.hasPrefix(preferred) }) {
|
|
loadCaption(match)
|
|
}
|
|
}
|
|
return
|
|
} catch {
|
|
LoggingService.shared.error("Failed to resolve media source stream: \(error.localizedDescription)", category: .player)
|
|
// Fall through to try other methods
|
|
}
|
|
}
|
|
|
|
// Check if video is downloaded and play locally if so
|
|
if let downloadManager,
|
|
let download = downloadManager.download(for: video.id),
|
|
download.status == .completed {
|
|
if let (downloadedVideo, localStream, audioStream, captionURL, dislikeCount) = downloadManager.videoAndStream(for: download) {
|
|
// Store the download info for later reference
|
|
currentDownload = download
|
|
await play(video: downloadedVideo, stream: localStream, audioStream: audioStream, startTime: startTime)
|
|
// Restore dislike count from download (for offline playback)
|
|
if let dislikeCount {
|
|
state.dislikeCount = dislikeCount
|
|
}
|
|
// Load caption if available, otherwise ensure subtitles are disabled
|
|
if let captionURL {
|
|
loadLocalCaption(url: captionURL)
|
|
}
|
|
// Note: Storyboards are now loaded in play() for all downloaded videos
|
|
|
|
// If downloaded video doesn't have full details (likeCount, viewCount), fetch them from API
|
|
// This runs in the background so playback starts immediately from local file
|
|
if downloadedVideo.supportsAPIStats, downloadedVideo.likeCount == nil || downloadedVideo.viewCount == nil {
|
|
Task {
|
|
await fetchAndUpdateVideoDetails(for: downloadedVideo)
|
|
}
|
|
} else {
|
|
// Video has full details from download, notify observers
|
|
NotificationCenter.default.post(name: .videoDetailsDidLoad, object: nil)
|
|
}
|
|
} else {
|
|
LoggingService.shared.warning("[Player] Download for \(video.id) is completed but videoAndStream returned nil (local file missing?)", category: .player)
|
|
toastManager?.show(
|
|
category: .download,
|
|
title: String(localized: "toast.download.fileMissing.title"),
|
|
icon: "exclamationmark.triangle",
|
|
iconColor: .orange,
|
|
autoDismissDelay: 4.0
|
|
)
|
|
currentDownload = nil
|
|
await play(video: video, stream: fallbackStream, audioStream: fallbackAudioStream, startTime: startTime)
|
|
}
|
|
} else {
|
|
currentDownload = nil
|
|
await play(video: video, stream: fallbackStream, audioStream: fallbackAudioStream, startTime: startTime)
|
|
}
|
|
}
|
|
|
|
/// Checks if the given video is currently loaded in the player.
|
|
/// - Parameter video: The video to check
|
|
/// - Returns: `true` if the video is currently loaded (playing, paused, or buffering)
|
|
func isCurrentlyPlaying(video: Video) -> Bool {
|
|
guard let currentVideo = state.currentVideo else { return false }
|
|
guard currentVideo.id == video.id else { return false }
|
|
|
|
// Check if we have an active playback state (not idle or failed)
|
|
switch state.playbackState {
|
|
case .playing, .paused, .buffering, .ready, .loading:
|
|
return true
|
|
case .idle, .ended, .failed:
|
|
return false
|
|
}
|
|
}
|
|
|
|
/// Opens a video in the player, expanding the player sheet.
|
|
/// If the video is already playing, just expands the player without reloading.
|
|
/// Respects the autoplay setting - if disabled, prepares the video with a play button overlay.
|
|
/// - Parameters:
|
|
/// - video: The video to open
|
|
/// - startTime: Optional start time in seconds (used for continue watching)
|
|
func openVideo(_ video: Video, startTime: TimeInterval? = nil) {
|
|
|
|
// Check if MPV PiP is active - if so, don't expand the player
|
|
#if os(iOS)
|
|
let mpvPiPActive = (currentBackend as? MPVBackend)?.isPiPActive ?? false
|
|
#else
|
|
let mpvPiPActive = false
|
|
#endif
|
|
|
|
// If this video is already playing, just expand the player (unless PiP is active)
|
|
if isCurrentlyPlaying(video: video) {
|
|
LoggingService.shared.logPlayer("Video already playing, just expanding")
|
|
if !mpvPiPActive {
|
|
navigationCoordinator?.expandPlayer()
|
|
}
|
|
return
|
|
}
|
|
|
|
// Cancel any previous play task before starting a new one
|
|
currentPlayTask?.cancel()
|
|
|
|
// Set video info before expanding so the sheet animates with content visible
|
|
state.setPlaybackState(.loading)
|
|
state.setCurrentVideo(video, stream: nil)
|
|
|
|
// Expand player immediately so it opens while loading (unless PiP is active)
|
|
if !mpvPiPActive {
|
|
navigationCoordinator?.expandPlayer()
|
|
}
|
|
|
|
currentPlayTask = Task {
|
|
await playPreferringDownloaded(video: video, startTime: startTime)
|
|
}
|
|
}
|
|
|
|
/// Opens a video with a specific stream (e.g., for downloaded content or media sources).
|
|
/// If the video is already playing, just expands the player without reloading.
|
|
/// - Parameters:
|
|
/// - video: The video to open
|
|
/// - stream: The specific stream to use
|
|
/// - audioStream: Optional separate audio stream (for video-only streams)
|
|
/// - download: Optional download to load local storyboards and captions from
|
|
/// - captions: Optional array of external captions (e.g., from WebDAV subtitle files)
|
|
func openVideo(_ video: Video, stream: Stream, audioStream: Stream? = nil, download: Download? = nil, captions: [Caption] = []) {
|
|
// Check if MPV PiP is active - if so, don't expand the player
|
|
#if os(iOS)
|
|
let mpvPiPActive = (currentBackend as? MPVBackend)?.isPiPActive ?? false
|
|
#else
|
|
let mpvPiPActive = false
|
|
#endif
|
|
|
|
// If this video is already playing, just expand the player (unless PiP is active)
|
|
if isCurrentlyPlaying(video: video) {
|
|
if !mpvPiPActive {
|
|
navigationCoordinator?.expandPlayer()
|
|
}
|
|
return
|
|
}
|
|
|
|
// Cancel any previous play task before starting a new one
|
|
currentPlayTask?.cancel()
|
|
|
|
// Set video info before expanding so the sheet animates with content visible
|
|
state.setPlaybackState(.loading)
|
|
state.setCurrentVideo(video, stream: stream, audioStream: audioStream)
|
|
|
|
// Expand player immediately so it opens while loading (unless PiP is active)
|
|
if !mpvPiPActive {
|
|
navigationCoordinator?.expandPlayer()
|
|
}
|
|
|
|
// Try to look up download if not provided but playing local file
|
|
var resolvedDownload = download
|
|
if resolvedDownload == nil, let downloadManager {
|
|
resolvedDownload = downloadManager.download(for: video.id)
|
|
}
|
|
|
|
// Store download for reference
|
|
currentDownload = resolvedDownload
|
|
|
|
LoggingService.shared.logPlayer("openVideo with stream called - download: \(resolvedDownload != nil ? "present" : "nil")")
|
|
|
|
currentPlayTask = Task {
|
|
await play(video: video, stream: stream, audioStream: audioStream)
|
|
// Note: Storyboards are now loaded in play() for all downloaded videos
|
|
|
|
// Load local caption if download has caption
|
|
if let resolvedDownload,
|
|
let captionPath = resolvedDownload.localCaptionPath,
|
|
let downloadManager {
|
|
let captionURL = downloadManager.downloadsDirectory().appendingPathComponent(captionPath)
|
|
if FileManager.default.fileExists(atPath: captionURL.path) {
|
|
LoggingService.shared.logPlayer("Loading local caption: \(captionPath)")
|
|
loadLocalCaption(url: captionURL)
|
|
}
|
|
}
|
|
|
|
// Set external captions (e.g., from WebDAV subtitle files)
|
|
if !captions.isEmpty {
|
|
LoggingService.shared.logPlayer("Setting \(captions.count) external caption(s)")
|
|
self.availableCaptions = captions
|
|
|
|
// Auto-select based on preferred language setting
|
|
if let preferred = settingsManager?.preferredSubtitlesLanguage,
|
|
let match = captions.first(where: { $0.baseLanguageCode == preferred || $0.languageCode.hasPrefix(preferred) }) {
|
|
LoggingService.shared.logPlayer("Auto-selecting caption: \(match.displayName)")
|
|
loadCaption(match)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Plays the previous video in queue.
|
|
/// If more than 3 seconds into current video, restarts it instead.
|
|
func playPrevious() async {
|
|
// Pause without saving progress if video ended - 100% was already saved in backendDidFinishPlaying
|
|
pause(saveProgress: state.playbackState != .ended)
|
|
|
|
// If more than 3 seconds in, restart current video
|
|
if state.currentTime > 3 {
|
|
await seek(to: 0)
|
|
resume()
|
|
return
|
|
}
|
|
|
|
// Try to go back to previous video in history
|
|
if let previous = state.retreatQueue() {
|
|
// Push current video to front of queue (starts at 0 when replayed)
|
|
if let currentVideo = state.currentVideo {
|
|
state.insertNext(currentVideo, stream: state.currentStream, audioStream: state.currentAudioStream)
|
|
}
|
|
|
|
// Play the previous video, resuming from saved position
|
|
await play(video: previous.video, stream: previous.stream, audioStream: previous.audioStream, startTime: previous.startTime)
|
|
} else {
|
|
// No history, just restart current video
|
|
await seek(to: 0)
|
|
resume()
|
|
}
|
|
}
|
|
|
|
// MARK: - Stream Refresh
|
|
|
|
/// Refreshes stream URLs and resumes playback at the specified time.
|
|
/// Called when mid-playback failure is detected (e.g., expired stream URLs).
|
|
private func refreshStreamsAndResume(atTime resumeTime: TimeInterval?) async {
|
|
guard let video = state.currentVideo else {
|
|
LoggingService.shared.logPlayerError("Cannot refresh streams: no current video")
|
|
return
|
|
}
|
|
|
|
// Check if we've exceeded refresh attempts
|
|
streamRefreshAttempts += 1
|
|
if streamRefreshAttempts > maxStreamRefreshAttempts {
|
|
LoggingService.shared.logPlayerError("Stream refresh failed: exceeded max attempts (\(maxStreamRefreshAttempts))")
|
|
toastManager?.show(
|
|
category: .playerStatus,
|
|
title: String(localized: "toast.player.streamRefreshFailed.title"),
|
|
icon: "exclamationmark.triangle",
|
|
iconColor: .red,
|
|
autoDismissDelay: 3.0
|
|
)
|
|
// Report error to delegate
|
|
let error = BackendError.loadFailed("Stream refresh failed after \(maxStreamRefreshAttempts) attempts")
|
|
delegate?.playerService(self, didEncounterError: error)
|
|
state.setPlaybackState(.failed(error))
|
|
return
|
|
}
|
|
|
|
LoggingService.shared.logPlayer("Refreshing streams (attempt \(streamRefreshAttempts)/\(maxStreamRefreshAttempts))")
|
|
|
|
// Show toast that refresh is starting
|
|
toastManager?.show(
|
|
category: .playerStatus,
|
|
title: String(localized: "toast.player.refreshingStream.title"),
|
|
icon: "arrow.clockwise",
|
|
iconColor: .blue,
|
|
autoDismissDelay: 2.0
|
|
)
|
|
|
|
do {
|
|
// Fetch fresh streams
|
|
let (_, streams, captions) = try await fetchVideoStreamsAndCaptions(for: video)
|
|
|
|
guard !streams.isEmpty else {
|
|
throw APIError.noStreams
|
|
}
|
|
|
|
// Update available streams
|
|
self.availableStreams = streams
|
|
self.availableCaptions = captions
|
|
|
|
// Find matching stream or best alternative
|
|
let newStream = findMatchingStream(in: streams, preferring: state.currentStream)
|
|
let newAudioStream: Stream?
|
|
|
|
if newStream.isVideoOnly {
|
|
// Find matching audio stream
|
|
newAudioStream = findMatchingAudioStream(in: streams, preferring: state.currentAudioStream)
|
|
} else {
|
|
newAudioStream = nil
|
|
}
|
|
|
|
LoggingService.shared.logPlayer("Resuming with stream: \(newStream.qualityLabel) at \(resumeTime ?? 0)s")
|
|
|
|
// Resume playback with fresh stream
|
|
await play(video: video, stream: newStream, audioStream: newAudioStream, startTime: resumeTime)
|
|
|
|
// Reset refresh counter on success
|
|
streamRefreshAttempts = 0
|
|
|
|
// Show success toast
|
|
toastManager?.show(
|
|
category: .playerStatus,
|
|
title: String(localized: "toast.player.streamRefreshed.title"),
|
|
icon: "checkmark.circle",
|
|
iconColor: .green,
|
|
autoDismissDelay: 2.0
|
|
)
|
|
|
|
} catch {
|
|
LoggingService.shared.logPlayerError("Stream refresh failed", error: error)
|
|
|
|
// If still under max attempts, the next error will trigger another refresh
|
|
// Otherwise, show error to user
|
|
if streamRefreshAttempts >= maxStreamRefreshAttempts {
|
|
toastManager?.show(
|
|
category: .playerStatus,
|
|
title: String(localized: "toast.player.streamRefreshFailed.title"),
|
|
icon: "exclamationmark.triangle",
|
|
iconColor: .red,
|
|
autoDismissDelay: 3.0
|
|
)
|
|
delegate?.playerService(self, didEncounterError: error)
|
|
state.setPlaybackState(.failed(error))
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Finds a stream matching the preferred stream's quality, or the best available alternative.
|
|
private func findMatchingStream(in streams: [Stream], preferring preferred: Stream?) -> Stream {
|
|
let videoStreams = streams.filter { !$0.isAudioOnly }
|
|
|
|
// If we have a preferred stream, try to match its resolution
|
|
if let preferred, let preferredResolution = preferred.resolution {
|
|
// First try exact resolution match
|
|
if let exact = videoStreams.first(where: { $0.resolution == preferredResolution }) {
|
|
return exact
|
|
}
|
|
|
|
// Then try closest resolution
|
|
let sorted = videoStreams.sorted { stream1, stream2 in
|
|
let diff1 = abs((stream1.resolution?.height ?? 0) - preferredResolution.height)
|
|
let diff2 = abs((stream2.resolution?.height ?? 0) - preferredResolution.height)
|
|
return diff1 < diff2
|
|
}
|
|
if let closest = sorted.first {
|
|
return closest
|
|
}
|
|
}
|
|
|
|
// Fall back to best available stream
|
|
return videoStreams.max(by: { ($0.resolution?.height ?? 0) < ($1.resolution?.height ?? 0) }) ?? streams.first!
|
|
}
|
|
|
|
/// Finds an audio stream matching the preferred stream, or the best available alternative.
|
|
private func findMatchingAudioStream(in streams: [Stream], preferring preferred: Stream?) -> Stream? {
|
|
let audioStreams = streams.filter { $0.isAudioOnly }
|
|
|
|
guard !audioStreams.isEmpty else { return nil }
|
|
|
|
// If we have a preferred audio stream, try to match its language/codec
|
|
if let preferred {
|
|
// Try exact language match
|
|
if let preferredLang = preferred.audioLanguage,
|
|
let match = audioStreams.first(where: { $0.audioLanguage == preferredLang }) {
|
|
return match
|
|
}
|
|
}
|
|
|
|
// Fall back to best available (highest bitrate)
|
|
return audioStreams.max(by: { ($0.bitrate ?? 0) < ($1.bitrate ?? 0) })
|
|
}
|
|
|
|
// MARK: - Captions
|
|
|
|
/// Loads and displays a caption track.
|
|
/// Only works with MPV backend.
|
|
/// - Parameter caption: The caption to load, or nil to disable subtitles
|
|
func loadCaption(_ caption: Caption?) {
|
|
guard let mpvBackend = currentBackend as? MPVBackend else {
|
|
LoggingService.shared.debug("Cannot load caption: not using MPV backend", category: .player)
|
|
return
|
|
}
|
|
|
|
mpvBackend.loadCaption(caption)
|
|
currentCaption = caption
|
|
|
|
if let caption {
|
|
LoggingService.shared.logPlayer("Loaded caption: \(caption.displayName)")
|
|
} else {
|
|
LoggingService.shared.logPlayer("Disabled subtitles")
|
|
}
|
|
}
|
|
|
|
/// Loads a local subtitle file from disk.
|
|
/// Only works with MPV backend.
|
|
/// - Parameter url: The local file URL of the caption file
|
|
func loadLocalCaption(url: URL) {
|
|
// Extract language from filename (e.g., "videoID_en.vtt" -> "en")
|
|
let filename = url.deletingPathExtension().lastPathComponent
|
|
let languageCode = filename.components(separatedBy: "_").last ?? "unknown"
|
|
let languageName = Locale.current.localizedString(forLanguageCode: languageCode) ?? languageCode
|
|
|
|
let caption = Caption(
|
|
label: languageName,
|
|
languageCode: languageCode,
|
|
url: url
|
|
)
|
|
|
|
loadCaption(caption)
|
|
}
|
|
|
|
/// Loads online streams for the current video (when playing downloaded content).
|
|
/// After loading, the user can switch to an online stream from QualitySelectorView.
|
|
/// The downloaded stream is preserved and mixed in with online streams.
|
|
func loadOnlineStreams() async {
|
|
guard let video = state.currentVideo else { return }
|
|
|
|
// Keep track of current downloaded streams (local file URLs)
|
|
let downloadedStreams = availableStreams.filter { $0.url.isFileURL }
|
|
|
|
isLoadingOnlineStreams = true
|
|
defer { isLoadingOnlineStreams = false }
|
|
|
|
do {
|
|
let (_, streams, captions) = try await fetchVideoStreamsAndCaptions(for: video)
|
|
// Combine downloaded streams with online streams (downloaded first)
|
|
availableStreams = downloadedStreams + streams
|
|
availableCaptions = captions
|
|
LoggingService.shared.logPlayer("Loaded \(streams.count) online streams and \(captions.count) captions (keeping \(downloadedStreams.count) downloaded)")
|
|
} catch {
|
|
LoggingService.shared.logPlayerError("Failed to load online streams", error: error)
|
|
}
|
|
}
|
|
|
|
/// Switches from downloaded content to an online stream.
|
|
/// - Parameters:
|
|
/// - stream: The online stream to switch to
|
|
/// - audioStream: Optional separate audio stream
|
|
func switchToOnlineStream(_ stream: Stream, audioStream: Stream? = nil) async {
|
|
guard let video = state.currentVideo else { return }
|
|
|
|
// Clear the download flag since we're now playing online
|
|
currentDownload = nil
|
|
|
|
// Get current playback position to resume at
|
|
let currentTime = state.currentTime
|
|
|
|
// Play the new stream from the current position
|
|
await play(video: video, stream: stream, audioStream: audioStream, startTime: currentTime)
|
|
}
|
|
|
|
// MARK: - Media Browser Stream Resolution
|
|
|
|
/// Resolves stream and captions for a media browser video on-demand.
|
|
/// - Parameters:
|
|
/// - video: The video to resolve
|
|
/// - context: Media browser context with source and folder files
|
|
/// - Returns: Tuple of (stream, captions) ready for playback
|
|
func resolveMediaBrowserStream(
|
|
for video: Video,
|
|
context: MediaBrowserQueueContext
|
|
) async throws -> (stream: Stream, captions: [Caption]) {
|
|
// Find the MediaFile for this video in the folder
|
|
// videoID format: "sourceUUID:path"
|
|
guard let mediaFile = context.allFilesInFolder.first(where: { $0.id == video.id.videoID }) else {
|
|
throw MediaSourceError.pathNotFound("Video file not found in folder context")
|
|
}
|
|
|
|
// Find matching subtitle files
|
|
let subtitleFiles = mediaFile.findMatchingSubtitles(in: context.allFilesInFolder)
|
|
|
|
let source = context.source
|
|
let password = mediaSourcesManager?.password(for: source)
|
|
|
|
switch source.type {
|
|
case .webdav:
|
|
// Get auth headers for WebDAV
|
|
var authHeaders: [String: String]?
|
|
if let webDAVClient {
|
|
authHeaders = await webDAVClient.authHeaders(for: source, password: password)
|
|
}
|
|
let stream = mediaFile.toStream(authHeaders: authHeaders)
|
|
// Subtitles can use remote URLs directly for WebDAV
|
|
return (stream, subtitleFiles)
|
|
|
|
case .smb:
|
|
guard let smbClient else {
|
|
throw MediaSourceError.unknown("SMB client not available")
|
|
}
|
|
|
|
let folderKey = source.id.uuidString + ":" + context.folderPath
|
|
|
|
// Pre-download ALL subtitles in folder on first access.
|
|
// Must complete before MPV opens any SMB connection.
|
|
// For queued videos from the same folder, subtitles will already be cached.
|
|
if !preDownloadedSubtitleFolders.contains(folderKey) {
|
|
let allSubtitleFiles = context.allFilesInFolder.filter { $0.isSubtitle }
|
|
LoggingService.shared.logMediaSources("Pre-downloading \(allSubtitleFiles.count) subtitle(s) from folder: \(context.folderPath)")
|
|
|
|
for subtitleFile in allSubtitleFiles {
|
|
do {
|
|
let localURL = try await smbClient.downloadSubtitleToTemp(
|
|
file: subtitleFile,
|
|
source: source,
|
|
password: password,
|
|
videoID: folderKey
|
|
)
|
|
downloadedSubtitlesCache[subtitleFile.id] = localURL
|
|
LoggingService.shared.logMediaSources("Pre-downloaded subtitle: \(subtitleFile.name) → \(localURL.lastPathComponent)")
|
|
} catch {
|
|
LoggingService.shared.error(
|
|
"Failed to pre-download subtitle \(subtitleFile.name): \(error.localizedDescription)",
|
|
category: .general
|
|
)
|
|
}
|
|
}
|
|
|
|
preDownloadedSubtitleFolders.insert(folderKey)
|
|
|
|
// Release app SMB context before MPV opens its connection.
|
|
// libsmbclient uses process-global talloc state that corrupts
|
|
// when two contexts access the same server concurrently.
|
|
await smbClient.clearCache(for: source)
|
|
LoggingService.shared.logMediaSources("Cleared SMB context cache before playback")
|
|
}
|
|
|
|
// Construct video playback URL (pure string manipulation, no libsmbclient)
|
|
let playbackURL = try await smbClient.constructPlaybackURL(
|
|
for: mediaFile,
|
|
source: source,
|
|
password: password
|
|
)
|
|
let stream = Stream(url: playbackURL, resolution: nil, format: mediaFile.fileExtension)
|
|
|
|
// Build captions from pre-downloaded local files
|
|
var localCaptions: [Caption] = []
|
|
for subtitle in subtitleFiles {
|
|
if let subtitleFile = context.allFilesInFolder.first(where: { $0.url == subtitle.url }),
|
|
let localURL = downloadedSubtitlesCache[subtitleFile.id] {
|
|
localCaptions.append(Caption(
|
|
label: subtitle.label,
|
|
languageCode: subtitle.languageCode,
|
|
url: localURL
|
|
))
|
|
LoggingService.shared.info(
|
|
"Using pre-downloaded subtitle: \(subtitleFile.name)",
|
|
category: .general
|
|
)
|
|
}
|
|
}
|
|
|
|
return (stream, localCaptions)
|
|
|
|
case .localFolder:
|
|
// Local folder - no auth needed, use file:// URLs directly
|
|
let stream = mediaFile.toStream(authHeaders: nil)
|
|
return (stream, subtitleFiles)
|
|
}
|
|
}
|
|
|
|
/// Resolves stream and subtitles for a media source video by fetching folder contents on-demand.
|
|
/// Works for all media source types (WebDAV, SMB, local folders) from any playback source
|
|
/// (Media Browser, Continue Watching, etc.).
|
|
func resolveMediaSourceStream(for video: Video) async throws -> (stream: Stream, captions: [Caption]) {
|
|
LoggingService.shared.debug("[SubtitleDebug] resolveMediaSourceStream called for videoID: \(video.id.videoID)", category: .player)
|
|
|
|
// 1. Extract source ID and file path from video ID
|
|
guard let sourceID = video.mediaSourceID,
|
|
let filePath = video.mediaSourceFilePath else {
|
|
LoggingService.shared.error("[SubtitleDebug] Failed to extract source info - mediaSourceID: \(String(describing: video.mediaSourceID)), mediaSourceFilePath: \(String(describing: video.mediaSourceFilePath))", category: .player)
|
|
throw MediaSourceError.pathNotFound("Could not extract source info from video ID")
|
|
}
|
|
|
|
LoggingService.shared.debug("[SubtitleDebug] Extracted sourceID: \(sourceID), filePath: \(filePath)", category: .player)
|
|
|
|
// 2. Look up the MediaSource
|
|
guard let source = mediaSourcesManager?.source(byID: sourceID) else {
|
|
LoggingService.shared.error("[SubtitleDebug] Media source not found for ID: \(sourceID)", category: .player)
|
|
throw MediaSourceError.unknown("Media source not found")
|
|
}
|
|
|
|
LoggingService.shared.debug("[SubtitleDebug] Found source: \(source.name), type: \(source.type)", category: .player)
|
|
|
|
let password = mediaSourcesManager?.password(for: source)
|
|
let parentPath = (filePath as NSString).deletingLastPathComponent
|
|
let fileName = (filePath as NSString).lastPathComponent
|
|
|
|
LoggingService.shared.debug("[SubtitleDebug] parentPath: \(parentPath), fileName: \(fileName)", category: .player)
|
|
|
|
// 3. Fetch folder contents based on source type (with cache for queued playback)
|
|
let cacheKey = "\(sourceID):\(parentPath)"
|
|
let folderFiles: [MediaFile]
|
|
|
|
if let cached = folderFilesCache[cacheKey] {
|
|
LoggingService.shared.debug("[SubtitleDebug] Using cached folder listing for \(cacheKey) (\(cached.count) files)", category: .player)
|
|
folderFiles = cached
|
|
} else {
|
|
switch source.type {
|
|
case .webdav:
|
|
guard let webDAVClient else {
|
|
throw MediaSourceError.unknown("WebDAV client not available")
|
|
}
|
|
folderFiles = try await webDAVClient.listFiles(at: parentPath, source: source, password: password)
|
|
|
|
case .smb:
|
|
guard let smbClient else {
|
|
throw MediaSourceError.unknown("SMB client not available")
|
|
}
|
|
folderFiles = try await smbClient.listFiles(at: parentPath, source: source, password: password)
|
|
|
|
case .localFolder:
|
|
guard let localFileClient else {
|
|
throw MediaSourceError.unknown("Local file client not available")
|
|
}
|
|
folderFiles = try await localFileClient.listFiles(at: parentPath, source: source)
|
|
}
|
|
folderFilesCache[cacheKey] = folderFiles
|
|
LoggingService.shared.debug("[SubtitleDebug] Listed \(folderFiles.count) files in folder, cached as \(cacheKey)", category: .player)
|
|
}
|
|
|
|
// 4. Find the MediaFile for this video
|
|
guard let mediaFile = folderFiles.first(where: { $0.name == fileName }) else {
|
|
throw MediaSourceError.pathNotFound("Video file not found in folder")
|
|
}
|
|
|
|
// 5. Find matching subtitles
|
|
let subtitleCaptions = mediaFile.findMatchingSubtitles(in: folderFiles)
|
|
|
|
// 6. Build stream and process captions based on source type
|
|
switch source.type {
|
|
case .webdav:
|
|
// Get auth headers for WebDAV
|
|
var authHeaders: [String: String]?
|
|
if let webDAVClient {
|
|
authHeaders = await webDAVClient.authHeaders(for: source, password: password)
|
|
}
|
|
let stream = mediaFile.toStream(authHeaders: authHeaders)
|
|
// Subtitles can use remote URLs directly for WebDAV
|
|
return (stream, subtitleCaptions)
|
|
|
|
case .smb:
|
|
guard let smbClient else {
|
|
throw MediaSourceError.unknown("SMB client not available")
|
|
}
|
|
|
|
let folderKey = "\(sourceID):\(parentPath)"
|
|
|
|
// Pre-download ALL subtitles in folder on first access.
|
|
// Must complete before MPV opens any SMB connection.
|
|
// For queued videos from the same folder, subtitles will already be cached.
|
|
if !preDownloadedSubtitleFolders.contains(folderKey) {
|
|
let allSubtitleFiles = folderFiles.filter { $0.isSubtitle }
|
|
LoggingService.shared.logMediaSources("Pre-downloading \(allSubtitleFiles.count) subtitle(s) from folder: \(parentPath)")
|
|
|
|
for subtitleFile in allSubtitleFiles {
|
|
do {
|
|
let localURL = try await smbClient.downloadSubtitleToTemp(
|
|
file: subtitleFile,
|
|
source: source,
|
|
password: password,
|
|
videoID: folderKey
|
|
)
|
|
downloadedSubtitlesCache[subtitleFile.id] = localURL
|
|
LoggingService.shared.logMediaSources("Pre-downloaded subtitle: \(subtitleFile.name) → \(localURL.lastPathComponent)")
|
|
} catch {
|
|
LoggingService.shared.error(
|
|
"Failed to pre-download subtitle \(subtitleFile.name): \(error.localizedDescription)",
|
|
category: .general
|
|
)
|
|
}
|
|
}
|
|
|
|
preDownloadedSubtitleFolders.insert(folderKey)
|
|
|
|
// Release app SMB context before MPV opens its connection.
|
|
// libsmbclient uses process-global talloc state that corrupts
|
|
// when two contexts access the same server concurrently.
|
|
await smbClient.clearCache(for: source)
|
|
LoggingService.shared.logMediaSources("Cleared SMB context cache before playback")
|
|
}
|
|
|
|
// Construct video playback URL (pure string manipulation, no libsmbclient)
|
|
let playbackURL = try await smbClient.constructPlaybackURL(
|
|
for: mediaFile,
|
|
source: source,
|
|
password: password
|
|
)
|
|
let stream = Stream(url: playbackURL, resolution: nil, format: mediaFile.fileExtension)
|
|
|
|
// Build captions from pre-downloaded local files
|
|
var localCaptions: [Caption] = []
|
|
for subtitle in subtitleCaptions {
|
|
if let subtitleFile = folderFiles.first(where: { $0.url == subtitle.url }),
|
|
let localURL = downloadedSubtitlesCache[subtitleFile.id] {
|
|
localCaptions.append(Caption(
|
|
label: subtitle.label,
|
|
languageCode: subtitle.languageCode,
|
|
url: localURL
|
|
))
|
|
LoggingService.shared.info(
|
|
"Using pre-downloaded subtitle: \(subtitleFile.name)",
|
|
category: .general
|
|
)
|
|
}
|
|
}
|
|
|
|
return (stream, localCaptions)
|
|
|
|
case .localFolder:
|
|
// Local folder - no auth needed, use file:// URLs directly
|
|
let stream = mediaFile.toStream(authHeaders: nil)
|
|
return (stream, subtitleCaptions)
|
|
}
|
|
}
|
|
|
|
// MARK: - Private Methods
|
|
|
|
/// Checks if the stream URL goes through Yattee Server's fast download endpoint.
|
|
/// Fast endpoint streams don't have Content-Length, so MPV can't determine accurate duration.
|
|
/// In this case, we should use the API-provided duration instead.
|
|
private func isFastEndpointStream(_ stream: Stream?) -> Bool {
|
|
guard let urlString = stream?.url.absoluteString else { return false }
|
|
return urlString.contains("/proxy/fast/")
|
|
}
|
|
|
|
/// Locks duration from API if playing through fast endpoint.
|
|
/// This prevents progress bar jitter when MPV reports changing duration during progressive download.
|
|
private func lockDurationIfNeeded(for video: Video, stream: Stream?) {
|
|
if isFastEndpointStream(stream), video.duration > 0 {
|
|
state.lockDuration(video.duration)
|
|
LoggingService.shared.debug("Locked duration from API: \(video.duration)s (fast endpoint stream)", category: .player)
|
|
}
|
|
}
|
|
|
|
private func setupAudioSession() {
|
|
#if os(iOS) || os(tvOS)
|
|
do {
|
|
let session = AVAudioSession.sharedInstance()
|
|
try session.setCategory(.playback, mode: .moviePlayback)
|
|
try session.setActive(true)
|
|
|
|
// Only register observer once
|
|
guard !hasRegisteredInterruptionObserver else { return }
|
|
hasRegisteredInterruptionObserver = true
|
|
|
|
LoggingService.shared.debug("Registering audio session interruption observer", category: .player)
|
|
|
|
// Register for audio interruption notifications (phone calls, alarms, etc.)
|
|
NotificationCenter.default.addObserver(
|
|
forName: AVAudioSession.interruptionNotification,
|
|
object: session,
|
|
queue: .main
|
|
) { [weak self] notification in
|
|
// Extract values before async boundary to satisfy Sendable requirements
|
|
guard let userInfo = notification.userInfo,
|
|
let typeValue = userInfo[AVAudioSessionInterruptionTypeKey] as? UInt,
|
|
let type = AVAudioSession.InterruptionType(rawValue: typeValue) else {
|
|
return
|
|
}
|
|
let optionsValue = userInfo[AVAudioSessionInterruptionOptionKey] as? UInt
|
|
|
|
Task { @MainActor [weak self] in
|
|
LoggingService.shared.debug("Audio interruption notification received, type: \(type.rawValue), options: \(optionsValue ?? 0)", category: .player)
|
|
self?.handleAudioSessionInterruption(type: type, optionsValue: optionsValue)
|
|
}
|
|
}
|
|
} catch {
|
|
LoggingService.shared.logPlayerError("Audio session setup failed", error: error)
|
|
}
|
|
#endif
|
|
}
|
|
|
|
#if os(iOS) || os(tvOS)
|
|
private func handleAudioSessionInterruption(type: AVAudioSession.InterruptionType, optionsValue: UInt?) {
|
|
LoggingService.shared.debug("handleAudioSessionInterruption called, current playbackState: \(state.playbackState), wasInterrupted: \(wasInterrupted)", category: .player)
|
|
|
|
switch type {
|
|
case .began:
|
|
LoggingService.shared.debug("Audio session interrupted (began), playbackState: \(state.playbackState)", category: .player)
|
|
// Audio was interrupted (phone call, alarm, etc.)
|
|
if state.playbackState == .playing {
|
|
LoggingService.shared.debug("Pausing playback due to interruption", category: .player)
|
|
// Call full pause() to properly pause the backend
|
|
pause()
|
|
wasInterrupted = true
|
|
} else {
|
|
LoggingService.shared.debug("Not pausing - playbackState is not .playing", category: .player)
|
|
}
|
|
case .ended:
|
|
LoggingService.shared.debug("Audio session interruption ended, wasInterrupted: \(wasInterrupted)", category: .player)
|
|
// Interruption ended - check if we should resume
|
|
if let optionsValue {
|
|
let options = AVAudioSession.InterruptionOptions(rawValue: optionsValue)
|
|
LoggingService.shared.debug("Interruption options: shouldResume=\(options.contains(.shouldResume))", category: .player)
|
|
if options.contains(.shouldResume) && wasInterrupted {
|
|
LoggingService.shared.debug("Auto-resuming playback after interruption", category: .player)
|
|
// Reactivate audio session before resuming - iOS requires this after interruption
|
|
do {
|
|
try AVAudioSession.sharedInstance().setActive(true)
|
|
LoggingService.shared.debug("Audio session reactivated successfully", category: .player)
|
|
} catch {
|
|
LoggingService.shared.error("Failed to reactivate audio session: \(error.localizedDescription)", category: .player)
|
|
}
|
|
resume()
|
|
} else {
|
|
LoggingService.shared.debug("Not auto-resuming: shouldResume=\(options.contains(.shouldResume)), wasInterrupted=\(wasInterrupted)", category: .player)
|
|
}
|
|
} else {
|
|
LoggingService.shared.debug("No interruption options provided", category: .player)
|
|
}
|
|
wasInterrupted = false
|
|
@unknown default:
|
|
LoggingService.shared.debug("Unknown interruption type: \(type.rawValue)", category: .player)
|
|
break
|
|
}
|
|
}
|
|
#endif
|
|
|
|
private func fetchVideoStreamsAndCaptions(for video: Video) async throws -> (Video, [Stream], [Caption]) {
|
|
let result = try await fetchVideoStreamsAndCaptionsAndStoryboards(for: video)
|
|
return (result.0, result.1, result.2)
|
|
}
|
|
|
|
private func fetchVideoStreamsAndCaptionsAndStoryboards(for video: Video) async throws -> (Video, [Stream], [Caption], [Storyboard]) {
|
|
// Handle media source videos (WebDAV, SMB, and local folders)
|
|
// These don't need API calls - we reconstruct the stream from the stored URL
|
|
if case .extracted(let extractor, let originalURL) = video.id.source {
|
|
if extractor == MediaFile.webdavProvider
|
|
|| extractor == MediaFile.localFolderProvider
|
|
|| extractor == MediaFile.smbProvider {
|
|
let stream = try await createStreamForMediaSource(video: video, url: originalURL, extractor: extractor)
|
|
return (video, [stream], [], [])
|
|
}
|
|
}
|
|
|
|
guard let instance = try await findInstance(for: video) else {
|
|
throw APIError.noInstance
|
|
}
|
|
|
|
// For extracted videos, use the extract endpoint with the original URL
|
|
// (stream URLs expire, so we need to re-extract each time)
|
|
if case .extracted(_, let originalURL) = video.id.source {
|
|
guard instance.type == .yatteeServer else {
|
|
throw APIError.notSupported
|
|
}
|
|
let result = try await contentService.extractURL(originalURL, instance: instance)
|
|
return (result.video, result.streams, result.captions, [])
|
|
}
|
|
|
|
// Race the proxy-detection HEAD against the video API call so the
|
|
// verdict is (usually) ready by the time streams come back. Cheap
|
|
// when there's nothing to do — it returns immediately if the
|
|
// verdict is already cached, or if no prior CDN sample exists.
|
|
async let _: Void = InvidiousAPI.prewarmProxyDetection(for: instance)
|
|
|
|
// Fetch full video details, streams, captions, and storyboards in a single API call
|
|
// (for Invidious, this is a single request; for other backends, calls are made in parallel)
|
|
let result = try await contentService.videoWithStreamsAndCaptionsAndStoryboards(id: video.id.videoID, instance: instance)
|
|
|
|
// Store original (unproxied) streams so we can re-apply proxy when settings change
|
|
self.originalStreams = result.streams
|
|
|
|
// Apply proxy URL rewriting for instances that support it
|
|
let streams = await InvidiousAPI.proxyStreamsIfNeeded(result.streams, instance: instance)
|
|
|
|
return (result.video, streams, result.captions, result.storyboards)
|
|
}
|
|
|
|
/// Re-applies proxy URL rewriting to cached streams when instance settings change.
|
|
/// Uses the stored `originalStreams` to derive the correct URLs without re-fetching from the API.
|
|
private func reapplyProxyToStreams() async {
|
|
guard !originalStreams.isEmpty,
|
|
let video = state.currentVideo,
|
|
let instance = try? await findInstance(for: video) else { return }
|
|
|
|
let newStreams = await InvidiousAPI.proxyStreamsIfNeeded(originalStreams, instance: instance)
|
|
|
|
// Update available streams (preserve any downloaded/local file streams)
|
|
let downloadedStreams = availableStreams.filter { $0.url.isFileURL }
|
|
availableStreams = downloadedStreams + newStreams
|
|
|
|
// Update current stream URL if it changed
|
|
if let currentStream = state.currentStream,
|
|
let matchingNew = newStreams.first(where: { $0.resolution == currentStream.resolution && $0.format == currentStream.format }) {
|
|
if matchingNew.url != currentStream.url {
|
|
state.updateCurrentStream(matchingNew)
|
|
LoggingService.shared.logPlayer("Updated current stream URL after proxy setting change")
|
|
}
|
|
}
|
|
|
|
// Update current audio stream URL if it changed
|
|
if let currentAudio = state.currentAudioStream,
|
|
let matchingNew = newStreams.first(where: { $0.resolution == currentAudio.resolution && $0.format == currentAudio.format && $0.isAudioOnly == true }) {
|
|
if matchingNew.url != currentAudio.url {
|
|
state.updateCurrentAudioStream(matchingNew)
|
|
LoggingService.shared.logPlayer("Updated current audio stream URL after proxy setting change")
|
|
}
|
|
}
|
|
|
|
LoggingService.shared.logPlayer("Re-applied proxy settings: \(newStreams.count) streams updated")
|
|
}
|
|
|
|
/// Creates a stream for media source videos (WebDAV, SMB, or local folder).
|
|
/// For WebDAV, adds authentication headers. For SMB, constructs URL with embedded credentials.
|
|
private func createStreamForMediaSource(video: Video, url: URL, extractor: String) async throws -> Stream {
|
|
// Parse the MediaSource UUID from the video ID
|
|
// Format: "UUID:/path/to/file.mp4"
|
|
let videoID = video.id.videoID
|
|
var headers: [String: String]? = nil
|
|
var playbackURL = url
|
|
|
|
if let separatorIndex = videoID.firstIndex(of: ":"),
|
|
let sourceUUID = UUID(uuidString: String(videoID[..<separatorIndex])) {
|
|
// For WebDAV sources, add authentication headers
|
|
if extractor == MediaFile.webdavProvider {
|
|
if let source = mediaSourcesManager?.sources.first(where: { $0.id == sourceUUID }) {
|
|
if let username = source.username,
|
|
let password = mediaSourcesManager?.password(for: source) {
|
|
let credentials = "\(username):\(password)"
|
|
if let credentialsData = credentials.data(using: .utf8) {
|
|
let base64Credentials = credentialsData.base64EncodedString()
|
|
headers = ["Authorization": "Basic \(base64Credentials)"]
|
|
LoggingService.shared.logPlayer("Media source: Created WebDAV stream with auth for '\(source.name)'")
|
|
}
|
|
} else {
|
|
LoggingService.shared.logPlayer("Media source: Created WebDAV stream without auth (no credentials) for '\(source.name)'")
|
|
}
|
|
} else {
|
|
LoggingService.shared.logPlayer("Media source: WebDAV source not found for UUID \(sourceUUID)")
|
|
}
|
|
}
|
|
// For SMB sources, construct URL with embedded credentials
|
|
else if extractor == MediaFile.smbProvider {
|
|
if let source = mediaSourcesManager?.sources.first(where: { $0.id == sourceUUID }) {
|
|
// Reconstruct MediaFile from video to use SMBClient
|
|
let path = String(videoID.dropFirst(sourceUUID.uuidString.count + 1))
|
|
let mediaFile = MediaFile(
|
|
source: source,
|
|
path: path,
|
|
name: url.lastPathComponent,
|
|
isDirectory: false
|
|
)
|
|
|
|
let password = mediaSourcesManager?.password(for: source)
|
|
if let smbClient {
|
|
playbackURL = try await smbClient.constructPlaybackURL(
|
|
for: mediaFile,
|
|
source: source,
|
|
password: password
|
|
)
|
|
} else {
|
|
LoggingService.shared.logPlayer("Media source: SMBClient not available, using original URL")
|
|
}
|
|
LoggingService.shared.logPlayer("Media source: Created SMB stream with embedded auth for '\(source.name)' - URL: \(playbackURL.sanitized)")
|
|
} else {
|
|
LoggingService.shared.logPlayer("Media source: SMB source not found for UUID \(sourceUUID)")
|
|
}
|
|
}
|
|
// Local folder
|
|
else {
|
|
LoggingService.shared.logPlayer("Media source: Created local folder stream")
|
|
}
|
|
} else {
|
|
LoggingService.shared.logPlayer("Media source: Could not parse UUID from videoID \(videoID)")
|
|
}
|
|
|
|
// Create stream with placeholder resolution and audio codec so it's recognized as muxed
|
|
// (actual values are unknown until playback, but MPV handles all formats)
|
|
return Stream(
|
|
url: playbackURL,
|
|
resolution: .p720, // Placeholder - actual resolution unknown
|
|
format: url.pathExtension,
|
|
videoCodec: "avc1", // Placeholder to indicate video content
|
|
audioCodec: "aac", // Placeholder to mark as muxed (has audio)
|
|
httpHeaders: headers
|
|
)
|
|
}
|
|
|
|
private func selectStreamAndBackend(from streams: [Stream]) -> (stream: Stream?, audioStream: Stream?, backend: PlayerBackendType) {
|
|
// Try preferred backend first
|
|
if let (stream, audioStream) = selectStreams(for: preferredBackendType, from: streams) {
|
|
return (stream, audioStream, preferredBackendType)
|
|
}
|
|
|
|
// Fallback to any available backend
|
|
for backendType in backendFactory.availableBackends {
|
|
if let (stream, audioStream) = selectStreams(for: backendType, from: streams) {
|
|
return (stream, audioStream, backendType)
|
|
}
|
|
}
|
|
|
|
return (nil, nil, preferredBackendType)
|
|
}
|
|
|
|
/// Selects best streams for preloading (exposed for QueueManager).
|
|
func selectStreamsForPreload(from streams: [Stream]) -> (stream: Stream?, audioStream: Stream?) {
|
|
let result = selectStreamAndBackend(from: streams)
|
|
return (result.stream, result.audioStream)
|
|
}
|
|
|
|
private func selectStreams(for backendType: PlayerBackendType, from streams: [Stream]) -> (video: Stream, audio: Stream?)? {
|
|
let supportedFormats = backendType.supportedFormats
|
|
let dashEnabled = settingsManager?.dashEnabled ?? false
|
|
|
|
// Get user's original quality preference (before network adjustments)
|
|
let userPreferredQuality = settingsManager?.preferredQuality ?? .auto
|
|
|
|
// Network-aware quality selection for fixed-bitrate streams
|
|
let effectiveQuality: VideoQuality
|
|
if let monitor = connectivityMonitor {
|
|
if monitor.isConstrained {
|
|
// Low Data Mode - be very conservative
|
|
effectiveQuality = .sd480p
|
|
} else if monitor.isCellular || monitor.isExpensive {
|
|
// Cellular/expensive - use cellular quality setting
|
|
effectiveQuality = settingsManager?.cellularQuality ?? .hd720p
|
|
} else {
|
|
effectiveQuality = userPreferredQuality
|
|
}
|
|
} else {
|
|
effectiveQuality = userPreferredQuality
|
|
}
|
|
|
|
// Separate streams by type
|
|
let videoOnlyStreams = streams.filter { stream in
|
|
guard !stream.isAudioOnly && stream.isVideoOnly else { return false }
|
|
let format = StreamFormat.detect(from: stream)
|
|
return supportedFormats.contains(format)
|
|
}
|
|
|
|
let muxedStreams = streams.filter { stream in
|
|
let format = StreamFormat.detect(from: stream)
|
|
guard supportedFormats.contains(format) else { return false }
|
|
if format == .dash && !dashEnabled { return false }
|
|
// Only include HLS/DASH if they have video (audio-only HLS/DASH should be treated as audio streams)
|
|
if format == .hls || format == .dash {
|
|
return !stream.isAudioOnly
|
|
}
|
|
return stream.isMuxed
|
|
}
|
|
|
|
let audioStreams = streams.filter { $0.isAudioOnly }
|
|
|
|
// Check for audio-only content (no real video streams available)
|
|
// This handles cases like SoundCloud where HLS is audio-only but not marked as such
|
|
let hasRealVideoStreams = !videoOnlyStreams.isEmpty || muxedStreams.contains { stream in
|
|
// Real video muxed streams have resolution info
|
|
// HLS/DASH without resolution could be audio-only (can't tell without parsing)
|
|
let format = StreamFormat.detect(from: stream)
|
|
if format == .hls || format == .dash {
|
|
return stream.resolution != nil
|
|
}
|
|
return true
|
|
}
|
|
|
|
if !hasRealVideoStreams && !audioStreams.isEmpty {
|
|
LoggingService.shared.debug("Stream selection: Audio-only content detected, using best audio stream", category: .player)
|
|
// Select best audio stream by bitrate
|
|
let bestAudio = audioStreams.sorted { ($0.bitrate ?? 0) > ($1.bitrate ?? 0) }.first!
|
|
return (bestAudio, nil)
|
|
}
|
|
|
|
// Get the maximum resolution based on effective quality preference
|
|
let maxResolution = effectiveQuality.maxResolution
|
|
|
|
// For live streams, always prefer HLS/DASH (designed for live streaming)
|
|
// Live streams with direct MP4 URLs (live=1&hang=1) don't work reliably
|
|
let isLiveStream = streams.contains(where: { $0.isLive })
|
|
if isLiveStream {
|
|
if let hlsStream = muxedStreams.first(where: { StreamFormat.detect(from: $0) == .hls }) {
|
|
LoggingService.shared.debug("Stream selection: Using HLS for live stream", category: .player)
|
|
return (hlsStream, nil)
|
|
}
|
|
if dashEnabled, let dashStream = muxedStreams.first(where: { StreamFormat.detect(from: $0) == .dash }) {
|
|
LoggingService.shared.debug("Stream selection: Using DASH for live stream", category: .player)
|
|
return (dashStream, nil)
|
|
}
|
|
LoggingService.shared.warning("Stream selection: No HLS/DASH available for live stream, falling back to adaptive formats", category: .player)
|
|
}
|
|
|
|
// Note: For non-live videos, we prefer progressive formats (MP4/WebM) over HLS/DASH
|
|
// because they typically offer better quality. HLS/DASH are only used as last resort.
|
|
// Live streams are already handled above with HLS/DASH preference.
|
|
|
|
// Try to find the best video-only stream + audio (for MPV which supports all formats)
|
|
if backendType == .mpv && !videoOnlyStreams.isEmpty && !audioStreams.isEmpty {
|
|
let filteredVideoStreams: [Stream]
|
|
if let maxRes = maxResolution {
|
|
filteredVideoStreams = videoOnlyStreams.filter { stream in
|
|
guard let resolution = stream.resolution else { return true }
|
|
return resolution <= maxRes
|
|
}
|
|
} else {
|
|
filteredVideoStreams = videoOnlyStreams
|
|
}
|
|
|
|
// Filter out codecs with priority 0 (software decode) if hardware options exist
|
|
let hardwareDecodableStreams = filteredVideoStreams.filter { videoCodecPriority($0.videoCodec) > 0 }
|
|
let streamsToConsider = hardwareDecodableStreams.isEmpty ? filteredVideoStreams : hardwareDecodableStreams
|
|
|
|
// Sort by resolution first, then by codec priority
|
|
let sortedVideo = streamsToConsider.sorted { s1, s2 in
|
|
let res1 = s1.resolution ?? .p360
|
|
let res2 = s2.resolution ?? .p360
|
|
if res1 != res2 {
|
|
return res1 > res2
|
|
}
|
|
// Same resolution - prefer better codec
|
|
return videoCodecPriority(s1.videoCodec) > videoCodecPriority(s2.videoCodec)
|
|
}
|
|
|
|
if let bestVideo = sortedVideo.first {
|
|
// Select best audio stream based on preferred language, codec, and bitrate
|
|
let preferredAudioLanguage = settingsManager?.preferredAudioLanguage
|
|
let bestAudio = audioStreams
|
|
.sorted { stream1, stream2 in
|
|
// First priority: preferred language or original audio
|
|
if let preferred = preferredAudioLanguage {
|
|
// User selected a specific language
|
|
let lang1 = stream1.audioLanguage ?? ""
|
|
let lang2 = stream2.audioLanguage ?? ""
|
|
let matches1 = lang1.hasPrefix(preferred)
|
|
let matches2 = lang2.hasPrefix(preferred)
|
|
if matches1 != matches2 { return matches1 }
|
|
} else {
|
|
// No preference set - prefer original audio track
|
|
if stream1.isOriginalAudio != stream2.isOriginalAudio {
|
|
return stream1.isOriginalAudio
|
|
}
|
|
}
|
|
|
|
// Second priority: prefer Opus > AAC for MPV (better quality/compression)
|
|
let codecPriority1 = audioCodecPriority(stream1.audioCodec)
|
|
let codecPriority2 = audioCodecPriority(stream2.audioCodec)
|
|
if codecPriority1 != codecPriority2 {
|
|
return codecPriority1 > codecPriority2
|
|
}
|
|
|
|
// Third priority: higher bitrate
|
|
return (stream1.bitrate ?? 0) > (stream2.bitrate ?? 0)
|
|
}
|
|
.first
|
|
|
|
if let audio = bestAudio {
|
|
return (bestVideo, audio)
|
|
}
|
|
}
|
|
}
|
|
|
|
// Fallback to muxed streams - prefer progressive formats over HLS/DASH for non-live content
|
|
let filteredMuxed: [Stream]
|
|
if let maxRes = maxResolution {
|
|
filteredMuxed = muxedStreams.filter { stream in
|
|
guard let resolution = stream.resolution else { return true }
|
|
return resolution <= maxRes
|
|
}
|
|
} else {
|
|
filteredMuxed = muxedStreams
|
|
}
|
|
|
|
// Sort: prefer non-HLS/DASH (progressive) formats, then by resolution
|
|
let sortedMuxed = filteredMuxed.sorted { s1, s2 in
|
|
let format1 = StreamFormat.detect(from: s1)
|
|
let format2 = StreamFormat.detect(from: s2)
|
|
let isAdaptive1 = format1 == .hls || format1 == .dash
|
|
let isAdaptive2 = format2 == .hls || format2 == .dash
|
|
|
|
// Prefer progressive formats for non-live content
|
|
if isAdaptive1 != isAdaptive2 {
|
|
return !isAdaptive1 // non-adaptive (false) comes first
|
|
}
|
|
return (s1.resolution ?? .p360) > (s2.resolution ?? .p360)
|
|
}
|
|
|
|
if let bestMuxed = sortedMuxed.first {
|
|
return (bestMuxed, nil)
|
|
}
|
|
|
|
// Last resort: any muxed stream (HLS/DASH will be selected here if nothing else available)
|
|
if let anyMuxed = muxedStreams.sorted(by: { ($0.resolution ?? .p360) > ($1.resolution ?? .p360) }).first {
|
|
return (anyMuxed, nil)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
/// Returns codec priority for video streams (higher = better).
|
|
/// Prefers hardware-decodable codecs for battery efficiency.
|
|
private func videoCodecPriority(_ codec: String?) -> Int {
|
|
HardwareCapabilities.shared.codecPriority(for: codec)
|
|
}
|
|
|
|
/// Returns codec priority for audio streams.
|
|
/// Opus and AAC are treated equally - let bitrate decide quality.
|
|
private func audioCodecPriority(_ codec: String?) -> Int {
|
|
guard let codec = codec?.lowercased() else { return 0 }
|
|
if codec.contains("opus") || codec.contains("aac") || codec.contains("mp4a") {
|
|
return 1 // Both are good - bitrate will decide
|
|
}
|
|
return 0
|
|
}
|
|
|
|
private func ensureBackend(type: PlayerBackendType) async throws -> any PlayerBackend {
|
|
// Reuse existing backend if same type
|
|
if let current = currentBackend, current.backendType == type {
|
|
return current
|
|
}
|
|
|
|
// Create new backend
|
|
let backend = try backendFactory.createBackend(type: type)
|
|
backend.delegate = self
|
|
currentBackend = backend
|
|
|
|
// Configure MPV-specific settings
|
|
#if os(iOS) || os(macOS)
|
|
if let mpvBackend = backend as? MPVBackend {
|
|
// Configure PiP callbacks
|
|
if let coordinator = navigationCoordinator {
|
|
mpvBackend.onRestoreFromPiP = { [weak coordinator] in
|
|
// If mini player video is disabled, expand player for restore
|
|
// Otherwise video continues in mini player
|
|
if MiniPlayerSettings.cached.showVideo == false {
|
|
coordinator?.expandPlayer()
|
|
}
|
|
}
|
|
mpvBackend.onPiPDidStart = { [weak coordinator] in
|
|
guard let coordinator else {
|
|
LoggingService.shared.debug("PlayerService: onPiPDidStart - coordinator is nil", category: .player)
|
|
return
|
|
}
|
|
// Collapse the player sheet/window when PiP starts
|
|
LoggingService.shared.debug("PlayerService: onPiPDidStart - isPlayerExpanded=\(coordinator.isPlayerExpanded)", category: .player)
|
|
if coordinator.isPlayerExpanded {
|
|
// Set collapsing first so mini player shows video immediately
|
|
coordinator.isPlayerCollapsing = true
|
|
coordinator.isPlayerExpanded = false
|
|
LoggingService.shared.debug("PlayerService: onPiPDidStart - set isPlayerExpanded to false", category: .player)
|
|
}
|
|
}
|
|
#if os(macOS)
|
|
// Clean up hidden window when PiP is closed via X button (not restore)
|
|
mpvBackend.onPiPDidStopWithoutRestore = {
|
|
LoggingService.shared.debug("PlayerService: onPiPDidStopWithoutRestore - cleaning up hidden window", category: .player)
|
|
ExpandedPlayerWindowManager.shared.cleanupAfterPiP()
|
|
}
|
|
#endif
|
|
}
|
|
}
|
|
#endif
|
|
|
|
return backend
|
|
}
|
|
|
|
private var instancesManager: InstancesManager?
|
|
private weak var mediaSourcesManager: MediaSourcesManager?
|
|
private var smbClient: SMBClient?
|
|
private var webDAVClient: WebDAVClient?
|
|
private var localFileClient: LocalFileClient?
|
|
private var folderFilesCache: [String: [MediaFile]] = [:]
|
|
|
|
/// Cache of pre-downloaded subtitle local file URLs.
|
|
/// Key: subtitle MediaFile.id, Value: local temp URL
|
|
private var downloadedSubtitlesCache: [String: URL] = [:]
|
|
|
|
/// Tracks folders whose subtitles have been pre-downloaded.
|
|
/// Prevents re-downloading when resolving queued videos from the same folder.
|
|
private var preDownloadedSubtitleFolders: Set<String> = []
|
|
|
|
/// Sets the instances manager for finding instances.
|
|
func setInstancesManager(_ manager: InstancesManager) {
|
|
self.instancesManager = manager
|
|
|
|
// Observe instance setting changes (e.g. proxy toggle) to re-apply proxy to cached streams
|
|
instanceChangeObserver = NotificationCenter.default.addObserver(
|
|
forName: .instancesDidChange,
|
|
object: nil,
|
|
queue: .main
|
|
) { [weak self] _ in
|
|
guard let self else { return }
|
|
Task { @MainActor [weak self] in
|
|
await self?.reapplyProxyToStreams()
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Sets the media sources manager for WebDAV/SMB/local folder playback.
|
|
func setMediaSourcesManager(_ manager: MediaSourcesManager) {
|
|
self.mediaSourcesManager = manager
|
|
}
|
|
|
|
/// Sets the SMB client for SMB playback.
|
|
func setSMBClient(_ client: SMBClient) {
|
|
self.smbClient = client
|
|
}
|
|
|
|
/// Sets the WebDAV client for WebDAV playback.
|
|
func setWebDAVClient(_ client: WebDAVClient) {
|
|
self.webDAVClient = client
|
|
}
|
|
|
|
/// Sets the local file client for local folder playback.
|
|
func setLocalFileClient(_ client: LocalFileClient) {
|
|
self.localFileClient = client
|
|
}
|
|
|
|
/// Sets the settings manager for accessing user preferences.
|
|
func setSettingsManager(_ manager: SettingsManager) {
|
|
self.settingsManager = manager
|
|
self.backendSwitcher.settingsManager = manager
|
|
self.nowPlayingService.settingsManager = manager
|
|
// Reconfigure remote commands now that we have settings
|
|
nowPlayingService.configureRemoteCommands()
|
|
// Note: preferredBackendType is now computed directly from settingsManager
|
|
}
|
|
|
|
/// Reconfigures system control buttons (Control Center, Lock Screen) based on current settings.
|
|
/// Call this when system controls settings change.
|
|
func reconfigureSystemControls(
|
|
mode: SystemControlsMode? = nil,
|
|
duration: SystemControlsSeekDuration? = nil
|
|
) {
|
|
nowPlayingService.configureRemoteCommands(mode: mode, duration: duration)
|
|
}
|
|
|
|
/// Observer for instance setting changes to re-apply proxy to cached streams.
|
|
private var instanceChangeObserver: NSObjectProtocol?
|
|
|
|
/// Observer for preset changes to reconfigure system controls.
|
|
private var presetChangeObserver: NSObjectProtocol?
|
|
|
|
/// Sets the player controls layout service for reading preset-specific settings.
|
|
func setPlayerControlsLayoutService(_ service: PlayerControlsLayoutService) {
|
|
self.playerControlsLayoutService = service
|
|
nowPlayingService.playerControlsLayoutService = service
|
|
|
|
// Reconfigure with the actual saved settings now that layout service is available
|
|
nowPlayingService.configureRemoteCommands()
|
|
|
|
// Observe preset changes to reconfigure system controls
|
|
presetChangeObserver = NotificationCenter.default.addObserver(
|
|
forName: .playerControlsActivePresetDidChange,
|
|
object: nil,
|
|
queue: .main
|
|
) { [weak self] _ in
|
|
guard let self else { return }
|
|
Task { @MainActor [weak self] in
|
|
self?.reconfigureSystemControls()
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Sets the download manager for checking downloaded videos.
|
|
func setDownloadManager(_ manager: DownloadManager) {
|
|
self.downloadManager = manager
|
|
}
|
|
|
|
/// Sets the DeArrow branding provider for enhanced titles.
|
|
func setDeArrowBrandingProvider(_ provider: DeArrowBrandingProvider) {
|
|
nowPlayingService.deArrowBrandingProvider = provider
|
|
}
|
|
|
|
/// Sets the connectivity monitor for network-aware quality selection.
|
|
func setConnectivityMonitor(_ monitor: ConnectivityMonitor) {
|
|
self.connectivityMonitor = monitor
|
|
}
|
|
|
|
/// Sets the toast manager for displaying notifications.
|
|
func setToastManager(_ manager: ToastManager) {
|
|
self.toastManager = manager
|
|
}
|
|
|
|
/// Sets the queue manager for queue operations.
|
|
func setQueueManager(_ manager: QueueManager) {
|
|
self.queueManager = manager
|
|
}
|
|
|
|
/// Sets the navigation coordinator for waiting on sheet animations.
|
|
func setNavigationCoordinator(_ coordinator: NavigationCoordinator) {
|
|
self.navigationCoordinator = coordinator
|
|
|
|
#if os(iOS)
|
|
// Configure PiP restore callback - don't expand player, just let PiP close
|
|
// Video continues in mini player; user taps mini player to expand
|
|
if let mpvBackend = currentBackend as? MPVBackend {
|
|
mpvBackend.onRestoreFromPiP = { [weak coordinator] in
|
|
// If mini player video is disabled, expand player for restore
|
|
// Otherwise video continues in mini player
|
|
if MiniPlayerSettings.cached.showVideo == false {
|
|
coordinator?.expandPlayer()
|
|
}
|
|
}
|
|
}
|
|
#endif
|
|
}
|
|
|
|
/// Sets the handoff manager for activity updates.
|
|
func setHandoffManager(_ manager: HandoffManager) {
|
|
self.handoffManager = manager
|
|
}
|
|
|
|
#if os(iOS)
|
|
/// Called when the player sheet appears.
|
|
func playerSheetDidAppear() {
|
|
let backendType = currentBackend?.backendType.rawValue ?? "none"
|
|
let playbackState = state.playbackState
|
|
LoggingService.shared.debug("PlayerService: playerSheetDidAppear - backend=\(backendType), playbackState=\(playbackState)", category: .player)
|
|
|
|
// Re-enable visual tracks and reattach layer when sheet appears
|
|
let backgroundEnabled = settingsManager?.backgroundPlaybackEnabled ?? true
|
|
let isPiPActive = (currentBackend as? MPVBackend)?.isPiPActive ?? false
|
|
LoggingService.shared.debug("PlayerService: sheetDidAppear checks - backgroundEnabled=\(backgroundEnabled), isPiPActive=\(isPiPActive)", category: .player)
|
|
|
|
guard backgroundEnabled && !isPiPActive else {
|
|
LoggingService.shared.debug("PlayerService: skipping visibility handling (backgroundEnabled=\(backgroundEnabled), isPiPActive=\(isPiPActive))", category: .player)
|
|
return
|
|
}
|
|
|
|
if let mpvBackend = currentBackend as? MPVBackend {
|
|
LoggingService.shared.debug("Player sheet appeared - re-enabling video (MPV)", category: .player)
|
|
mpvBackend.handlePlayerSheetVisibility(isVisible: true)
|
|
}
|
|
}
|
|
|
|
/// Called when the player sheet disappears.
|
|
func playerSheetDidDisappear() {
|
|
let backendType = currentBackend?.backendType.rawValue ?? "none"
|
|
let playbackState = state.playbackState
|
|
LoggingService.shared.debug("PlayerService: playerSheetDidDisappear - backend=\(backendType), playbackState=\(playbackState)", category: .player)
|
|
|
|
// Disable visual tracks and detach layer for background audio playback
|
|
let backgroundEnabled = settingsManager?.backgroundPlaybackEnabled ?? true
|
|
let mpvPiPActive = (currentBackend as? MPVBackend)?.isPiPActive ?? false
|
|
LoggingService.shared.debug("PlayerService: sheetDidDisappear checks - backgroundEnabled=\(backgroundEnabled), mpvPiPActive=\(mpvPiPActive)", category: .player)
|
|
|
|
guard backgroundEnabled && !mpvPiPActive else {
|
|
LoggingService.shared.debug("PlayerService: skipping visibility handling on disappear (backgroundEnabled=\(backgroundEnabled), mpvPiP=\(mpvPiPActive))", category: .player)
|
|
return
|
|
}
|
|
|
|
// Check if mini player video should be visible
|
|
// If so, don't pause rendering - mini player will manage rendering state
|
|
let miniPlayerVideoEnabled = MiniPlayerSettings.cached.showVideo
|
|
let isAudioOnly = state.currentStream?.isAudioOnly == true
|
|
let shouldMiniPlayerShowVideo = miniPlayerVideoEnabled && !isAudioOnly
|
|
|
|
if shouldMiniPlayerShowVideo {
|
|
LoggingService.shared.debug("PlayerService: skipping pause - mini player will show video (miniPlayerVideoEnabled=\(miniPlayerVideoEnabled), isAudioOnly=\(isAudioOnly))", category: .player)
|
|
return
|
|
}
|
|
|
|
if let mpvBackend = currentBackend as? MPVBackend {
|
|
LoggingService.shared.debug("Player sheet dismissed - disabling video for background playback (MPV)", category: .player)
|
|
mpvBackend.handlePlayerSheetVisibility(isVisible: false)
|
|
}
|
|
}
|
|
#endif
|
|
|
|
private func findInstance(for video: Video) async throws -> Instance? {
|
|
guard let instancesManager else { return nil }
|
|
return instancesManager.instance(for: video)
|
|
}
|
|
|
|
private func fetchSponsorBlockSegments(for videoID: String) async {
|
|
// Update SponsorBlock API URL from settings
|
|
if let urlString = settingsManager?.sponsorBlockAPIURL,
|
|
let url = URL(string: urlString) {
|
|
await sponsorBlockAPI.setBaseURL(url)
|
|
}
|
|
|
|
do {
|
|
let segments = try await sponsorBlockAPI.segments(for: videoID)
|
|
state.sponsorSegments = segments
|
|
} catch {
|
|
LoggingService.shared.logPlayerError("SponsorBlock fetch failed", error: error)
|
|
}
|
|
}
|
|
|
|
private func fetchReturnYouTubeDislikeCounts(for videoID: String) async {
|
|
guard settingsManager?.returnYouTubeDislikeEnabled == true else { return }
|
|
|
|
do {
|
|
let votes = try await returnYouTubeDislikeAPI.votes(for: videoID)
|
|
state.dislikeCount = votes.dislikes
|
|
// Notify observers that video details updated
|
|
NotificationCenter.default.post(name: .videoDetailsDidLoad, object: nil)
|
|
} catch {
|
|
LoggingService.shared.logPlayerError("Return YouTube Dislike fetch failed", error: error)
|
|
}
|
|
}
|
|
|
|
/// Fetches full video details from API and updates state.
|
|
/// Used for downloaded videos that may not have full metadata stored.
|
|
private func fetchAndUpdateVideoDetails(for video: Video) async {
|
|
guard let instance = try? await findInstance(for: video) else { return }
|
|
|
|
do {
|
|
let fullVideo = try await contentService.video(id: video.id.videoID, instance: instance)
|
|
|
|
// Only update if this is still the current video
|
|
guard state.currentVideo?.id == video.id else { return }
|
|
|
|
// Update state with full video (preserving current stream)
|
|
state.setCurrentVideo(fullVideo, stream: state.currentStream, audioStream: state.currentAudioStream)
|
|
NotificationCenter.default.post(name: .videoDetailsDidLoad, object: nil)
|
|
|
|
// Also fetch dislike count if enabled
|
|
if case .global = video.id.source {
|
|
await fetchReturnYouTubeDislikeCounts(for: video.id.videoID)
|
|
}
|
|
} catch {
|
|
// Silently fail - offline playback continues with stored metadata
|
|
}
|
|
}
|
|
|
|
private func startProgressSaveTimer() {
|
|
progressSaveTimer?.invalidate()
|
|
progressSaveTimer = Timer.scheduledTimer(withTimeInterval: progressSaveInterval, repeats: true) { [weak self] _ in
|
|
Task { @MainActor [weak self] in
|
|
self?.saveProgress()
|
|
}
|
|
}
|
|
}
|
|
|
|
private func saveProgress() {
|
|
guard settingsManager?.incognitoModeEnabled != true,
|
|
settingsManager?.saveWatchHistory != false else { return }
|
|
|
|
guard let video = state.currentVideo,
|
|
state.currentTime > 0 else { return }
|
|
|
|
// Save locally only during playback - no iCloud sync overhead
|
|
dataManager.updateWatchProgressLocal(for: video, seconds: state.currentTime, duration: state.duration)
|
|
|
|
// Update Handoff activity with current playback time
|
|
handoffManager?.updatePlaybackTime(state.currentTime)
|
|
}
|
|
|
|
/// Saves progress as 100% completed when video finishes naturally.
|
|
/// This ensures the watch history shows full completion instead of ~99%.
|
|
private func saveProgressAsCompleted() {
|
|
guard settingsManager?.incognitoModeEnabled != true,
|
|
settingsManager?.saveWatchHistory != false,
|
|
let video = state.currentVideo else { return }
|
|
|
|
// Use video.duration (API-reported) to match WatchEntry.duration stored value.
|
|
// This ensures 100% progress since WatchEntry.progress = watchedSeconds / WatchEntry.duration.
|
|
// Fall back to state.duration (MPV-reported) if video.duration is not available.
|
|
let completedDuration = video.duration > 0 ? video.duration : state.duration
|
|
guard completedDuration > 0 else { return }
|
|
|
|
// Save the full duration as watched time when video completes
|
|
dataManager.updateWatchProgressLocal(for: video, seconds: completedDuration, duration: completedDuration)
|
|
|
|
// Update Handoff activity with completed time
|
|
handoffManager?.updatePlaybackTime(completedDuration)
|
|
}
|
|
|
|
/// Saves progress and triggers iCloud sync for watch history.
|
|
/// Call this when video playback ends or switches to a different video.
|
|
private func saveProgressAndSync() {
|
|
guard settingsManager?.incognitoModeEnabled != true,
|
|
settingsManager?.saveWatchHistory != false else { return }
|
|
|
|
guard let video = state.currentVideo,
|
|
state.currentTime > 0 else { return }
|
|
|
|
// Save and queue for iCloud sync (used when video closes/switches)
|
|
dataManager.updateWatchProgress(for: video, seconds: state.currentTime, duration: state.duration)
|
|
NotificationCenter.default.post(name: .watchHistoryDidChange, object: nil)
|
|
}
|
|
|
|
private func checkSponsorBlockSegments(at time: Double) {
|
|
// Read settings - use defaults if settings manager not available
|
|
let enabled = settingsManager?.sponsorBlockEnabled ?? true
|
|
let enabledCategories = settingsManager?.sponsorBlockCategories ?? SponsorBlockCategory.defaultEnabled
|
|
|
|
guard enabled else { return }
|
|
|
|
// Find segment at current time that matches enabled categories
|
|
let applicableSegments = state.sponsorSegments.skippable().inCategories(enabledCategories)
|
|
if let segment = applicableSegments.segment(at: time) {
|
|
// Auto-skip all enabled categories
|
|
// Prevent duplicate skips for the same segment
|
|
guard lastSkippedSegmentID != segment.id else { return }
|
|
|
|
if delegate?.playerService(self, shouldSkipSegment: segment) ?? true {
|
|
lastSkippedSegmentID = segment.id
|
|
Task {
|
|
await skipSegment(segment)
|
|
}
|
|
}
|
|
} else {
|
|
state.currentSegment = nil
|
|
// Clear last skipped when not in any segment (allows re-skip if user seeks back)
|
|
lastSkippedSegmentID = nil
|
|
}
|
|
}
|
|
|
|
/// Skips a specific segment by seeking past it.
|
|
private func skipSegment(_ segment: SponsorBlockSegment) async {
|
|
let skipTarget = segment.endTime + 0.1
|
|
|
|
// Don't skip if target is past video duration - this would cause an infinite loop
|
|
// since we'd seek to end, still be "in" the segment, and skip again
|
|
guard state.duration > 0, skipTarget < state.duration else {
|
|
LoggingService.shared.logPlayer("SponsorBlock: not skipping \(segment.category.rawValue) segment (extends past video end)", details: "\(segment.startTime)s - \(segment.endTime)s, duration: \(state.duration)s")
|
|
return
|
|
}
|
|
|
|
LoggingService.shared.logPlayer("SponsorBlock: skipping \(segment.category.rawValue) segment", details: "\(segment.startTime)s - \(segment.endTime)s")
|
|
// Show loading for early skips (intro, early sponsors) to avoid brief video flash
|
|
// But only if we haven't already shown video (still in loading state)
|
|
let isEarlySkip = segment.startTime < 30
|
|
let isStillLoading = state.playbackState == .loading
|
|
await seek(to: skipTarget, showLoading: isEarlySkip && isStillLoading)
|
|
}
|
|
|
|
private func cleanup() {
|
|
// Clean up temp subtitle files for current video
|
|
if let currentVideo = state.currentVideo {
|
|
cleanupTempSubtitles(for: currentVideo.id.id)
|
|
}
|
|
|
|
progressSaveTimer?.invalidate()
|
|
progressSaveTimer = nil
|
|
|
|
// Remove audio session interruption observer
|
|
#if os(iOS) || os(tvOS)
|
|
NotificationCenter.default.removeObserver(self, name: AVAudioSession.interruptionNotification, object: nil)
|
|
hasRegisteredInterruptionObserver = false
|
|
#endif
|
|
|
|
// Cancel any ongoing play task
|
|
currentPlayTask?.cancel()
|
|
currentPlayTask = nil
|
|
|
|
currentBackend?.stop()
|
|
// Keep backend alive for reuse - prevents race condition where old backend's
|
|
// delayed deinit destroys render context that new backend is using
|
|
// currentBackend = nil
|
|
}
|
|
|
|
/// Cleans up temporary subtitle files for a given video.
|
|
/// Call this when closing/stopping a video.
|
|
/// - Parameter videoID: The video ID whose subtitles to clean up.
|
|
private func cleanupTempSubtitles(for videoID: String) {
|
|
let tempDir = FileManager.default.temporaryDirectory
|
|
.appendingPathComponent("yattee-subtitles", isDirectory: true)
|
|
.appendingPathComponent(videoID, isDirectory: true)
|
|
|
|
do {
|
|
if FileManager.default.fileExists(atPath: tempDir.path) {
|
|
try FileManager.default.removeItem(at: tempDir)
|
|
LoggingService.shared.debug("Cleaned up temp subtitles for: \(videoID)", category: .player)
|
|
}
|
|
} catch {
|
|
LoggingService.shared.error(
|
|
"Failed to clean up temp subtitles for \(videoID): \(error.localizedDescription)",
|
|
category: .player
|
|
)
|
|
}
|
|
}
|
|
|
|
/// Cleans up all temporary subtitle files (pre-downloaded and per-video).
|
|
/// Called when stopping playback entirely.
|
|
private func cleanupAllTempSubtitles() {
|
|
let tempDir = FileManager.default.temporaryDirectory
|
|
.appendingPathComponent("yattee-subtitles", isDirectory: true)
|
|
|
|
do {
|
|
if FileManager.default.fileExists(atPath: tempDir.path) {
|
|
try FileManager.default.removeItem(at: tempDir)
|
|
LoggingService.shared.debug("Cleaned up all temp subtitles", category: .player)
|
|
}
|
|
} catch {
|
|
LoggingService.shared.error(
|
|
"Failed to clean up all temp subtitles: \(error.localizedDescription)",
|
|
category: .player
|
|
)
|
|
}
|
|
}
|
|
|
|
// MARK: - Chapter Resolution
|
|
|
|
/// Resolves chapters for the video using source hierarchy.
|
|
/// Priority: SponsorBlock chapters > description parsing
|
|
/// - Parameter video: The video to resolve chapters for.
|
|
private func resolveChapters(for video: Video) {
|
|
let videoDuration = video.duration > 0 ? video.duration : state.duration
|
|
guard videoDuration > 0 else {
|
|
state.chapters = []
|
|
return
|
|
}
|
|
|
|
// 1. Try SponsorBlock chapters (if enabled and segments available)
|
|
let sponsorBlockEnabled = settingsManager?.sponsorBlockEnabled ?? true
|
|
if sponsorBlockEnabled {
|
|
let chapters = state.sponsorSegments.extractChapters(videoDuration: videoDuration)
|
|
if chapters.count >= 2 {
|
|
state.chapters = chapters
|
|
LoggingService.shared.logPlayer("Chapters: \(chapters.count) from SponsorBlock")
|
|
return
|
|
}
|
|
}
|
|
|
|
// 2. Fall back to description parsing
|
|
if let description = video.description {
|
|
let introTitle = String(localized: "player.chapters.intro")
|
|
let chapters = ChapterParser.parse(
|
|
description: description,
|
|
videoDuration: videoDuration,
|
|
introTitle: introTitle
|
|
)
|
|
state.chapters = chapters
|
|
if !chapters.isEmpty {
|
|
LoggingService.shared.logPlayer("Chapters: \(chapters.count) from description")
|
|
}
|
|
} else {
|
|
state.chapters = []
|
|
}
|
|
}
|
|
}
|
|
|
|
// MARK: - PlayerBackendDelegate
|
|
|
|
extension PlayerService: PlayerBackendDelegate {
|
|
func backend(_ backend: any PlayerBackend, didUpdateTime time: TimeInterval) {
|
|
// Ignore time updates while loading a new video - these are stale updates from the previous video
|
|
guard loadingVideoID == nil else { return }
|
|
|
|
state.currentTime = time
|
|
delegate?.playerService(self, didUpdateTime: time)
|
|
checkSponsorBlockSegments(at: time)
|
|
|
|
// Update Now Playing time periodically (every ~5 seconds to avoid excessive updates)
|
|
let shouldUpdateNowPlaying = Int(time) % 5 == 0
|
|
if shouldUpdateNowPlaying {
|
|
nowPlayingService.updatePlaybackTime(
|
|
currentTime: time,
|
|
duration: state.duration,
|
|
isPlaying: state.playbackState == .playing
|
|
)
|
|
}
|
|
}
|
|
|
|
func backend(_ backend: any PlayerBackend, didUpdateDuration duration: TimeInterval) {
|
|
// Skip duration updates when locked from API (fast endpoint streams where
|
|
// file is progressively downloaded and MPV can't determine accurate duration)
|
|
guard !state.isDurationLockedFromAPI else { return }
|
|
state.duration = duration
|
|
}
|
|
|
|
func backend(_ backend: any PlayerBackend, didChangeState playbackState: PlaybackState) {
|
|
LoggingService.shared.debug("Backend state changed to: \(playbackState)", category: .player)
|
|
state.setPlaybackState(playbackState)
|
|
delegate?.playerService(self, didChangeState: playbackState)
|
|
}
|
|
|
|
func backend(_ backend: any PlayerBackend, didUpdateBufferedTime time: TimeInterval) {
|
|
state.bufferedTime = time
|
|
}
|
|
|
|
func backend(_ backend: any PlayerBackend, didUpdateBufferProgress progress: Int) {
|
|
// Only update during initial buffering (before buffer is ready)
|
|
// Skip buffer progress for local files - they load quickly and don't need buffering indicator
|
|
let isLocalFile = state.currentStream?.url.isFileURL == true
|
|
if !state.isBufferReady && !isLocalFile {
|
|
state.bufferProgress = progress
|
|
}
|
|
}
|
|
|
|
func backend(_ backend: any PlayerBackend, didEncounterError error: Error) {
|
|
delegate?.playerService(self, didEncounterError: error)
|
|
}
|
|
|
|
func backend(_ backend: any PlayerBackend, didUpdateVideoSize width: Int, height: Int) {
|
|
guard width > 0, height > 0 else { return }
|
|
let aspectRatio = Double(width) / Double(height)
|
|
|
|
state.videoAspectRatio = aspectRatio
|
|
LoggingService.shared.debug("Video aspect ratio updated: \(aspectRatio) (\(width)x\(height))", category: .player)
|
|
}
|
|
|
|
func backend(_ backend: any PlayerBackend, didUpdateRetryState currentRetry: Int, maxRetries: Int, isRetrying: Bool, exhausted: Bool) {
|
|
let retryState = RetryState(
|
|
currentRetry: currentRetry,
|
|
maxRetries: maxRetries,
|
|
isRetrying: isRetrying,
|
|
exhausted: exhausted
|
|
)
|
|
state.setRetryState(retryState)
|
|
}
|
|
|
|
func backend(_ backend: any PlayerBackend, didRequestStreamRefresh atTime: TimeInterval?) {
|
|
LoggingService.shared.logPlayer("Stream refresh requested at time: \(atTime ?? -1)")
|
|
|
|
Task {
|
|
await refreshStreamsAndResume(atTime: atTime)
|
|
}
|
|
}
|
|
|
|
func backendDidBecomeReady(_ backend: any PlayerBackend) {
|
|
// First frame of new video has been rendered - thumbnail can now hide
|
|
state.isFirstFrameReady = true
|
|
}
|
|
|
|
func backendDidFinishPlaying(_ backend: any PlayerBackend) {
|
|
// Mark that video ended naturally - prevents play() from saving progress again
|
|
// when switching to next video (100% is already saved below)
|
|
videoEndedNaturally = true
|
|
|
|
// Stop the progress save timer BEFORE saving completion
|
|
// Otherwise the timer can overwrite our 100% with a lower value
|
|
progressSaveTimer?.invalidate()
|
|
progressSaveTimer = nil
|
|
|
|
state.setPlaybackState(.ended)
|
|
saveProgressAsCompleted()
|
|
delegate?.playerServiceDidFinishPlaying(self)
|
|
|
|
// Auto-play immediately if player is not visible (no point showing countdown)
|
|
// UI (ExpandedPlayerSheet) will handle countdown when player is visible
|
|
let autoPlayEnabled = settingsManager?.queueAutoPlayNext ?? true
|
|
let hasNextInQueue = !state.queue.isEmpty
|
|
|
|
if autoPlayEnabled && hasNextInQueue {
|
|
let isSheetCollapsed = navigationCoordinator?.isPlayerExpanded != true
|
|
let isInBackground = currentScenePhase == .background
|
|
let isPiPActive = state.pipState == .active
|
|
|
|
if isSheetCollapsed || isInBackground || isPiPActive {
|
|
Task {
|
|
await playNext()
|
|
}
|
|
} else {
|
|
// UI will handle countdown - allow sleep while waiting
|
|
// (preventSleep will be called again when next video starts)
|
|
sleepPreventionService.allowSleep()
|
|
}
|
|
} else {
|
|
// No auto-play will happen - allow sleep
|
|
sleepPreventionService.allowSleep()
|
|
}
|
|
}
|
|
|
|
/// Called when a video starts playing to trigger proactive continuation loading.
|
|
func notifyVideoStarted() {
|
|
queueManager?.onVideoStarted()
|
|
}
|
|
|
|
/// Handles playback errors by auto-skipping to next video if available and player is not visible.
|
|
/// When player is visible, user can manually tap "Play Next" button on the error overlay.
|
|
private func handlePlaybackErrorAutoSkip() async {
|
|
let autoPlayEnabled = settingsManager?.queueAutoPlayNext ?? true
|
|
let hasNextInQueue = !state.queue.isEmpty
|
|
|
|
guard autoPlayEnabled && hasNextInQueue else { return }
|
|
|
|
let isSheetCollapsed = navigationCoordinator?.isPlayerExpanded != true
|
|
let isInBackground = currentScenePhase == .background
|
|
let isPiPActive = state.pipState == .active
|
|
|
|
// Skip immediately if player is NOT visible to user (no point showing error screen)
|
|
if isSheetCollapsed || isInBackground || isPiPActive {
|
|
// Show toast notification
|
|
toastManager?.showInfo(String(localized: "player.error.skippingToNext.title"))
|
|
|
|
await playNext()
|
|
}
|
|
}
|
|
}
|