diff --git a/Model/Import Export Settings/Exporters/AdvancedSettingsGroupExporter.swift b/Model/Import Export Settings/Exporters/AdvancedSettingsGroupExporter.swift index 89cffad2..ac94a101 100644 --- a/Model/Import Export Settings/Exporters/AdvancedSettingsGroupExporter.swift +++ b/Model/Import Export Settings/Exporters/AdvancedSettingsGroupExporter.swift @@ -6,6 +6,7 @@ final class AdvancedSettingsGroupExporter: SettingsGroupExporter { [ "showPlayNowInBackendContextMenu": Defaults[.showPlayNowInBackendContextMenu], "videoLoadingRetryCount": Defaults[.videoLoadingRetryCount], + "avPlayerAllowsNonStreamableFormats": Defaults[.avPlayerAllowsNonStreamableFormats], "showMPVPlaybackStats": Defaults[.showMPVPlaybackStats], "mpvEnableLogging": Defaults[.mpvEnableLogging], "mpvCacheSecs": Defaults[.mpvCacheSecs], diff --git a/Model/Import Export Settings/Importers/AdvancedSettingsGroupImporter.swift b/Model/Import Export Settings/Importers/AdvancedSettingsGroupImporter.swift index 6c22a3f8..467b671c 100644 --- a/Model/Import Export Settings/Importers/AdvancedSettingsGroupImporter.swift +++ b/Model/Import Export Settings/Importers/AdvancedSettingsGroupImporter.swift @@ -13,6 +13,10 @@ struct AdvancedSettingsGroupImporter { Defaults[.videoLoadingRetryCount] = videoLoadingRetryCount } + if let avPlayerAllowsNonStreamableFormats = json["avPlayerAllowsNonStreamableFormats"].bool { + Defaults[.avPlayerAllowsNonStreamableFormats] = avPlayerAllowsNonStreamableFormats + } + if let showMPVPlaybackStats = json["showMPVPlaybackStats"].bool { Defaults[.showMPVPlaybackStats] = showMPVPlaybackStats } diff --git a/Model/Player/Backends/AVPlayerBackend.swift b/Model/Player/Backends/AVPlayerBackend.swift index 2b2c1df0..2548ad1c 100644 --- a/Model/Player/Backends/AVPlayerBackend.swift +++ b/Model/Player/Backends/AVPlayerBackend.swift @@ -11,6 +11,8 @@ import SwiftUI final class AVPlayerBackend: PlayerBackend { static let assetKeysToLoad = ["tracks", "playable", "duration"] + @Default(.avPlayerAllowsNonStreamableFormats) private var allowsNonStreamableFormats + private var logger = Logger(label: "avplayer-backend") var model: PlayerModel { .shared } @@ -150,7 +152,30 @@ final class AVPlayerBackend: PlayerBackend { } func canPlay(_ stream: Stream) -> Bool { - stream.kind == .hls || stream.kind == .stream + // AVPlayer has a fundamental limitation with MP4/AVC1 progressive downloads: + // If the moov atom is at the end of the file (common case), it must download + // the entire file before playback can start. MPV doesn't have this limitation. + // By default, reject non-HLS MP4/AVC1 streams unless user explicitly enables them. + + // Check if this is a non-streamable format (MP4/AVC1) that isn't HLS + let isNonStreamableFormat = stream.kind != .hls && (stream.format == .mp4 || stream.format == .avc1) + + if isNonStreamableFormat && !allowsNonStreamableFormats { + return false + } + + // If non-streamable formats are enabled, allow MP4/AVC1 adaptive streams + // but limit to 1080p maximum (higher resolutions can't be played properly) + if isNonStreamableFormat && allowsNonStreamableFormats { + let maxHeight = 1080 + if let resolution = stream.resolution, resolution.height > maxHeight { + return false + } + return true + } + + // AVPlayer works well with HLS and stream formats + return stream.kind == .hls || stream.kind == .stream } func playStream( @@ -304,12 +329,12 @@ final class AVPlayerBackend: PlayerBackend { preservingTime: Bool = false, model: PlayerModel ) { + model.logger.info("loading \(type.rawValue) track") + asset.loadValuesAsynchronously(forKeys: Self.assetKeysToLoad) { [weak self] in guard let self else { return } - model.logger.info("loading \(type.rawValue) track") - let assetTracks = asset.tracks(withMediaType: type) guard let compositionTrack = self.composition.addMutableTrack( diff --git a/Shared/Defaults.swift b/Shared/Defaults.swift index a33ee284..79905aa4 100644 --- a/Shared/Defaults.swift +++ b/Shared/Defaults.swift @@ -192,7 +192,8 @@ extension Defaults.Keys { hd1080p60MPVProfile, hd1080pMPVProfile, hd720p60MPVProfile, - hd720pMPVProfile + hd720pMPVProfile, + hd720pAVPlayerProfile ] static let batteryCellularProfileDefault = hd720pMPVProfile.id @@ -208,7 +209,8 @@ extension Defaults.Keys { hd1080pMPVProfile, hd720p60MPVProfile, hd720pMPVProfile, - sd360pMPVProfile + sd360pMPVProfile, + hd720pAVPlayerProfile ] static let batteryCellularProfileDefault = sd360pMPVProfile.id @@ -361,6 +363,7 @@ extension Defaults.Keys { static let showPlayNowInBackendContextMenu = Key("showPlayNowInBackendContextMenu", default: false) static let videoLoadingRetryCount = Key("videoLoadingRetryCount", default: 10) + static let avPlayerAllowsNonStreamableFormats = Key("avPlayerAllowsNonStreamableFormats", default: false) static let showMPVPlaybackStats = Key("showMPVPlaybackStats", default: false) static let mpvEnableLogging = Key("mpvEnableLogging", default: false) diff --git a/Shared/Settings/AdvancedSettings.swift b/Shared/Settings/AdvancedSettings.swift index 6d67ecb6..c199019d 100644 --- a/Shared/Settings/AdvancedSettings.swift +++ b/Shared/Settings/AdvancedSettings.swift @@ -16,10 +16,12 @@ struct AdvancedSettings: View { @Default(.feedCacheSize) private var feedCacheSize @Default(.showPlayNowInBackendContextMenu) private var showPlayNowInBackendContextMenu @Default(.videoLoadingRetryCount) private var videoLoadingRetryCount + @Default(.avPlayerAllowsNonStreamableFormats) private var avPlayerAllowsNonStreamableFormats @State private var filesToShare = [MPVClient.logFile] @State private var presentingShareSheet = false + @ObservedObject private var player = PlayerModel.shared private var settings = SettingsModel.shared var body: some View { @@ -73,6 +75,10 @@ struct AdvancedSettings: View { videoLoadingRetryCountField } + Section(header: SettingsHeader(text: "AVPlayer"), footer: avPlayerNonStreamableFormatsFooter) { + avPlayerAllowsNonStreamableFormatsToggle + } + Section(header: SettingsHeader(text: "MPV"), footer: mpvFooter) { showMPVPlaybackStatsToggle #if !os(tvOS) @@ -370,6 +376,22 @@ struct AdvancedSettings: View { Text(String(format: "Total size: %@".localized(), BaseCacheModel.shared.totalSizeFormatted)) .foregroundColor(.secondary) } + + var avPlayerAllowsNonStreamableFormatsToggle: some View { + Toggle("Enable non-streamable formats (MP4/AVC1)", isOn: $avPlayerAllowsNonStreamableFormats) + .onChange(of: avPlayerAllowsNonStreamableFormats) { _ in + // Trigger refresh of available streams when setting changes + if let video = player.currentVideo { + player.loadAvailableStreams(video) + } + } + } + + @ViewBuilder var avPlayerNonStreamableFormatsFooter: some View { + Text("Non-streamable video formats (MP4/AVC1) may take a long time to start playback with AVPlayer. These formats require downloading metadata before playback can begin. Limited to 1080p maximum. For better performance with these formats, use MPV backend instead.") + .foregroundColor(.secondary) + .fixedSize(horizontal: false, vertical: true) + } } struct AdvancedSettings_Previews: PreviewProvider { diff --git a/Shared/Settings/SettingsView.swift b/Shared/Settings/SettingsView.swift index c1d0db0c..48801a53 100644 --- a/Shared/Settings/SettingsView.swift +++ b/Shared/Settings/SettingsView.swift @@ -360,7 +360,7 @@ struct SettingsView: View { case .locations: return 600 case .advanced: - return 630 + return 700 case .importExport: return 580 case .help: