From 33175acac04847584cad36f3c46c2c1e1c25fb3a Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Wed, 29 Jul 2026 16:58:37 +0200 Subject: [PATCH 1/3] feat: add VLC playback options for videos Allow switching individual videos to VLC and persisting that preference. Improve VLC replay and cleanup behavior. Signed-off-by: Marino Faggiana --- .../Vlc-Logo.imageset/Contents.json | 12 ++ .../Vlc-Logo.imageset/Vlc-Logo.svg | 10 ++ iOSClient/Settings/NCPreferences.swift | 31 ++++ .../en.lproj/Localizable.strings | 2 + .../Video/NCVideoPlaybackController.swift | 32 +++- .../Video/VLC/NCVideoVLCPresenter.swift | 3 + .../Video/VLC/NCVideoVLCViewController.swift | 57 ++++++- .../Video/VLC/NCVideoVLCViewControls.swift | 4 + .../NCMediaViewerHostingController.swift | 148 +++++++++++++++--- 9 files changed, 270 insertions(+), 29 deletions(-) create mode 100644 iOSClient/Images.xcassets/Vlc-Logo.imageset/Contents.json create mode 100644 iOSClient/Images.xcassets/Vlc-Logo.imageset/Vlc-Logo.svg diff --git a/iOSClient/Images.xcassets/Vlc-Logo.imageset/Contents.json b/iOSClient/Images.xcassets/Vlc-Logo.imageset/Contents.json new file mode 100644 index 0000000000..4981181699 --- /dev/null +++ b/iOSClient/Images.xcassets/Vlc-Logo.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "Vlc-Logo.svg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/iOSClient/Images.xcassets/Vlc-Logo.imageset/Vlc-Logo.svg b/iOSClient/Images.xcassets/Vlc-Logo.imageset/Vlc-Logo.svg new file mode 100644 index 0000000000..8f4587979c --- /dev/null +++ b/iOSClient/Images.xcassets/Vlc-Logo.imageset/Vlc-Logo.svg @@ -0,0 +1,10 @@ + + + Vlc Logo Streamline Icon: https://streamlinehq.com + + + + + + + \ No newline at end of file diff --git a/iOSClient/Settings/NCPreferences.swift b/iOSClient/Settings/NCPreferences.swift index badd971c0b..df114bfd77 100644 --- a/iOSClient/Settings/NCPreferences.swift +++ b/iOSClient/Settings/NCPreferences.swift @@ -604,6 +604,37 @@ final class NCPreferences: NSObject { setUserDefaults(weekString, forKey: "cleaningWeek") } + // MARK: - Video + + func alwaysUseVLCForVideo(account: String, ocId: String) -> Bool { + let key = alwaysUseVLCForVideoKey( + account: account, + ocId: ocId + ) + + return UserDefaults.standard.object(forKey: key) as? Bool == true + } + + func setAlwaysUseVLCForVideo(_ value: Bool, account: String, ocId: String) { + let key = alwaysUseVLCForVideoKey( + account: account, + ocId: ocId + ) + + if value { + UserDefaults.standard.set(true, forKey: key) + } else { + UserDefaults.standard.removeObject(forKey: key) + } + } + + private func alwaysUseVLCForVideoKey( + account: String, + ocId: String + ) -> String { + "Preferences_alwaysUseVLCForVideo_\(account)|\(ocId)" + } + // MARK: - private func migrate(key: String) { diff --git a/iOSClient/Supporting Files/en.lproj/Localizable.strings b/iOSClient/Supporting Files/en.lproj/Localizable.strings index b299172ca7..1cc0c4a28a 100644 --- a/iOSClient/Supporting Files/en.lproj/Localizable.strings +++ b/iOSClient/Supporting Files/en.lproj/Localizable.strings @@ -708,6 +708,8 @@ "_no_assistant_installed_" = "Assistant is not installed on this server. Ask your administrator to install the Assistant app"; "_open_in_office_" = "Open in Office"; "_select_date_" = "Select date"; +"_play_with_vlc_" = "Play with VLC"; +"_always_play_with_vlc_" = "Always play with VLC"; // Tip "_tip_pdf_thumbnails_" = "Swipe left from the right edge of the screen to show the thumbnails"; diff --git a/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoPlaybackController.swift b/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoPlaybackController.swift index 50cda8d7a9..98faf8c2f6 100644 --- a/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoPlaybackController.swift +++ b/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoPlaybackController.swift @@ -47,7 +47,7 @@ final class NCVideoPlaybackController: ObservableObject { private var currentOcId: String? private var currentEtag: String? private var currentURL: URL? - private var currentFileName: String? + private var currentUserAgent: String? private var loadToken = UUID() private init() { } @@ -94,7 +94,7 @@ final class NCVideoPlaybackController: ObservableObject { currentOcId = metadata.ocId currentEtag = metadata.etag currentURL = url - currentFileName = fileName + currentUserAgent = userAgent engine = .loading if url.isFileURL, @@ -105,6 +105,15 @@ final class NCVideoPlaybackController: ObservableObject { configureAudioSession() + if NCPreferences().alwaysUseVLCForVideo(account: metadata.account, ocId: metadata.ocId) { + resolveWithVLC( + url: url, + userAgent: userAgent, + token: token + ) + return + } + if shouldUseVLCWithoutAVFoundation( url: url, fileName: fileName @@ -118,7 +127,6 @@ final class NCVideoPlaybackController: ObservableObject { } prepareAVFoundation( - metadata: metadata, url: url, userAgent: userAgent, httpHeaders: url.isFileURL ? [:] : httpHeaders, @@ -126,6 +134,19 @@ final class NCVideoPlaybackController: ObservableObject { ) } + // Changes only the prepared engine. Playback still starts from the cover. + func switchToVLC() { + guard let currentURL else { + return + } + + resolveWithVLC( + url: currentURL, + userAgent: currentUserAgent, + token: loadToken + ) + } + func stopIfCurrent(ocId: String) { guard currentOcId == ocId else { return @@ -133,6 +154,7 @@ final class NCVideoPlaybackController: ObservableObject { stop() } + // Releases the current prepared playback state and pending AVFoundation probes. func stop() { loadToken = UUID() @@ -147,15 +169,13 @@ final class NCVideoPlaybackController: ObservableObject { currentOcId = nil currentEtag = nil currentURL = nil - currentFileName = nil - + currentUserAgent = nil engine = .loading } // MARK: - AVFoundation private func prepareAVFoundation( - metadata: tableMetadata, url: URL, userAgent: String?, httpHeaders: [String: String], diff --git a/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCPresenter.swift b/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCPresenter.swift index d03666ac88..57b94ee7c6 100644 --- a/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCPresenter.swift +++ b/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCPresenter.swift @@ -146,6 +146,9 @@ enum NCVideoVLCPresenter { return } + // Stop VLC synchronously before changing the Media Viewer page. + currentViewController.stop() + currentViewController.dismiss(animated: false) { clearCurrent(currentViewController) } diff --git a/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCViewController.swift b/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCViewController.swift index 45b1a4e570..53791104a6 100644 --- a/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCViewController.swift +++ b/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCViewController.swift @@ -22,6 +22,7 @@ final class NCVideoVLCViewController: UIViewController { private var shouldAutoPlayOnStart: Bool private var isChromeHidden: Bool private weak var contextMenuController: NCMainTabBarController? + private var isReplayFromBeginningRequested = false // MARK: - Paging Callbacks @@ -545,8 +546,9 @@ final class NCVideoVLCViewController: UIViewController { stopControlsHideTimer() } - private func stop() { + func stop() { isPlaybackRequested = false + isReplayFromBeginningRequested = false mediaPlayer.stop() mediaPlayer.media = nil @@ -558,6 +560,40 @@ final class NCVideoVLCViewController: UIViewController { clearVLCTrackMenuItems() } + func restartPlaybackFromBeginning() { + isReplayFromBeginningRequested = true + isPlaybackRequested = true + updatePlayPauseButton() + + if mediaPlayer.state == .stopped { + startReplayAfterStop() + } else { + mediaPlayer.stop() + } + } + + private func startReplayAfterStop() { + guard isReplayFromBeginningRequested else { + return + } + + isReplayFromBeginningRequested = false + + let media = VLCMedia(url: url) + + if let userAgent, + !userAgent.isEmpty, + !url.isFileURL { + media.addOption(":http-user-agent=\(userAgent)") + } + + mediaPlayer.media = media + mediaPlayer.play() + + startProgressTimer() + scheduleControlsHide() + } + private func attachDrawable() { guard drawableView.bounds.width > 0, drawableView.bounds.height > 0 else { @@ -577,9 +613,24 @@ final class NCVideoVLCViewController: UIViewController { case .playing: isPlaybackRequested = true + case .ended: + isPlaybackRequested = false + stopProgressTimer() + updatePlayPauseButton() + updateProgressLabels(position: 1) + showControls(animated: true) + stopControlsHideTimer() + return + + case .stopped: + if isReplayFromBeginningRequested { + startReplayAfterStop() + return + } + + isPlaybackRequested = false + case .paused, - .stopped, - .ended, .error: isPlaybackRequested = false diff --git a/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCViewControls.swift b/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCViewControls.swift index 5ae0690174..b9549d120c 100644 --- a/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCViewControls.swift +++ b/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCViewControls.swift @@ -189,10 +189,14 @@ extension NCVideoVLCViewController: NCVideoControlsViewDelegate { updatePlayPauseButton() showControls(animated: false) stopControlsHideTimer() + } else if mediaPlayer.state == .ended || + mediaPlayer.state == .stopped { + restartPlaybackFromBeginning() } else { isPlaybackRequested = true updatePlayPauseButton() mediaPlayer.play() + startProgressTimer() scheduleControlsHide() } diff --git a/iOSClient/Viewer/NCViewerMedia/NCMediaViewerHostingController.swift b/iOSClient/Viewer/NCViewerMedia/NCMediaViewerHostingController.swift index 2b8b68d8a9..800d949cef 100644 --- a/iOSClient/Viewer/NCViewerMedia/NCMediaViewerHostingController.swift +++ b/iOSClient/Viewer/NCViewerMedia/NCMediaViewerHostingController.swift @@ -39,27 +39,38 @@ final class NCMediaViewerHostingController: UIHostingController UIMenu? { + guard metadata.classFile == NKTypeClassFile.video.rawValue else { + return nil + } + + let playback = NCVideoPlaybackController.shared + + guard playback.isCurrentVideo( + ocId: metadata.ocId, + etag: metadata.etag + ) else { + return nil + } + + let alwaysUseVLC = NCPreferences().alwaysUseVLCForVideo( + account: metadata.account, + ocId: metadata.ocId + ) + + let actions: [UIMenuElement] + + switch playback.engine { + case .avFoundation: + let useVLCAction = UIAction( + title: NSLocalizedString("_play_with_vlc_", comment: ""), + image: UIImage(named: "Vlc-Logo")?.withRenderingMode(.alwaysTemplate) + ) { _ in + // Prepares VLC but leaves the video on the cover. + NCVideoPlaybackController.shared.switchToVLC() + } + + let alwaysUseVLCAction = UIAction( + title: NSLocalizedString("_always_play_with_vlc_", comment: ""), + image: UIImage(named: "Vlc-Logo")?.withRenderingMode(.alwaysTemplate), + state: .off + ) { _ in + NCPreferences().setAlwaysUseVLCForVideo( + true, + account: metadata.account, + ocId: metadata.ocId + ) + + // Prepares VLC but leaves the video on the cover. + NCVideoPlaybackController.shared.switchToVLC() + } + + actions = [ + useVLCAction, + alwaysUseVLCAction + ] + + case .vlc: + // Show the VLC options only when this video was explicitly forced to VLC. + guard alwaysUseVLC else { + return nil + } + + let useVLCAction = UIAction( + title: NSLocalizedString("_play_with_vlc_", comment: ""), + image: UIImage(named: "Vlc-Logo")?.withRenderingMode(.alwaysTemplate) + ) { _ in + NCVideoPlaybackController.shared.switchToVLC() + } + + let disableAlwaysUseVLCAction = UIAction( + title: NSLocalizedString("_always_play_with_vlc_", comment: ""), + image: UIImage(named: "Vlc-Logo")?.withRenderingMode(.alwaysTemplate), + state: .on + ) { _ in + NCPreferences().setAlwaysUseVLCForVideo( + false, + account: metadata.account, + ocId: metadata.ocId + ) + } + + actions = [ + useVLCAction, + disableAlwaysUseVLCAction + ] + + case .loading, + .failed: + return nil + } + + return UIMenu( + title: "", + options: .displayInline, + children: actions + ) + } + /// Creates a media viewer hosting controller. init( model: NCMediaViewerModel, From c5ea48a43dc35dddcb99ff919388374797f3c0b1 Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Wed, 29 Jul 2026 17:08:17 +0200 Subject: [PATCH 2/3] fix: handle VLC playback preference toggling Retry playback with AVFoundation when disabling the per-video VLC preference and stop the controls timer when VLC shuts down. Signed-off-by: Marino Faggiana --- .../Video/NCVideoPlaybackController.swift | 33 ++++++++++++++ .../Video/VLC/NCVideoVLCViewController.swift | 1 + .../NCMediaViewerHostingController.swift | 43 ++++--------------- 3 files changed, 43 insertions(+), 34 deletions(-) diff --git a/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoPlaybackController.swift b/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoPlaybackController.swift index 98faf8c2f6..ae94593397 100644 --- a/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoPlaybackController.swift +++ b/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoPlaybackController.swift @@ -147,6 +147,39 @@ final class NCVideoPlaybackController: ObservableObject { ) } + func retryAVFoundation() { + guard let currentURL else { + return + } + + let token = UUID() + loadToken = token + + statusObservation?.invalidate() + statusObservation = nil + + avProbePlayer?.pause() + avProbePlayer = nil + avProbeItem = nil + + engine = .loading + + var httpHeaders: [String: String] = [:] + + if let currentUserAgent, + !currentUserAgent.isEmpty, + !currentURL.isFileURL { + httpHeaders["User-Agent"] = currentUserAgent + } + + prepareAVFoundation( + url: currentURL, + userAgent: currentUserAgent, + httpHeaders: httpHeaders, + token: token + ) + } + func stopIfCurrent(ocId: String) { guard currentOcId == ocId else { return diff --git a/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCViewController.swift b/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCViewController.swift index 53791104a6..0543992d78 100644 --- a/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCViewController.swift +++ b/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCViewController.swift @@ -547,6 +547,7 @@ final class NCVideoVLCViewController: UIViewController { } func stop() { + stopControlsHideTimer() isPlaybackRequested = false isReplayFromBeginningRequested = false diff --git a/iOSClient/Viewer/NCViewerMedia/NCMediaViewerHostingController.swift b/iOSClient/Viewer/NCViewerMedia/NCMediaViewerHostingController.swift index 800d949cef..8819cae17c 100644 --- a/iOSClient/Viewer/NCViewerMedia/NCMediaViewerHostingController.swift +++ b/iOSClient/Viewer/NCViewerMedia/NCMediaViewerHostingController.swift @@ -87,9 +87,7 @@ final class NCMediaViewerHostingController: UIHostingController UIMenu? { + private func makeVideoPlayerMenu(metadata: tableMetadata) -> UIMenu? { guard metadata.classFile == NKTypeClassFile.video.rawValue else { return nil } @@ -108,19 +106,11 @@ final class NCMediaViewerHostingController: UIHostingController Date: Wed, 29 Jul 2026 17:09:05 +0200 Subject: [PATCH 3/3] chore: bump project build number to 5 Signed-off-by: Marino Faggiana --- Nextcloud.xcodeproj/project.pbxproj | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/Nextcloud.xcodeproj/project.pbxproj b/Nextcloud.xcodeproj/project.pbxproj index d546868fde..0a35fe2a21 100644 --- a/Nextcloud.xcodeproj/project.pbxproj +++ b/Nextcloud.xcodeproj/project.pbxproj @@ -6303,7 +6303,7 @@ CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 4; + CURRENT_PROJECT_VERSION = 5; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = NKUJUXUJ3B; @@ -6371,7 +6371,7 @@ CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 4; + CURRENT_PROJECT_VERSION = 5; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = NKUJUXUJ3B;