From a90643fc806324f7905ebe5fbbc18b024887f100 Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Tue, 11 Aug 2026 09:08:04 +0200 Subject: [PATCH 01/14] feat: add media repeat and auto-advance controls Support shared playback completion options across audio, AVPlayer, and VLC viewers. Automatically advance to the next compatible media item and cover option precedence with unit tests. Signed-off-by: Marino Faggiana --- Nextcloud.xcodeproj/project.pbxproj | 8 ++ .../NCMediaPlaybackOptionsTests.swift | 50 +++++++++ .../en.lproj/Localizable.strings | 2 + .../Audio/NCAudioViewerContentView.swift | 53 +++++++-- .../AVPlayer/NCVideoAVPlayerPresenter.swift | 30 +++-- .../NCVideoAVPlayerViewController.swift | 62 ++++++++++- .../NCVideoAVPlayerViewControls.swift | 12 ++ .../NCVideoViewerContentView+AVPlayer.swift | 28 +++-- .../Content/Video/NCVideoControlsView.swift | 80 ++++++++++--- .../Video/NCVideoViewerContentView.swift | 105 +++++++++++++++++- .../Video/VLC/NCVideoVLCPresenter.swift | 14 ++- .../Video/VLC/NCVideoVLCViewController.swift | 37 +++++- .../Video/VLC/NCVideoVLCViewControls.swift | 12 ++ .../VLC/NCVideoViewerContentView+VLC.swift | 17 ++- .../Core/NCMediaPlaybackOptions.swift | 36 ++++++ .../Core/NCMediaViewerModel.swift | 29 +++++ .../Views/NCMediaViewerPageView.swift | 14 ++- .../Views/NCMediaViewerPagingView.swift | 81 +++++++++++++- 18 files changed, 606 insertions(+), 64 deletions(-) create mode 100644 Tests/NextcloudUnitTests/NCMediaPlaybackOptionsTests.swift create mode 100644 iOSClient/Viewer/NCViewerMedia/Core/NCMediaPlaybackOptions.swift diff --git a/Nextcloud.xcodeproj/project.pbxproj b/Nextcloud.xcodeproj/project.pbxproj index 7e9a731547..415c0c1ec9 100644 --- a/Nextcloud.xcodeproj/project.pbxproj +++ b/Nextcloud.xcodeproj/project.pbxproj @@ -845,6 +845,8 @@ F7CDB5C32FA33CA300F72306 /* NCMediaViewerPageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7CDB5B92FA33CA300F72306 /* NCMediaViewerPageView.swift */; }; F7CDB5C42FA33CA300F72306 /* NCImageViewerContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7CDB5B62FA33CA300F72306 /* NCImageViewerContentView.swift */; }; F7CDB5C52FA33CA300F72306 /* NCMediaViewerModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7CDB5B82FA33CA300F72306 /* NCMediaViewerModel.swift */; }; + AABBCC0230A8000100F0A001 /* NCMediaPlaybackOptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = AABBCC0130A8000100F0A001 /* NCMediaPlaybackOptions.swift */; }; + AABBCC0430A8000100F0A001 /* NCMediaPlaybackOptionsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AABBCC0330A8000100F0A001 /* NCMediaPlaybackOptionsTests.swift */; }; F7CDB5C62FA33CA300F72306 /* NCMediaViewerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7CDB5BB2FA33CA300F72306 /* NCMediaViewerView.swift */; }; F7CDB5CC2FA33CA300F72306 /* NCNextcloudMediaViewerLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7CDB5BD2FA33CA300F72306 /* NCNextcloudMediaViewerLoader.swift */; }; F7CDB5D32FA3448B00F72306 /* NCAudioViewerContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7CDB5D22FA3448A00F72306 /* NCAudioViewerContentView.swift */; }; @@ -1857,6 +1859,8 @@ F7CCAB502ECF315F00F8E68B /* NCCollectionViewCommon+SyncMetadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NCCollectionViewCommon+SyncMetadata.swift"; sourceTree = ""; }; F7CDB5B62FA33CA300F72306 /* NCImageViewerContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCImageViewerContentView.swift; sourceTree = ""; }; F7CDB5B82FA33CA300F72306 /* NCMediaViewerModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCMediaViewerModel.swift; sourceTree = ""; }; + AABBCC0130A8000100F0A001 /* NCMediaPlaybackOptions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCMediaPlaybackOptions.swift; sourceTree = ""; }; + AABBCC0330A8000100F0A001 /* NCMediaPlaybackOptionsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCMediaPlaybackOptionsTests.swift; sourceTree = ""; }; F7CDB5B92FA33CA300F72306 /* NCMediaViewerPageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCMediaViewerPageView.swift; sourceTree = ""; }; F7CDB5BB2FA33CA300F72306 /* NCMediaViewerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCMediaViewerView.swift; sourceTree = ""; }; F7CDB5BD2FA33CA300F72306 /* NCNextcloudMediaViewerLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCNextcloudMediaViewerLoader.swift; sourceTree = ""; }; @@ -2179,6 +2183,7 @@ AA52EB442D42AC500089C348 /* NextcloudUnitTests */ = { isa = PBXGroup; children = ( + AABBCC0330A8000100F0A001 /* NCMediaPlaybackOptionsTests.swift */, F34BDB3B2F574A58007A222C /* BidiSafeFilenameTests.swift */, C0DECA012F65000100C0D001 /* NCCameraRollTests.swift */, AA52EB452D42AC5A0089C348 /* Placeholder.swift */, @@ -2595,6 +2600,7 @@ F749ED342FAF0EE200CE8DFA /* Core */ = { isa = PBXGroup; children = ( + AABBCC0130A8000100F0A001 /* NCMediaPlaybackOptions.swift */, F7CDB5BB2FA33CA300F72306 /* NCMediaViewerView.swift */, F7CDB5B82FA33CA300F72306 /* NCMediaViewerModel.swift */, ); @@ -4473,6 +4479,7 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + AABBCC0430A8000100F0A001 /* NCMediaPlaybackOptionsTests.swift in Sources */, AA52EB472D42AC9E0089C348 /* Placeholder.swift in Sources */, F34BDB3C2F574A58007A222C /* BidiSafeFilenameTests.swift in Sources */, C0DECA022F65000100C0D001 /* NCCameraRollTests.swift in Sources */, @@ -4884,6 +4891,7 @@ F7CDB5C32FA33CA300F72306 /* NCMediaViewerPageView.swift in Sources */, F7CDB5C42FA33CA300F72306 /* NCImageViewerContentView.swift in Sources */, F7CDB5C52FA33CA300F72306 /* NCMediaViewerModel.swift in Sources */, + AABBCC0230A8000100F0A001 /* NCMediaPlaybackOptions.swift in Sources */, F7CDB5C62FA33CA300F72306 /* NCMediaViewerView.swift in Sources */, F7CDB5CC2FA33CA300F72306 /* NCNextcloudMediaViewerLoader.swift in Sources */, F76341182EBE0BC60056F538 /* NCNetworking+NextcloudKitDelegate.swift in Sources */, diff --git a/Tests/NextcloudUnitTests/NCMediaPlaybackOptionsTests.swift b/Tests/NextcloudUnitTests/NCMediaPlaybackOptionsTests.swift new file mode 100644 index 0000000000..a139ee3053 --- /dev/null +++ b/Tests/NextcloudUnitTests/NCMediaPlaybackOptionsTests.swift @@ -0,0 +1,50 @@ +// SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: GPL-2.0-or-later + +import Testing +@testable import Nextcloud + +@Suite("Media playback completion options") +@MainActor +struct NCMediaPlaybackOptionsTests { + @Test("Playback stops when no completion option is enabled") + func stopsByDefault() { + let options = NCMediaPlaybackOptions() + + #expect(options.completionAction == .stop) + } + + @Test("Automatic advance plays the next compatible item") + func advancesAutomatically() { + let options = NCMediaPlaybackOptions() + + options.toggleAutoAdvance() + + #expect(options.completionAction == .playNextItem) + } + + @Test("Disabling repeat restores normal stop behavior") + func disablingRepeatRestoresStop() { + let options = NCMediaPlaybackOptions() + + options.toggleRepeat() + #expect(options.completionAction == .repeatCurrentItem) + + options.toggleRepeat() + #expect(options.completionAction == .stop) + } + + @Test("Repeat takes precedence over automatic advance") + func repeatTakesPrecedence() { + let options = NCMediaPlaybackOptions() + + options.toggleAutoAdvance() + options.toggleRepeat() + + #expect(options.completionAction == .repeatCurrentItem) + + options.toggleRepeat() + + #expect(options.completionAction == .playNextItem) + } +} diff --git a/iOSClient/Supporting Files/en.lproj/Localizable.strings b/iOSClient/Supporting Files/en.lproj/Localizable.strings index 54fb807e45..435865b349 100644 --- a/iOSClient/Supporting Files/en.lproj/Localizable.strings +++ b/iOSClient/Supporting Files/en.lproj/Localizable.strings @@ -699,6 +699,8 @@ "_no_subtitles_available_" = "No subtitles available"; "_no_audio_tracks_available_" = "No audio tracks available"; "_add_external_subtitle_" = "Add external subtitle"; +"_repeat_current_media_" = "Repeat current item"; +"_play_next_media_automatically_" = "Play next item automatically"; "_image_load_failed_" = "Image load failed"; "_image_load_failed_" = "Image load failed"; "_gif_file_could_not_be_decoded_" = "GIF file could not be decoded"; diff --git a/iOSClient/Viewer/NCViewerMedia/Content/Audio/NCAudioViewerContentView.swift b/iOSClient/Viewer/NCViewerMedia/Content/Audio/NCAudioViewerContentView.swift index 2ea0307da2..dc0d03e84d 100644 --- a/iOSClient/Viewer/NCViewerMedia/Content/Audio/NCAudioViewerContentView.swift +++ b/iOSClient/Viewer/NCViewerMedia/Content/Audio/NCAudioViewerContentView.swift @@ -16,8 +16,10 @@ struct NCAudioViewerContentView: View { let canGoPrevious: Bool let canGoNext: Bool let shouldAutoPlay: Bool + @ObservedObject var playbackOptions: NCMediaPlaybackOptions let onPrevious: (_ shouldAutoPlay: Bool) -> Void let onNext: (_ shouldAutoPlay: Bool) -> Void + let onPlayNextMedia: () -> Void let onAutoPlayConsumed: () -> Void let onToggleChrome: () -> Void @@ -31,8 +33,10 @@ struct NCAudioViewerContentView: View { canGoPrevious: Bool = false, canGoNext: Bool = false, shouldAutoPlay: Bool = false, + playbackOptions: NCMediaPlaybackOptions, onPrevious: @escaping (_ shouldAutoPlay: Bool) -> Void = { _ in }, onNext: @escaping (_ shouldAutoPlay: Bool) -> Void = { _ in }, + onPlayNextMedia: @escaping () -> Void = {}, onAutoPlayConsumed: @escaping () -> Void = {}, onToggleChrome: @escaping () -> Void = {} ) { @@ -43,8 +47,10 @@ struct NCAudioViewerContentView: View { self.canGoPrevious = canGoPrevious self.canGoNext = canGoNext self.shouldAutoPlay = shouldAutoPlay + self.playbackOptions = playbackOptions self.onPrevious = onPrevious self.onNext = onNext + self.onPlayNextMedia = onPlayNextMedia self.onAutoPlayConsumed = onAutoPlayConsumed self.onToggleChrome = onToggleChrome @@ -116,13 +122,14 @@ struct NCAudioViewerContentView: View { HStack(spacing: buttonSpacing) { Button { - model.toggleLoop() + playbackOptions.toggleRepeat() } label: { - Image(systemName: model.isLoopEnabled ? "repeat.circle.fill" : "repeat.circle") + Image(systemName: playbackOptions.isRepeatEnabled ? "repeat.circle.fill" : "repeat.circle") .font(.system(size: sideButtonSize, weight: .regular)) - .foregroundStyle(model.isLoopEnabled ? primaryForegroundStyle : mutedForegroundStyle) + .foregroundStyle(playbackOptions.isRepeatEnabled ? primaryForegroundStyle : mutedForegroundStyle) } .buttonStyle(.plain) + .accessibilityLabel(NSLocalizedString("_repeat_current_media_", comment: "")) Button { model.togglePlayback() @@ -142,6 +149,16 @@ struct NCAudioViewerContentView: View { } .buttonStyle(.plain) .disabled(model.duration <= 0) + + Button { + playbackOptions.toggleAutoAdvance() + } label: { + Image(systemName: playbackOptions.isAutoAdvanceEnabled ? "forward.end.circle.fill" : "forward.end.circle") + .font(.system(size: sideButtonSize, weight: .regular)) + .foregroundStyle(playbackOptions.isAutoAdvanceEnabled ? primaryForegroundStyle : mutedForegroundStyle) + } + .buttonStyle(.plain) + .accessibilityLabel(NSLocalizedString("_play_next_media_automatically_", comment: "")) } } .padding(.top, topPadding) @@ -149,6 +166,10 @@ struct NCAudioViewerContentView: View { .frame(maxWidth: .infinity, maxHeight: .infinity) } .task(id: localURL) { + model.configurePlaybackCompletion( + options: playbackOptions, + onPlayNextMedia: onPlayNextMedia + ) await model.load(url: localURL) consumeAutoPlayIfNeeded() } @@ -337,7 +358,6 @@ final class NCAudioViewerModel: ObservableObject { @Published private(set) var isPlaying = false @Published private(set) var duration: Double = 0 @Published var currentTime: Double = 0 - @Published private(set) var isLoopEnabled = false // MARK: - Private State @@ -346,9 +366,19 @@ final class NCAudioViewerModel: ObservableObject { private var endObserver: NSObjectProtocol? private var currentURL: URL? private var loadedURL: URL? + private weak var playbackOptions: NCMediaPlaybackOptions? + private var onPlayNextMedia: (() -> Void)? // MARK: - Public API + func configurePlaybackCompletion( + options: NCMediaPlaybackOptions, + onPlayNextMedia: @escaping () -> Void + ) { + playbackOptions = options + self.onPlayNextMedia = onPlayNextMedia + } + func load(url: URL) async { guard currentURL != url else { return @@ -426,10 +456,6 @@ final class NCAudioViewerModel: ObservableObject { } } - func toggleLoop() { - isLoopEnabled.toggle() - } - func restart() { seek(to: 0) @@ -555,7 +581,8 @@ final class NCAudioViewerModel: ObservableObject { return } - if self.isLoopEnabled { + switch self.playbackOptions?.completionAction ?? .stop { + case .repeatCurrentItem: self.currentTime = 0 player.seek( @@ -572,7 +599,13 @@ final class NCAudioViewerModel: ObservableObject { self.isPlaying = true } } - } else { + + case .playNextItem: + self.currentTime = self.duration + self.isPlaying = false + self.onPlayNextMedia?() + + case .stop: self.currentTime = self.duration self.isPlaying = false } diff --git a/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoAVPlayerPresenter.swift b/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoAVPlayerPresenter.swift index 95c86c3477..f0b993c40d 100644 --- a/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoAVPlayerPresenter.swift +++ b/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoAVPlayerPresenter.swift @@ -22,13 +22,15 @@ enum NCVideoAVPlayerPresenter { shouldAutoPlayOnStart: Bool = true, isChromeHidden: Bool = false, contextMenuController: NCMainTabBarController?, + playbackOptions: NCMediaPlaybackOptions, canGoPrevious: Bool = false, canGoNext: Bool = false, onPrevious: (() -> Void)? = nil, onNext: (() -> Void)? = nil, + onPlaybackEnded: (() -> Void)? = nil, onClose: ((_ ocId: String?) -> Void)? = nil, onPlaybackError: (() -> Void)? = nil - ) { + ) -> Bool { let url = preparedPlayback.url if currentURL == url, let currentViewController { @@ -38,20 +40,22 @@ enum NCVideoAVPlayerPresenter { userAgent: userAgent, shouldAutoPlayOnStart: shouldAutoPlayOnStart, isChromeHidden: isChromeHidden, - contextMenuController: contextMenuController + contextMenuController: contextMenuController, + playbackOptions: playbackOptions ) currentViewController.canGoPrevious = canGoPrevious currentViewController.canGoNext = canGoNext currentViewController.onPrevious = onPrevious currentViewController.onNext = onNext + currentViewController.onPlaybackEnded = onPlaybackEnded currentViewController.onClose = onClose currentViewController.onPlaybackError = onPlaybackError - return + return true } if isPresenting { - return + return false } if let currentViewController { @@ -61,17 +65,19 @@ enum NCVideoAVPlayerPresenter { userAgent: userAgent, shouldAutoPlayOnStart: shouldAutoPlayOnStart, isChromeHidden: isChromeHidden, - contextMenuController: contextMenuController + contextMenuController: contextMenuController, + playbackOptions: playbackOptions ) currentViewController.canGoPrevious = canGoPrevious currentViewController.canGoNext = canGoNext currentViewController.onPrevious = onPrevious currentViewController.onNext = onNext + currentViewController.onPlaybackEnded = onPlaybackEnded currentViewController.onClose = onClose currentViewController.onPlaybackError = onPlaybackError currentURL = url - return + return true } guard let presenter = topViewController() else { @@ -81,16 +87,16 @@ enum NCVideoAVPlayerPresenter { message: "VIDEO AVPlayer presenter failed: no top view controller", consoleOnly: true ) - return + return false } if presenter is NCVideoAVPlayerViewController { - return + return false } if let navigationController = presenter as? UINavigationController, navigationController.topViewController is NCVideoAVPlayerViewController { - return + return false } isPresenting = true @@ -101,12 +107,14 @@ enum NCVideoAVPlayerPresenter { userAgent: userAgent, shouldAutoPlayOnStart: shouldAutoPlayOnStart, isChromeHidden: isChromeHidden, - contextMenuController: contextMenuController + contextMenuController: contextMenuController, + playbackOptions: playbackOptions ) viewController.canGoPrevious = canGoPrevious viewController.canGoNext = canGoNext viewController.onPrevious = onPrevious viewController.onNext = onNext + viewController.onPlaybackEnded = onPlaybackEnded viewController.onClose = onClose viewController.onPlaybackError = onPlaybackError @@ -131,6 +139,8 @@ enum NCVideoAVPlayerPresenter { ) { isPresenting = false } + + return true } static func clearCurrent( diff --git a/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoAVPlayerViewController.swift b/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoAVPlayerViewController.swift index fdf9deee27..303e4eaa70 100644 --- a/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoAVPlayerViewController.swift +++ b/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoAVPlayerViewController.swift @@ -41,11 +41,13 @@ final class NCVideoAVPlayerViewController: UIViewController { private var shouldAutoPlayOnStart: Bool private var isChromeHidden: Bool private weak var contextMenuController: NCMainTabBarController? + internal var playbackOptions: NCMediaPlaybackOptions // MARK: - Paging Callbacks var onPrevious: (() -> Void)? var onNext: (() -> Void)? + var onPlaybackEnded: (() -> Void)? var onClose: ((_ ocId: String?) -> Void)? var onPlaybackError: (() -> Void)? var canGoPrevious = false @@ -112,7 +114,8 @@ final class NCVideoAVPlayerViewController: UIViewController { userAgent: String?, shouldAutoPlayOnStart: Bool = true, isChromeHidden: Bool = false, - contextMenuController: NCMainTabBarController? + contextMenuController: NCMainTabBarController?, + playbackOptions: NCMediaPlaybackOptions ) { self.metadata = metadata self.preparedPlayback = preparedPlayback @@ -122,6 +125,7 @@ final class NCVideoAVPlayerViewController: UIViewController { self.shouldAutoPlayOnStart = shouldAutoPlayOnStart self.isChromeHidden = isChromeHidden self.contextMenuController = contextMenuController + self.playbackOptions = playbackOptions super.init( nibName: nil, @@ -159,6 +163,7 @@ final class NCVideoAVPlayerViewController: UIViewController { playerContainerView.playerLayer.videoGravity = .resizeAspect controlsView.delegate = self + updatePlaybackOptionsControls() controlsView.alpha = 0 controlsView.isHidden = true controlsView.translatesAutoresizingMaskIntoConstraints = false @@ -241,7 +246,8 @@ final class NCVideoAVPlayerViewController: UIViewController { userAgent: String?, shouldAutoPlayOnStart: Bool = true, isChromeHidden: Bool = false, - contextMenuController: NCMainTabBarController? + contextMenuController: NCMainTabBarController?, + playbackOptions: NCMediaPlaybackOptions ) { let urlChanged = self.url != preparedPlayback.url @@ -256,6 +262,7 @@ final class NCVideoAVPlayerViewController: UIViewController { self.userAgent = userAgent self.shouldAutoPlayOnStart = shouldAutoPlayOnStart self.contextMenuController = contextMenuController + self.playbackOptions = playbackOptions updateViewerBackground(isChromeHidden: isChromeHidden) updateTitleLabel(metadata: metadata) @@ -265,6 +272,7 @@ final class NCVideoAVPlayerViewController: UIViewController { updatePlayPauseButton() updateProgressControls() + updatePlaybackOptionsControls() } private var viewerBackgroundColor: UIColor { @@ -783,11 +791,61 @@ final class NCVideoAVPlayerViewController: UIViewController { isPlaybackRequested = false cancelPlaybackStartupTimeout() + switch playbackOptions.completionAction { + case .repeatCurrentItem: + repeatCurrentItem() + return + + case .playNextItem: + updatePlayPauseButton() + updateProgressControls() + showControls(animated: true) + onPlaybackEnded?() + return + + case .stop: + break + } + updatePlayPauseButton() updateProgressControls() showControls(animated: true) } + private func repeatCurrentItem() { + isPlaybackRequested = true + updatePlayPauseButton() + + player.seek( + to: .zero, + toleranceBefore: .zero, + toleranceAfter: .zero + ) { [weak self, weak player] didFinish in + guard didFinish, + let self, + let player else { + return + } + + Task { @MainActor in + guard self.player === player else { + return + } + + player.play() + self.updateProgressControls() + self.scheduleControlsHide() + } + } + } + + internal func updatePlaybackOptionsControls() { + controlsView.updatePlaybackOptions( + isRepeatEnabled: playbackOptions.isRepeatEnabled, + isAutoAdvanceEnabled: playbackOptions.isAutoAdvanceEnabled + ) + } + private func updateControlsNavigationBar() { controlsView.setTopActionsNavigationBar(navigationController?.navigationBar) } diff --git a/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoAVPlayerViewControls.swift b/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoAVPlayerViewControls.swift index 8c59d3bd4c..db62aed48c 100644 --- a/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoAVPlayerViewControls.swift +++ b/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoAVPlayerViewControls.swift @@ -194,6 +194,18 @@ extension NCVideoAVPlayerViewController: NCVideoControlsViewDelegate { seek(bySeconds: 10) } + func videoControlsDidToggleRepeat(_ controlsView: NCVideoControlsView) { + playbackOptions.toggleRepeat() + updatePlaybackOptionsControls() + scheduleControlsHide() + } + + func videoControlsDidToggleAutoAdvance(_ controlsView: NCVideoControlsView) { + playbackOptions.toggleAutoAdvance() + updatePlaybackOptionsControls() + scheduleControlsHide() + } + func videoControlsDidTapPictureInPicture(_ controlsView: NCVideoControlsView) { togglePictureInPicture() } diff --git a/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoViewerContentView+AVPlayer.swift b/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoViewerContentView+AVPlayer.swift index b7272b2220..47c2c7f758 100644 --- a/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoViewerContentView+AVPlayer.swift +++ b/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoViewerContentView+AVPlayer.swift @@ -6,37 +6,49 @@ import Foundation extension NCVideoViewerContentView { @MainActor - func requestAVPlayerPresentation(preparedPlayback: NCVideoAVPreparedPlayback) { + @discardableResult + func requestAVPlayerPresentation(preparedPlayback: NCVideoAVPreparedPlayback) -> Bool { hasRequestedPlayback = true - presentAVPlayerIfSelected(preparedPlayback: preparedPlayback) + return presentAVPlayerIfSelected(preparedPlayback: preparedPlayback) } @MainActor - func presentAVPlayerIfSelected(preparedPlayback: NCVideoAVPreparedPlayback) { + @discardableResult + func presentAVPlayerIfSelected(preparedPlayback: NCVideoAVPreparedPlayback) -> Bool { guard isSelected else { - return + return false } guard presentedAVPlayerURL != preparedPlayback.url else { - return + return true } - presentedAVPlayerURL = preparedPlayback.url - - NCVideoAVPlayerPresenter.present( + let didPresent = NCVideoAVPlayerPresenter.present( metadata: metadata, preparedPlayback: preparedPlayback, userAgent: userAgent, shouldAutoPlayOnStart: true, isChromeHidden: isChromeHidden, contextMenuController: contextMenuController, + playbackOptions: playbackOptions, canGoPrevious: canGoPrevious, canGoNext: canGoNext, onPrevious: goToPreviousPageFromAVPlayer, onNext: goToNextPageFromAVPlayer, + onPlaybackEnded: onPlayNextMedia, onClose: closeFromFullscreenVideo, onPlaybackError: handleAVPlayerPlaybackError ) + + guard didPresent else { + presentedAVPlayerURL = nil + hasRequestedPlayback = false + isLaunchingPlayback = false + return false + } + + presentedAVPlayerURL = preparedPlayback.url + return true } @MainActor diff --git a/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoControlsView.swift b/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoControlsView.swift index 047ecbba33..9c40a6799d 100644 --- a/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoControlsView.swift +++ b/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoControlsView.swift @@ -13,6 +13,8 @@ protocol NCVideoControlsViewDelegate: AnyObject { func videoControlsDidTapSeekBackward(_ controlsView: NCVideoControlsView) func videoControlsDidTapPlayPause(_ controlsView: NCVideoControlsView) func videoControlsDidTapSeekForward(_ controlsView: NCVideoControlsView) + func videoControlsDidToggleRepeat(_ controlsView: NCVideoControlsView) + func videoControlsDidToggleAutoAdvance(_ controlsView: NCVideoControlsView) func videoControlsDidTapPictureInPicture(_ controlsView: NCVideoControlsView) func videoControlsDidTapAddExternalSubtitle(_ controlsView: NCVideoControlsView) func videoControls(_ controlsView: NCVideoControlsView, didSelectSubtitleTrackIndex index: Int32) @@ -23,6 +25,10 @@ protocol NCVideoControlsViewDelegate: AnyObject { } extension NCVideoControlsViewDelegate { + func videoControlsDidToggleRepeat(_ controlsView: NCVideoControlsView) { } + + func videoControlsDidToggleAutoAdvance(_ controlsView: NCVideoControlsView) { } + func videoControlsDidTapPictureInPicture(_ controlsView: NCVideoControlsView) { } func videoControlsDidTapAddExternalSubtitle(_ controlsView: NCVideoControlsView) { } @@ -137,6 +143,19 @@ final class NCVideoControlsView: UIView { state.isSeekingEnabled = isEnabled } + func updatePlaybackOptions( + isRepeatEnabled: Bool, + isAutoAdvanceEnabled: Bool + ) { + if state.isRepeatEnabled != isRepeatEnabled { + state.isRepeatEnabled = isRepeatEnabled + } + + if state.isAutoAdvanceEnabled != isAutoAdvanceEnabled { + state.isAutoAdvanceEnabled = isAutoAdvanceEnabled + } + } + func setPictureInPictureVisible(_ isVisible: Bool) { setTopActionsMode(isVisible ? .pictureInPicture : .none) } @@ -286,6 +305,18 @@ final class NCVideoControlsView: UIView { } delegate?.videoControlsDidTapSeekForward(self) }, + onToggleRepeat: { [weak self] in + guard let self else { + return + } + delegate?.videoControlsDidToggleRepeat(self) + }, + onToggleAutoAdvance: { [weak self] in + guard let self else { + return + } + delegate?.videoControlsDidToggleAutoAdvance(self) + }, onScrubBegan: { [weak self] in guard let self else { return @@ -342,6 +373,8 @@ private final class NCVideoControlsState: ObservableObject { @Published var elapsedText = "0:00" @Published var remainingText = "−0:00" @Published var isSeekingEnabled = true + @Published var isRepeatEnabled = false + @Published var isAutoAdvanceEnabled = false @Published var topActionsMode: NCVideoControlsTopActionsMode = .none @Published var subtitleTrackItems: [NCVideoTrackMenuItem] = [] @Published var audioTrackItems: [NCVideoTrackMenuItem] = [] @@ -355,6 +388,8 @@ private struct NCVideoControlsSwiftUIView: View { let onSeekBackward: () -> Void let onPlayPause: () -> Void let onSeekForward: () -> Void + let onToggleRepeat: () -> Void + let onToggleAutoAdvance: () -> Void let onScrubBegan: () -> Void let onScrubChanged: (Float) -> Void let onScrubEnded: (Float) -> Void @@ -382,14 +417,12 @@ private struct NCVideoControlsSwiftUIView: View { y: proxy.size.height - proxy.safeAreaInsets.bottom - NCVideoControlsView.bottomControlsBottomInset - (NCVideoControlsView.bottomControlsHeight / 2) ) - if state.topActionsMode != .none { - topActions - .frame(height: NCVideoControlsView.topActionsHeight) - .position( - x: topActionsCenterX, - y: state.topActionsTopOffset + (NCVideoControlsView.topActionsHeight / 2) - ) - } + topActions + .frame(height: NCVideoControlsView.topActionsHeight) + .position( + x: topActionsCenterX, + y: state.topActionsTopOffset + (NCVideoControlsView.topActionsHeight / 2) + ) } .frame(maxWidth: .infinity, maxHeight: .infinity) } @@ -401,11 +434,11 @@ private struct NCVideoControlsSwiftUIView: View { switch state.topActionsMode { case .none: - visibleButtonsCount = 0 - case .pictureInPicture: visibleButtonsCount = 2 + case .pictureInPicture: + visibleButtonsCount = 4 case .vlcTracks: - visibleButtonsCount = 2 + visibleButtonsCount = 4 } let totalWidth = (visibleButtonsCount * NCVideoControlsView.topActionsButtonSize) + (max(0, visibleButtonsCount - 1) * NCVideoControlsView.topActionsSpacing) @@ -486,6 +519,26 @@ private struct NCVideoControlsSwiftUIView: View { private var topActions: some View { HStack(spacing: NCVideoControlsView.topActionsSpacing) { + Button(action: onToggleRepeat) { + topActionIcon( + systemName: "repeat.1", + pointSize: 17, + isActive: state.isRepeatEnabled + ) + } + .buttonStyle(.plain) + .accessibilityLabel(NSLocalizedString("_repeat_current_media_", comment: "")) + + Button(action: onToggleAutoAdvance) { + topActionIcon( + systemName: state.isAutoAdvanceEnabled ? "forward.end.fill" : "forward.end", + pointSize: 17, + isActive: state.isAutoAdvanceEnabled + ) + } + .buttonStyle(.plain) + .accessibilityLabel(NSLocalizedString("_play_next_media_automatically_", comment: "")) + switch state.topActionsMode { case .none: EmptyView() @@ -610,11 +663,12 @@ private struct NCVideoControlsSwiftUIView: View { private func topActionIcon( systemName: String, - pointSize: CGFloat + pointSize: CGFloat, + isActive: Bool = false ) -> some View { Image(systemName: systemName) .font(.system(size: pointSize, weight: .regular)) - .foregroundStyle(.white) + .foregroundStyle(isActive ? Color.accentColor : .white) .videoControlIconShadow() .frame( width: NCVideoControlsView.topActionsButtonSize, diff --git a/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoViewerContentView.swift b/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoViewerContentView.swift index 6e28745817..cecc27cbdc 100644 --- a/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoViewerContentView.swift +++ b/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoViewerContentView.swift @@ -18,8 +18,12 @@ struct NCVideoViewerContentView: View { let navigationBar: UINavigationBar? let canGoPrevious: Bool let canGoNext: Bool + let shouldAutoPlay: Bool + @ObservedObject var playbackOptions: NCMediaPlaybackOptions let onPreviousPage: (() -> Void)? let onNextPage: (() -> Void)? + let onPlayNextMedia: (() -> Void)? + let onAutoPlayConsumed: (() -> Void)? let onToggleChrome: (() -> Void)? let onClose: ((_ ocId: String?) -> Void)? let downloadVideo: (@MainActor () async throws -> URL)? @@ -54,8 +58,12 @@ struct NCVideoViewerContentView: View { navigationBar: UINavigationBar? = nil, canGoPrevious: Bool = false, canGoNext: Bool = false, + shouldAutoPlay: Bool = false, + playbackOptions: NCMediaPlaybackOptions, onPreviousPage: (() -> Void)? = nil, onNextPage: (() -> Void)? = nil, + onPlayNextMedia: (() -> Void)? = nil, + onAutoPlayConsumed: (() -> Void)? = nil, onToggleChrome: (() -> Void)? = nil, onClose: ((_ ocId: String?) -> Void)? = nil, downloadVideo: (@MainActor () async throws -> URL)? = nil, @@ -71,8 +79,12 @@ struct NCVideoViewerContentView: View { self.navigationBar = navigationBar self.canGoPrevious = canGoPrevious self.canGoNext = canGoNext + self.shouldAutoPlay = shouldAutoPlay + self.playbackOptions = playbackOptions self.onPreviousPage = onPreviousPage self.onNextPage = onNextPage + self.onPlayNextMedia = onPlayNextMedia + self.onAutoPlayConsumed = onAutoPlayConsumed self.onToggleChrome = onToggleChrome self.onClose = onClose self.downloadVideo = downloadVideo @@ -89,6 +101,10 @@ struct NCVideoViewerContentView: View { .background(videoBackgroundColor) .task(id: taskIdentifier) { await loadVideoIfSelected() + scheduleAutoPlayIfNeeded() + } + .onAppear { + scheduleAutoPlayIfNeeded() } .onChange(of: isSelected) { _, selected in loadGeneration = UUID() @@ -100,8 +116,19 @@ struct NCVideoViewerContentView: View { Task { await loadVideoIfSelected() + scheduleAutoPlayIfNeeded() } } + .onChange(of: shouldAutoPlay) { _, shouldAutoPlay in + guard shouldAutoPlay else { + return + } + + scheduleAutoPlayIfNeeded() + } + .onReceive(playback.$engine) { _ in + scheduleAutoPlayIfNeeded() + } .onReceive(NotificationCenter.default.publisher(for: .ncMediaViewerStopPlayback)) { _ in stopPlaybackForDeselection() } @@ -111,6 +138,72 @@ struct NCVideoViewerContentView: View { } } +// MARK: - Automatic Playback + +private extension NCVideoViewerContentView { + func scheduleAutoPlayIfNeeded() { + // Keep the normal cover/play path completely untouched when this page + // is not the selected autoplay target. + guard isSelected, + shouldAutoPlay else { + return + } + + Task { @MainActor in + // Present on the next run-loop turn, after SwiftUI has committed the + // selected page and the prepared playback state. + await Task.yield() + autoPlayIfNeeded() + } + } + + @MainActor + func autoPlayIfNeeded() { + guard isSelected, + shouldAutoPlay, + !hasRequestedPlayback, + !isLaunchingPlayback else { + return + } + + if requiresUserTrustedCertificateDownload { + guard downloadVideo != nil, + !isDownloadingPlayback else { + return + } + + onAutoPlayConsumed?() + isLaunchingPlayback = true + downloadAndPlayVideo() + return + } + + switch playback.engine { + case .avFoundation(let preparedPlayback): + guard isCurrentPlaybackVideo() else { + return + } + + if requestAVPlayerPresentation(preparedPlayback: preparedPlayback) { + onAutoPlayConsumed?() + } + + case .vlc(let preparedPlayback): + guard isCurrentPlaybackVideo() else { + return + } + + if requestVLCPresentation(preparedPlayback: preparedPlayback) { + onAutoPlayConsumed?() + } + + case .loading, + .failed: + break + } + } +} + // MARK: - Main Content private extension NCVideoViewerContentView { @@ -304,10 +397,18 @@ private extension NCVideoViewerContentView { switch playback.engine { case .avFoundation(let preparedPlayback): - requestAVPlayerPresentation(preparedPlayback: preparedPlayback) + if requestAVPlayerPresentation(preparedPlayback: preparedPlayback) { + if shouldAutoPlay { + onAutoPlayConsumed?() + } + } case .vlc(let preparedPlayback): - requestVLCPresentation(preparedPlayback: preparedPlayback) + if requestVLCPresentation(preparedPlayback: preparedPlayback) { + if shouldAutoPlay { + onAutoPlayConsumed?() + } + } case .loading, .failed: diff --git a/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCPresenter.swift b/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCPresenter.swift index 4448f82fdf..5eb1cd9d91 100644 --- a/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCPresenter.swift +++ b/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCPresenter.swift @@ -25,10 +25,12 @@ enum NCVideoVLCPresenter { shouldAutoPlayOnStart: Bool = true, isChromeHidden: Bool = false, contextMenuController: NCMainTabBarController?, + playbackOptions: NCMediaPlaybackOptions, canGoPrevious: Bool = false, canGoNext: Bool = false, onPrevious: (() -> Void)? = nil, onNext: (() -> Void)? = nil, + onPlaybackEnded: (() -> Void)? = nil, onClose: ((_ ocId: String?) -> Void)? = nil, onPlaybackError: (() -> Void)? = nil ) -> Bool { @@ -47,10 +49,12 @@ enum NCVideoVLCPresenter { userAgent: userAgent, shouldAutoPlayOnStart: shouldAutoPlayOnStart, isChromeHidden: isChromeHidden, - contextMenuController: contextMenuController + contextMenuController: contextMenuController, + playbackOptions: playbackOptions ) currentViewController.onPrevious = onPrevious currentViewController.onNext = onNext + currentViewController.onPlaybackEnded = onPlaybackEnded currentViewController.onClose = onClose currentViewController.onPlaybackError = onPlaybackError currentViewController.canGoPrevious = canGoPrevious @@ -70,10 +74,12 @@ enum NCVideoVLCPresenter { userAgent: userAgent, shouldAutoPlayOnStart: shouldAutoPlayOnStart, isChromeHidden: isChromeHidden, - contextMenuController: contextMenuController + contextMenuController: contextMenuController, + playbackOptions: playbackOptions ) currentViewController.onPrevious = onPrevious currentViewController.onNext = onNext + currentViewController.onPlaybackEnded = onPlaybackEnded currentViewController.onClose = onClose currentViewController.onPlaybackError = onPlaybackError currentViewController.canGoPrevious = canGoPrevious @@ -112,10 +118,12 @@ enum NCVideoVLCPresenter { userAgent: userAgent, shouldAutoPlayOnStart: shouldAutoPlayOnStart, isChromeHidden: isChromeHidden, - contextMenuController: contextMenuController + contextMenuController: contextMenuController, + playbackOptions: playbackOptions ) viewController.onPrevious = onPrevious viewController.onNext = onNext + viewController.onPlaybackEnded = onPlaybackEnded viewController.onClose = onClose viewController.onPlaybackError = onPlaybackError viewController.canGoPrevious = canGoPrevious diff --git a/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCViewController.swift b/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCViewController.swift index a94661fee1..229364b6ab 100644 --- a/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCViewController.swift +++ b/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCViewController.swift @@ -20,12 +20,14 @@ final class NCVideoVLCViewController: UIViewController { private var shouldAutoPlayOnStart: Bool private var isChromeHidden: Bool private weak var contextMenuController: NCMainTabBarController? + internal var playbackOptions: NCMediaPlaybackOptions private var isReplayFromBeginningRequested = false // MARK: - Paging Callbacks var onPrevious: (() -> Void)? var onNext: (() -> Void)? + var onPlaybackEnded: (() -> Void)? var onClose: ((_ ocId: String?) -> Void)? var onPlaybackError: (() -> Void)? var canGoPrevious = false @@ -85,7 +87,8 @@ final class NCVideoVLCViewController: UIViewController { userAgent: String?, shouldAutoPlayOnStart: Bool = true, isChromeHidden: Bool = false, - contextMenuController: NCMainTabBarController? + contextMenuController: NCMainTabBarController?, + playbackOptions: NCMediaPlaybackOptions ) { self.metadata = metadata self.preparedPlayback = preparedPlayback @@ -94,6 +97,7 @@ final class NCVideoVLCViewController: UIViewController { self.shouldAutoPlayOnStart = shouldAutoPlayOnStart self.isChromeHidden = isChromeHidden self.contextMenuController = contextMenuController + self.playbackOptions = playbackOptions super.init( nibName: nil, @@ -131,6 +135,7 @@ final class NCVideoVLCViewController: UIViewController { controlsView.delegate = self controlsView.setTopActionsMode(.vlcTracks) + updatePlaybackOptionsControls() controlsView.alpha = 0 controlsView.isHidden = true controlsView.translatesAutoresizingMaskIntoConstraints = false @@ -208,7 +213,8 @@ final class NCVideoVLCViewController: UIViewController { userAgent: String?, shouldAutoPlayOnStart: Bool = true, isChromeHidden: Bool = false, - contextMenuController: NCMainTabBarController? + contextMenuController: NCMainTabBarController?, + playbackOptions: NCMediaPlaybackOptions ) { let urlChanged = self.url != preparedPlayback.url let applyConfiguration = { [weak self] in @@ -219,10 +225,12 @@ final class NCVideoVLCViewController: UIViewController { self.shouldAutoPlayOnStart = shouldAutoPlayOnStart self.isChromeHidden = isChromeHidden self.contextMenuController = contextMenuController + self.playbackOptions = playbackOptions self.updateViewerBackgroundIfNeeded() self.updateTitleLabel(metadata: metadata) self.refreshVLCTrackMenuItemsWhenPlayerIsActive() self.updatePlayPauseButton() + self.updatePlaybackOptionsControls() } guard urlChanged else { @@ -656,6 +664,24 @@ final class NCVideoVLCViewController: UIViewController { case .ended: isPlaybackRequested = false stopProgressTimer() + + switch playbackOptions.completionAction { + case .repeatCurrentItem: + restartPlaybackFromBeginning() + return + + case .playNextItem: + updatePlayPauseButton() + updateProgressLabels(position: 1) + showControls(animated: true) + stopControlsHideTimer() + onPlaybackEnded?() + return + + case .stop: + break + } + updatePlayPauseButton() updateProgressLabels(position: 1) showControls(animated: true) @@ -723,6 +749,13 @@ final class NCVideoVLCViewController: UIViewController { scheduleControlsHide() } + internal func updatePlaybackOptionsControls() { + controlsView.updatePlaybackOptions( + isRepeatEnabled: playbackOptions.isRepeatEnabled, + isAutoAdvanceEnabled: playbackOptions.isAutoAdvanceEnabled + ) + } + // MARK: - VLC Track Menus func refreshVLCTrackMenuItems() { diff --git a/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCViewControls.swift b/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCViewControls.swift index b9549d120c..1478e9f83e 100644 --- a/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCViewControls.swift +++ b/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCViewControls.swift @@ -209,6 +209,18 @@ extension NCVideoVLCViewController: NCVideoControlsViewDelegate { seek(byMilliseconds: 10_000) } + func videoControlsDidToggleRepeat(_ controlsView: NCVideoControlsView) { + playbackOptions.toggleRepeat() + updatePlaybackOptionsControls() + scheduleControlsHide() + } + + func videoControlsDidToggleAutoAdvance(_ controlsView: NCVideoControlsView) { + playbackOptions.toggleAutoAdvance() + updatePlaybackOptionsControls() + scheduleControlsHide() + } + func videoControlsDidBeginScrubbing(_ controlsView: NCVideoControlsView) { showControls(animated: true) stopControlsHideTimer() diff --git a/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoViewerContentView+VLC.swift b/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoViewerContentView+VLC.swift index 7417fa1b4d..d2a4b88838 100644 --- a/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoViewerContentView+VLC.swift +++ b/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoViewerContentView+VLC.swift @@ -6,19 +6,21 @@ import Foundation extension NCVideoViewerContentView { @MainActor - func requestVLCPresentation(preparedPlayback: NCVideoVLCPreparedPlayback) { + @discardableResult + func requestVLCPresentation(preparedPlayback: NCVideoVLCPreparedPlayback) -> Bool { hasRequestedPlayback = true - presentVLCIfSelected(preparedPlayback: preparedPlayback) + return presentVLCIfSelected(preparedPlayback: preparedPlayback) } @MainActor - func presentVLCIfSelected(preparedPlayback: NCVideoVLCPreparedPlayback) { + @discardableResult + func presentVLCIfSelected(preparedPlayback: NCVideoVLCPreparedPlayback) -> Bool { guard isSelected else { - return + return false } guard presentedVLCURL != preparedPlayback.url else { - return + return true } let didPresent = NCVideoVLCPresenter.present( @@ -28,10 +30,12 @@ extension NCVideoViewerContentView { shouldAutoPlayOnStart: true, isChromeHidden: isChromeHidden, contextMenuController: contextMenuController, + playbackOptions: playbackOptions, canGoPrevious: canGoPrevious, canGoNext: canGoNext, onPrevious: goToPreviousPageFromVLC, onNext: goToNextPageFromVLC, + onPlaybackEnded: onPlayNextMedia, onClose: closeFromFullscreenVideo, onPlaybackError: handleVLCPlaybackError ) @@ -40,10 +44,11 @@ extension NCVideoViewerContentView { presentedVLCURL = nil hasRequestedPlayback = false isLaunchingPlayback = false - return + return false } presentedVLCURL = preparedPlayback.url + return true } @MainActor diff --git a/iOSClient/Viewer/NCViewerMedia/Core/NCMediaPlaybackOptions.swift b/iOSClient/Viewer/NCViewerMedia/Core/NCMediaPlaybackOptions.swift new file mode 100644 index 0000000000..2eda046e5a --- /dev/null +++ b/iOSClient/Viewer/NCViewerMedia/Core/NCMediaPlaybackOptions.swift @@ -0,0 +1,36 @@ +// SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: GPL-2.0-or-later + +import Combine + +enum NCMediaPlaybackCompletionAction: Equatable { + case stop + case repeatCurrentItem + case playNextItem +} + +@MainActor +final class NCMediaPlaybackOptions: ObservableObject { + @Published private(set) var isRepeatEnabled = false + @Published private(set) var isAutoAdvanceEnabled = false + + var completionAction: NCMediaPlaybackCompletionAction { + if isRepeatEnabled { + return .repeatCurrentItem + } + + if isAutoAdvanceEnabled { + return .playNextItem + } + + return .stop + } + + func toggleRepeat() { + isRepeatEnabled.toggle() + } + + func toggleAutoAdvance() { + isAutoAdvanceEnabled.toggle() + } +} diff --git a/iOSClient/Viewer/NCViewerMedia/Core/NCMediaViewerModel.swift b/iOSClient/Viewer/NCViewerMedia/Core/NCMediaViewerModel.swift index f60e100b7b..52786b00a9 100644 --- a/iOSClient/Viewer/NCViewerMedia/Core/NCMediaViewerModel.swift +++ b/iOSClient/Viewer/NCViewerMedia/Core/NCMediaViewerModel.swift @@ -102,6 +102,8 @@ final class NCMediaViewerModel: ObservableObject { @Published private(set) var isChromeHidden = false @Published private(set) var autoPlayTargetIndex: Int? + let playbackOptions = NCMediaPlaybackOptions() + // MARK: - Dependencies private let loader: NCMediaViewerLoading @@ -531,6 +533,33 @@ final class NCMediaViewerModel: ObservableObject { return metadata } + func nextMediaIndex( + after index: Int, + matchingClassFile classFile: String + ) async -> Int? { + guard ocIds.indices.contains(index), + index < ocIds.index(before: ocIds.endIndex) else { + return nil + } + + for candidateIndex in ocIds.index(after: index).. Bool { guard let ocId = ocId(at: index), let page = cachedPagesByOcId[ocId] else { diff --git a/iOSClient/Viewer/NCViewerMedia/Views/NCMediaViewerPageView.swift b/iOSClient/Viewer/NCViewerMedia/Views/NCMediaViewerPageView.swift index bff54bfaf9..6aefff7856 100644 --- a/iOSClient/Viewer/NCViewerMedia/Views/NCMediaViewerPageView.swift +++ b/iOSClient/Viewer/NCViewerMedia/Views/NCMediaViewerPageView.swift @@ -15,9 +15,9 @@ struct NCMediaViewerPageView: View { let canGoPrevious: Bool let canGoNext: Bool - let shouldAutoPlay: Bool let onPreviousPage: (_ shouldAutoPlay: Bool) -> Void let onNextPage: (_ shouldAutoPlay: Bool) -> Void + let onNextMediaOfSameType: (_ classFile: String) -> Void let onClose: (_ ocId: String?) -> Void let onAutoPlayConsumed: () -> Void let onZoomChanged: (Bool) -> Void @@ -103,7 +103,7 @@ struct NCMediaViewerPageView: View { // Neighbor pages must not consume auto-play. private var effectiveShouldAutoPlay: Bool { - isSelected && shouldAutoPlay + isSelected && model.autoPlayTargetIndex == page.index } private func goToPreviousPage(_ requestedAutoPlay: Bool) { @@ -210,8 +210,14 @@ struct NCMediaViewerPageView: View { navigationBar: navigationBar, canGoPrevious: canGoPrevious, canGoNext: canGoNext, + shouldAutoPlay: effectiveShouldAutoPlay, + playbackOptions: model.playbackOptions, onPreviousPage: goToPreviousPageFromVideo, onNextPage: goToNextPageFromVideo, + onPlayNextMedia: { [onNextMediaOfSameType] in + onNextMediaOfSameType(NKTypeClassFile.video.rawValue) + }, + onAutoPlayConsumed: consumeAutoPlayIfNeeded, onToggleChrome: onToggleChrome, onClose: onClose, downloadVideo: { @@ -242,8 +248,12 @@ struct NCMediaViewerPageView: View { canGoPrevious: canGoPrevious, canGoNext: canGoNext, shouldAutoPlay: effectiveShouldAutoPlay, + playbackOptions: model.playbackOptions, onPrevious: goToPreviousPage, onNext: goToNextPage, + onPlayNextMedia: { [onNextMediaOfSameType] in + onNextMediaOfSameType(NKTypeClassFile.audio.rawValue) + }, onAutoPlayConsumed: consumeAutoPlayIfNeeded, onToggleChrome: onToggleChrome ) diff --git a/iOSClient/Viewer/NCViewerMedia/Views/NCMediaViewerPagingView.swift b/iOSClient/Viewer/NCViewerMedia/Views/NCMediaViewerPagingView.swift index c977bd305f..6e8cfdca09 100644 --- a/iOSClient/Viewer/NCViewerMedia/Views/NCMediaViewerPagingView.swift +++ b/iOSClient/Viewer/NCViewerMedia/Views/NCMediaViewerPagingView.swift @@ -131,6 +131,7 @@ final class NCMediaViewerPagingCoordinator: NSObject, private var cancellable: AnyCancellable? private var lastVisibleIndex: Int? private var isUserPaging = false + private var isAutoPlayPaging = false private var isAdjustingLayout = false // MARK: - Init @@ -300,6 +301,13 @@ final class NCMediaViewerPagingCoordinator: NSObject, } func jumpToSelectedIndexIfNeeded(animated: Bool) { + // During an autoplay transition, selectedIndex still represents the + // source page until the scroll reaches the target. Every manual paging + // path keeps its original continuous selection behavior. + guard !isAutoPlayPaging else { + return + } + guard model.numberOfPages > 0 else { return } @@ -348,6 +356,7 @@ final class NCMediaViewerPagingCoordinator: NSObject, if !animated { isUserPaging = false + isAutoPlayPaging = false model.setSelectedIndex(index) refreshVisibleCells() @@ -423,6 +432,16 @@ final class NCMediaViewerPagingCoordinator: NSObject, ) { let targetIndex = model.selectedIndex + offset + moveToPage( + at: targetIndex, + shouldAutoPlay: shouldAutoPlay + ) + } + + private func moveToPage( + at targetIndex: Int, + shouldAutoPlay: Bool + ) { guard targetIndex >= 0, targetIndex < model.numberOfPages else { return @@ -436,13 +455,16 @@ final class NCMediaViewerPagingCoordinator: NSObject, object: nil ) + // Mark the transition before publishing the autoplay target. Publishing + // causes SwiftUI to call updateUIView, which must not restore the source + // index while the collection view is moving to the target page. + isUserPaging = true + isAutoPlayPaging = shouldAutoPlay + if shouldAutoPlay { model.requestAutoPlay(at: targetIndex) } - // Selection is finalized when the scroll animation ends. - isUserPaging = true - updateCollectionBackground(for: targetIndex) updateVisibleMetadataTitle(for: targetIndex) refreshVisibleCells() @@ -453,6 +475,39 @@ final class NCMediaViewerPagingCoordinator: NSObject, ) } + private func moveToNextMediaOfSameType( + classFile: String, + after sourceIndex: Int + ) { + Task { @MainActor [weak self] in + guard let self, + let targetIndex = await self.model.nextMediaIndex( + after: sourceIndex, + matchingClassFile: classFile + ), + self.model.selectedIndex == sourceIndex else { + return + } + + // Wait until the current fullscreen player has fully disappeared. + // Otherwise the next player can be rejected while the previous + // AVPlayer or VLC controller is still dismissing. + NCVideoAVPlayerPresenter.dismiss { [weak self] in + NCVideoVLCPresenter.dismiss { [weak self] in + guard let self, + self.model.selectedIndex == sourceIndex else { + return + } + + self.moveToPage( + at: targetIndex, + shouldAutoPlay: true + ) + } + } + } + } + private func configure(cell: NCMediaViewerPagingCell, page: NCMediaViewerPageModel) { let pageBackgroundColor = backgroundColor(for: page) @@ -464,7 +519,6 @@ final class NCMediaViewerPagingCoordinator: NSObject, backgroundColor: pageBackgroundColor, canGoPrevious: page.index > 0, canGoNext: page.index < model.numberOfPages - 1, - shouldAutoPlay: model.autoPlayTargetIndex == page.index, onToggleChrome: { [weak model] in model?.toggleChromeVisibility() }, @@ -480,6 +534,12 @@ final class NCMediaViewerPagingCoordinator: NSObject, shouldAutoPlay: shouldAutoPlay ) }, + onNextMediaOfSameType: { [weak self] classFile in + self?.moveToNextMediaOfSameType( + classFile: classFile, + after: page.index + ) + }, onAutoPlayConsumed: { [weak model] in model?.clearAutoPlayIfNeeded(for: page.index) }, @@ -549,6 +609,8 @@ final class NCMediaViewerPagingCoordinator: NSObject, // MARK: - UIScrollViewDelegate func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { + // A user drag takes ownership of any in-flight autoplay transition. + isAutoPlayPaging = false isUserPaging = true // Stop the current media playback before manual page navigation. @@ -632,6 +694,12 @@ final class NCMediaViewerPagingCoordinator: NSObject, } func scrollViewDidScroll(_ scrollView: UIScrollView) { + // Auto-next keeps selectedIndex on the source page until it settles. + // Manual swipes preserve their original update behavior. + guard !isAutoPlayPaging else { + return + } + guard isScrollGeometryStable(scrollView) else { return } @@ -684,6 +752,7 @@ final class NCMediaViewerPagingCoordinator: NSObject, // The settled page is now the selected page. isUserPaging = false + isAutoPlayPaging = false lastVisibleIndex = index model.setSelectedIndex(index) @@ -751,10 +820,10 @@ final class NCMediaViewerPagingCell: UICollectionViewCell { backgroundColor: UIColor, canGoPrevious: Bool, canGoNext: Bool, - shouldAutoPlay: Bool, onToggleChrome: @escaping () -> Void, onPreviousPage: @escaping (_ shouldAutoPlay: Bool) -> Void, onNextPage: @escaping (_ shouldAutoPlay: Bool) -> Void, + onNextMediaOfSameType: @escaping (_ classFile: String) -> Void, onAutoPlayConsumed: @escaping () -> Void, onZoomChanged: @escaping (Bool) -> Void, onClose: @escaping (_ ocId: String?) -> Void, @@ -771,9 +840,9 @@ final class NCMediaViewerPagingCell: UICollectionViewCell { onToggleChrome: onToggleChrome, canGoPrevious: canGoPrevious, canGoNext: canGoNext, - shouldAutoPlay: shouldAutoPlay, onPreviousPage: onPreviousPage, onNextPage: onNextPage, + onNextMediaOfSameType: onNextMediaOfSameType, onClose: onClose, onAutoPlayConsumed: onAutoPlayConsumed, onZoomChanged: onZoomChanged, From 718f287ae4d53e0ee7fde79c0fd8af857c0885eb Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Tue, 11 Aug 2026 09:13:41 +0200 Subject: [PATCH 02/14] fix: restore audio autoplay when paging tracks Reload the audio player after viewer-wide teardown and skip fullscreen video dismissal for audio transitions. Use the repeat-one icon for the audio repeat control. Signed-off-by: Marino Faggiana --- .../Audio/NCAudioViewerContentView.swift | 19 +++++++++++++++---- .../Views/NCMediaViewerPagingView.swift | 14 +++++++++++--- 2 files changed, 26 insertions(+), 7 deletions(-) diff --git a/iOSClient/Viewer/NCViewerMedia/Content/Audio/NCAudioViewerContentView.swift b/iOSClient/Viewer/NCViewerMedia/Content/Audio/NCAudioViewerContentView.swift index dc0d03e84d..2c60f28f2f 100644 --- a/iOSClient/Viewer/NCViewerMedia/Content/Audio/NCAudioViewerContentView.swift +++ b/iOSClient/Viewer/NCViewerMedia/Content/Audio/NCAudioViewerContentView.swift @@ -124,7 +124,7 @@ struct NCAudioViewerContentView: View { Button { playbackOptions.toggleRepeat() } label: { - Image(systemName: playbackOptions.isRepeatEnabled ? "repeat.circle.fill" : "repeat.circle") + Image(systemName: playbackOptions.isRepeatEnabled ? "repeat.1.circle.fill" : "repeat.1.circle") .font(.system(size: sideButtonSize, weight: .regular)) .foregroundStyle(playbackOptions.isRepeatEnabled ? primaryForegroundStyle : mutedForegroundStyle) } @@ -171,14 +171,16 @@ struct NCAudioViewerContentView: View { onPlayNextMedia: onPlayNextMedia ) await model.load(url: localURL) - consumeAutoPlayIfNeeded() + await consumeAutoPlayIfNeeded() } .onChange(of: shouldAutoPlay) { _, newValue in guard newValue else { return } - consumeAutoPlayIfNeeded() + Task { @MainActor in + await consumeAutoPlayIfNeeded() + } } // Stop all audio playback when the media viewer performs a global playback teardown. // This notification is intentionally viewer-wide and should not be used for normal @@ -294,7 +296,16 @@ struct NCAudioViewerContentView: View { } @MainActor - private func consumeAutoPlayIfNeeded() { + private func consumeAutoPlayIfNeeded() async { + guard shouldAutoPlay else { + return + } + + // The viewer-wide stop notification also releases players belonging to + // prefetched audio pages. Recreate this page's player before autoplaying + // instead of relying on the previous preload still being alive. + await model.load(url: localURL) + guard shouldAutoPlay else { return } diff --git a/iOSClient/Viewer/NCViewerMedia/Views/NCMediaViewerPagingView.swift b/iOSClient/Viewer/NCViewerMedia/Views/NCMediaViewerPagingView.swift index 6e8cfdca09..c91711a058 100644 --- a/iOSClient/Viewer/NCViewerMedia/Views/NCMediaViewerPagingView.swift +++ b/iOSClient/Viewer/NCViewerMedia/Views/NCMediaViewerPagingView.swift @@ -489,9 +489,17 @@ final class NCMediaViewerPagingCoordinator: NSObject, return } - // Wait until the current fullscreen player has fully disappeared. - // Otherwise the next player can be rejected while the previous - // AVPlayer or VLC controller is still dismissing. + guard classFile == NKTypeClassFile.video.rawValue else { + self.moveToPage( + at: targetIndex, + shouldAutoPlay: true + ) + return + } + + // Video transitions must wait until the current fullscreen player + // has fully disappeared. Audio has no fullscreen presenter and can + // navigate immediately through the normal paging path above. NCVideoAVPlayerPresenter.dismiss { [weak self] in NCVideoVLCPresenter.dismiss { [weak self] in guard let self, From db6d769a9d6a7fe7bb2891e85a71ebca30f51aef Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Tue, 11 Aug 2026 09:17:45 +0200 Subject: [PATCH 03/14] feat: persist media viewer playback options Signed-off-by: Marino Faggiana --- .../NCMediaPlaybackOptionsTests.swift | 34 ++++++++++++++++--- iOSClient/Settings/NCPreferences.swift | 32 +++++++++++++++++ .../Core/NCMediaPlaybackOptions.swift | 14 ++++++-- 3 files changed, 74 insertions(+), 6 deletions(-) diff --git a/Tests/NextcloudUnitTests/NCMediaPlaybackOptionsTests.swift b/Tests/NextcloudUnitTests/NCMediaPlaybackOptionsTests.swift index a139ee3053..3fa87f632c 100644 --- a/Tests/NextcloudUnitTests/NCMediaPlaybackOptionsTests.swift +++ b/Tests/NextcloudUnitTests/NCMediaPlaybackOptionsTests.swift @@ -9,14 +9,14 @@ import Testing struct NCMediaPlaybackOptionsTests { @Test("Playback stops when no completion option is enabled") func stopsByDefault() { - let options = NCMediaPlaybackOptions() + let options = NCMediaPlaybackOptions(preferences: nil) #expect(options.completionAction == .stop) } @Test("Automatic advance plays the next compatible item") func advancesAutomatically() { - let options = NCMediaPlaybackOptions() + let options = NCMediaPlaybackOptions(preferences: nil) options.toggleAutoAdvance() @@ -25,7 +25,7 @@ struct NCMediaPlaybackOptionsTests { @Test("Disabling repeat restores normal stop behavior") func disablingRepeatRestoresStop() { - let options = NCMediaPlaybackOptions() + let options = NCMediaPlaybackOptions(preferences: nil) options.toggleRepeat() #expect(options.completionAction == .repeatCurrentItem) @@ -36,7 +36,7 @@ struct NCMediaPlaybackOptionsTests { @Test("Repeat takes precedence over automatic advance") func repeatTakesPrecedence() { - let options = NCMediaPlaybackOptions() + let options = NCMediaPlaybackOptions(preferences: nil) options.toggleAutoAdvance() options.toggleRepeat() @@ -47,4 +47,30 @@ struct NCMediaPlaybackOptionsTests { #expect(options.completionAction == .playNextItem) } + + @Test("Playback options restore and save their preferences") + func persistsPlaybackOptions() { + let preferences = NCPreferences() + let originalRepeat = preferences.mediaViewerRepeatCurrentItem + let originalAutoAdvance = preferences.mediaViewerAutoAdvance + + defer { + preferences.mediaViewerRepeatCurrentItem = originalRepeat + preferences.mediaViewerAutoAdvance = originalAutoAdvance + } + + preferences.mediaViewerRepeatCurrentItem = true + preferences.mediaViewerAutoAdvance = false + + let options = NCMediaPlaybackOptions(preferences: preferences) + + #expect(options.isRepeatEnabled) + #expect(!options.isAutoAdvanceEnabled) + + options.toggleRepeat() + options.toggleAutoAdvance() + + #expect(!preferences.mediaViewerRepeatCurrentItem) + #expect(preferences.mediaViewerAutoAdvance) + } } diff --git a/iOSClient/Settings/NCPreferences.swift b/iOSClient/Settings/NCPreferences.swift index 4c18099f1c..c3b4d5d66e 100644 --- a/iOSClient/Settings/NCPreferences.swift +++ b/iOSClient/Settings/NCPreferences.swift @@ -633,6 +633,38 @@ final class NCPreferences: NSObject { setUserDefaults(weekString, forKey: "cleaningWeek") } + // MARK: - Media Viewer + + var mediaViewerRepeatCurrentItem: Bool { + get { + getBoolPreference( + key: "mediaViewerRepeatCurrentItem", + defaultValue: false + ) + } + set { + setUserDefaults( + newValue, + forKey: "mediaViewerRepeatCurrentItem" + ) + } + } + + var mediaViewerAutoAdvance: Bool { + get { + getBoolPreference( + key: "mediaViewerAutoAdvance", + defaultValue: false + ) + } + set { + setUserDefaults( + newValue, + forKey: "mediaViewerAutoAdvance" + ) + } + } + // MARK: - Video func alwaysUseVLCForVideo(account: String, ocId: String) -> Bool { diff --git a/iOSClient/Viewer/NCViewerMedia/Core/NCMediaPlaybackOptions.swift b/iOSClient/Viewer/NCViewerMedia/Core/NCMediaPlaybackOptions.swift index 2eda046e5a..2eb6192dc1 100644 --- a/iOSClient/Viewer/NCViewerMedia/Core/NCMediaPlaybackOptions.swift +++ b/iOSClient/Viewer/NCViewerMedia/Core/NCMediaPlaybackOptions.swift @@ -11,8 +11,16 @@ enum NCMediaPlaybackCompletionAction: Equatable { @MainActor final class NCMediaPlaybackOptions: ObservableObject { - @Published private(set) var isRepeatEnabled = false - @Published private(set) var isAutoAdvanceEnabled = false + @Published private(set) var isRepeatEnabled: Bool + @Published private(set) var isAutoAdvanceEnabled: Bool + + private let preferences: NCPreferences? + + init(preferences: NCPreferences? = NCPreferences()) { + self.preferences = preferences + self.isRepeatEnabled = preferences?.mediaViewerRepeatCurrentItem ?? false + self.isAutoAdvanceEnabled = preferences?.mediaViewerAutoAdvance ?? false + } var completionAction: NCMediaPlaybackCompletionAction { if isRepeatEnabled { @@ -28,9 +36,11 @@ final class NCMediaPlaybackOptions: ObservableObject { func toggleRepeat() { isRepeatEnabled.toggle() + preferences?.mediaViewerRepeatCurrentItem = isRepeatEnabled } func toggleAutoAdvance() { isAutoAdvanceEnabled.toggle() + preferences?.mediaViewerAutoAdvance = isAutoAdvanceEnabled } } From f8d0863364bfc1cf0d97974552cff81cdc36e667 Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Tue, 11 Aug 2026 09:34:49 +0200 Subject: [PATCH 04/14] feat: update audio viewer playback controls Signed-off-by: Marino Faggiana --- .../Audio/NCAudioViewerContentView.swift | 117 +++++++++++++++--- .../Views/NCMediaViewerPageView.swift | 1 + 2 files changed, 98 insertions(+), 20 deletions(-) diff --git a/iOSClient/Viewer/NCViewerMedia/Content/Audio/NCAudioViewerContentView.swift b/iOSClient/Viewer/NCViewerMedia/Content/Audio/NCAudioViewerContentView.swift index 2c60f28f2f..202cd6d570 100644 --- a/iOSClient/Viewer/NCViewerMedia/Content/Audio/NCAudioViewerContentView.swift +++ b/iOSClient/Viewer/NCViewerMedia/Content/Audio/NCAudioViewerContentView.swift @@ -13,6 +13,7 @@ struct NCAudioViewerContentView: View { let localURL: URL let previewURL: URL? let backgroundStyle: NCViewerBackgroundStyle + let navigationBar: UINavigationBar? let canGoPrevious: Bool let canGoNext: Bool let shouldAutoPlay: Bool @@ -30,6 +31,7 @@ struct NCAudioViewerContentView: View { localURL: URL, previewURL: URL? = nil, backgroundStyle: NCViewerBackgroundStyle = .system, + navigationBar: UINavigationBar? = nil, canGoPrevious: Bool = false, canGoNext: Bool = false, shouldAutoPlay: Bool = false, @@ -44,6 +46,7 @@ struct NCAudioViewerContentView: View { self.localURL = localURL self.previewURL = previewURL self.backgroundStyle = backgroundStyle + self.navigationBar = navigationBar self.canGoPrevious = canGoPrevious self.canGoNext = canGoNext self.shouldAutoPlay = shouldAutoPlay @@ -72,6 +75,16 @@ struct NCAudioViewerContentView: View { let buttonSpacing: CGFloat = isLandscape ? 24 : 28 let sideButtonSize: CGFloat = isLandscape ? 30 : 34 let playButtonSize: CGFloat = isLandscape ? 64 : 72 + let navigationBarHeight: CGFloat = isLandscape ? 32 : 44 + let minimumNavigationBarBottom: CGFloat = isLandscape ? 32 : 64 + let fallbackNavigationBarBottom = max( + proxy.safeAreaInsets.top + navigationBarHeight, + minimumNavigationBarBottom + ) + let topActionsPadding = max( + navigationBar?.frame.maxY ?? 0, + fallbackNavigationBarBottom + ) + 4 ZStack { Color.ncViewerBackground(backgroundStyle) @@ -120,17 +133,7 @@ struct NCAudioViewerContentView: View { } .padding(.horizontal, sliderHorizontalPadding) - HStack(spacing: buttonSpacing) { - Button { - playbackOptions.toggleRepeat() - } label: { - Image(systemName: playbackOptions.isRepeatEnabled ? "repeat.1.circle.fill" : "repeat.1.circle") - .font(.system(size: sideButtonSize, weight: .regular)) - .foregroundStyle(playbackOptions.isRepeatEnabled ? primaryForegroundStyle : mutedForegroundStyle) - } - .buttonStyle(.plain) - .accessibilityLabel(NSLocalizedString("_repeat_current_media_", comment: "")) - + ZStack { Button { model.togglePlayback() } label: { @@ -143,25 +146,46 @@ struct NCAudioViewerContentView: View { Button { model.restart() } label: { - Image(systemName: "gobackward") + Image(systemName: "backward.end.circle") .font(.system(size: sideButtonSize, weight: .regular)) .foregroundStyle(mutedForegroundStyle) } .buttonStyle(.plain) .disabled(model.duration <= 0) + .offset( + x: -(playButtonSize / 2 + buttonSpacing + sideButtonSize / 2) + ) + } + .frame(height: playButtonSize) + .frame(maxWidth: .infinity) + } + .padding(.top, topPadding) - Button { + VStack { + HStack(spacing: 8) { + audioPlaybackOptionButton( + systemName: "repeat.1", + isActive: playbackOptions.isRepeatEnabled, + accessibilityLabel: "_repeat_current_media_" + ) { + playbackOptions.toggleRepeat() + } + + audioPlaybackOptionButton( + systemName: playbackOptions.isAutoAdvanceEnabled ? "forward.end.fill" : "forward.end", + isActive: playbackOptions.isAutoAdvanceEnabled, + accessibilityLabel: "_play_next_media_automatically_" + ) { playbackOptions.toggleAutoAdvance() - } label: { - Image(systemName: playbackOptions.isAutoAdvanceEnabled ? "forward.end.circle.fill" : "forward.end.circle") - .font(.system(size: sideButtonSize, weight: .regular)) - .foregroundStyle(playbackOptions.isAutoAdvanceEnabled ? primaryForegroundStyle : mutedForegroundStyle) } - .buttonStyle(.plain) - .accessibilityLabel(NSLocalizedString("_play_next_media_automatically_", comment: "")) + + Spacer() } + .padding(.leading, 28) + .padding(.top, topActionsPadding) + + Spacer() } - .padding(.top, topPadding) } .frame(maxWidth: .infinity, maxHeight: .infinity) } @@ -212,6 +236,29 @@ struct NCAudioViewerContentView: View { } } + private func audioPlaybackOptionButton( + systemName: String, + isActive: Bool, + accessibilityLabel: String, + action: @escaping () -> Void + ) -> some View { + Button(action: action) { + Image(systemName: systemName) + .font(.system(size: 17, weight: .regular)) + .foregroundStyle(isActive ? Color.accentColor : Color.primary) + .shadow( + color: .black.opacity(0.35), + radius: 2, + x: 0, + y: 1 + ) + .frame(width: 38, height: 38) + .audioControlGlassBackground(shape: Circle()) + } + .buttonStyle(.plain) + .accessibilityLabel(NSLocalizedString(accessibilityLabel, comment: "")) + } + private var previewImage: UIImage? { guard let previewURL, previewURL.isFileURL else { @@ -332,6 +379,36 @@ struct NCAudioViewerContentView: View { } } +private extension View { + @ViewBuilder + func audioControlGlassBackground( + shape: BackgroundShape + ) -> some View { + if #available(iOS 26.0, *) { + self + .glassEffect(.regular, in: shape) + .overlay { + shape + .stroke(.white.opacity(0.58), lineWidth: 1.2) + } + .shadow( + color: .black.opacity(0.18), + radius: 14, + x: 0, + y: 4 + ) + } else { + self + .background(.ultraThinMaterial, in: shape) + .overlay { + shape + .stroke(.primary.opacity(0.12), lineWidth: 1) + } + .clipShape(shape) + } + } +} + // MARK: - Audio Viewer Playback Registry // Keeps audio models alive across SwiftUI rebuilds. diff --git a/iOSClient/Viewer/NCViewerMedia/Views/NCMediaViewerPageView.swift b/iOSClient/Viewer/NCViewerMedia/Views/NCMediaViewerPageView.swift index 6aefff7856..02476cc3a0 100644 --- a/iOSClient/Viewer/NCViewerMedia/Views/NCMediaViewerPageView.swift +++ b/iOSClient/Viewer/NCViewerMedia/Views/NCMediaViewerPageView.swift @@ -245,6 +245,7 @@ struct NCMediaViewerPageView: View { localURL: localURL, previewURL: previewURL, backgroundStyle: backgroundStyle, + navigationBar: navigationBar, canGoPrevious: canGoPrevious, canGoNext: canGoNext, shouldAutoPlay: effectiveShouldAutoPlay, From 2a969f3722d211ab78ddd454fddf81d656c31cea Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Tue, 11 Aug 2026 09:40:02 +0200 Subject: [PATCH 05/14] chore: bump project build version to 2 Signed-off-by: Marino Faggiana --- Nextcloud.xcodeproj/project.pbxproj | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/Nextcloud.xcodeproj/project.pbxproj b/Nextcloud.xcodeproj/project.pbxproj index 415c0c1ec9..574006438a 100644 --- a/Nextcloud.xcodeproj/project.pbxproj +++ b/Nextcloud.xcodeproj/project.pbxproj @@ -40,6 +40,8 @@ AA8E041D2D300FDE00E7E89C /* NCShareNetworkingDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA8E041C2D300FDE00E7E89C /* NCShareNetworkingDelegate.swift */; }; AAA7BC2E2D3E39F1008F1A22 /* CapabilitiesResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAA7BC2D2D3E39EC008F1A22 /* CapabilitiesResponse.swift */; }; AAA7BC302D3E3B88008F1A22 /* CapabilityResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAA7BC2F2D3E3B83008F1A22 /* CapabilityResponse.swift */; }; + AABBCC0230A8000100F0A001 /* NCMediaPlaybackOptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = AABBCC0130A8000100F0A001 /* NCMediaPlaybackOptions.swift */; }; + AABBCC0430A8000100F0A001 /* NCMediaPlaybackOptionsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AABBCC0330A8000100F0A001 /* NCMediaPlaybackOptionsTests.swift */; }; AABD0C8A2D5F67A400F009E6 /* XCUIElement.swift in Sources */ = {isa = PBXBuildFile; fileRef = AABD0C892D5F67A200F009E6 /* XCUIElement.swift */; }; AABD0C9B2D5F73FC00F009E6 /* Placeholder.swift in Sources */ = {isa = PBXBuildFile; fileRef = AABD0C9A2D5F73FA00F009E6 /* Placeholder.swift */; }; AAE330042D2ED20200B04903 /* NCShareNavigationTitleSetting.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAE330032D2ED1FF00B04903 /* NCShareNavigationTitleSetting.swift */; }; @@ -845,8 +847,6 @@ F7CDB5C32FA33CA300F72306 /* NCMediaViewerPageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7CDB5B92FA33CA300F72306 /* NCMediaViewerPageView.swift */; }; F7CDB5C42FA33CA300F72306 /* NCImageViewerContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7CDB5B62FA33CA300F72306 /* NCImageViewerContentView.swift */; }; F7CDB5C52FA33CA300F72306 /* NCMediaViewerModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7CDB5B82FA33CA300F72306 /* NCMediaViewerModel.swift */; }; - AABBCC0230A8000100F0A001 /* NCMediaPlaybackOptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = AABBCC0130A8000100F0A001 /* NCMediaPlaybackOptions.swift */; }; - AABBCC0430A8000100F0A001 /* NCMediaPlaybackOptionsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AABBCC0330A8000100F0A001 /* NCMediaPlaybackOptionsTests.swift */; }; F7CDB5C62FA33CA300F72306 /* NCMediaViewerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7CDB5BB2FA33CA300F72306 /* NCMediaViewerView.swift */; }; F7CDB5CC2FA33CA300F72306 /* NCNextcloudMediaViewerLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7CDB5BD2FA33CA300F72306 /* NCNextcloudMediaViewerLoader.swift */; }; F7CDB5D32FA3448B00F72306 /* NCAudioViewerContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7CDB5D22FA3448A00F72306 /* NCAudioViewerContentView.swift */; }; @@ -1282,6 +1282,8 @@ AA9B6A902DF1D8F7009D805D /* et */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = et; path = et.lproj/InfoPlist.strings; sourceTree = ""; }; AAA7BC2D2D3E39EC008F1A22 /* CapabilitiesResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CapabilitiesResponse.swift; sourceTree = ""; }; AAA7BC2F2D3E3B83008F1A22 /* CapabilityResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CapabilityResponse.swift; sourceTree = ""; }; + AABBCC0130A8000100F0A001 /* NCMediaPlaybackOptions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCMediaPlaybackOptions.swift; sourceTree = ""; }; + AABBCC0330A8000100F0A001 /* NCMediaPlaybackOptionsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCMediaPlaybackOptionsTests.swift; sourceTree = ""; }; AABD0C862D5F58C400F009E6 /* Server.sh */ = {isa = PBXFileReference; lastKnownFileType = text.script.sh; path = Server.sh; sourceTree = ""; }; AABD0C892D5F67A200F009E6 /* XCUIElement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XCUIElement.swift; sourceTree = ""; }; AABD0C9A2D5F73FA00F009E6 /* Placeholder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Placeholder.swift; sourceTree = ""; }; @@ -1859,8 +1861,6 @@ F7CCAB502ECF315F00F8E68B /* NCCollectionViewCommon+SyncMetadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NCCollectionViewCommon+SyncMetadata.swift"; sourceTree = ""; }; F7CDB5B62FA33CA300F72306 /* NCImageViewerContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCImageViewerContentView.swift; sourceTree = ""; }; F7CDB5B82FA33CA300F72306 /* NCMediaViewerModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCMediaViewerModel.swift; sourceTree = ""; }; - AABBCC0130A8000100F0A001 /* NCMediaPlaybackOptions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCMediaPlaybackOptions.swift; sourceTree = ""; }; - AABBCC0330A8000100F0A001 /* NCMediaPlaybackOptionsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCMediaPlaybackOptionsTests.swift; sourceTree = ""; }; F7CDB5B92FA33CA300F72306 /* NCMediaViewerPageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCMediaViewerPageView.swift; sourceTree = ""; }; F7CDB5BB2FA33CA300F72306 /* NCMediaViewerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCMediaViewerView.swift; sourceTree = ""; }; F7CDB5BD2FA33CA300F72306 /* NCNextcloudMediaViewerLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCNextcloudMediaViewerLoader.swift; sourceTree = ""; }; @@ -6327,7 +6327,7 @@ CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = NKUJUXUJ3B; @@ -6395,7 +6395,7 @@ CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = NKUJUXUJ3B; From 97332cb7f12793fb67e67c4b39256c251dfff918 Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Tue, 11 Aug 2026 10:46:24 +0200 Subject: [PATCH 06/14] fix: improve VLC playback progress and seeking Track playback using elapsed media time, keep progress updates active during UI interactions, and seek by timestamp after scrubbing. Signed-off-by: Marino Faggiana --- .../Video/VLC/NCVideoVLCViewControls.swift | 36 ++++++++++++++++--- 1 file changed, 31 insertions(+), 5 deletions(-) diff --git a/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCViewControls.swift b/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCViewControls.swift index 1478e9f83e..c01c2540ef 100644 --- a/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCViewControls.swift +++ b/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCViewControls.swift @@ -36,12 +36,15 @@ extension NCVideoVLCViewController { func startProgressTimer() { stopProgressTimer() - progressTimer = Timer.scheduledTimer( - withTimeInterval: 0.35, + let timer = Timer( + timeInterval: 0.35, repeats: true ) { [weak self] _ in self?.updateProgressControls() } + + progressTimer = timer + RunLoop.main.add(timer, forMode: .common) } func stopProgressTimer() { @@ -54,11 +57,22 @@ extension NCVideoVLCViewController { return } - let position = max(0, min(1, mediaPlayer.position)) + let position = currentPlaybackProgress() updateProgressLabels(position: position) updatePlayPauseButton() } + private func currentPlaybackProgress() -> Float { + let duration = mediaPlayer.media?.length.intValue ?? 0 + + guard duration > 0 else { + return max(0, min(1, mediaPlayer.position)) + } + + let elapsed = max(0, min(duration, mediaPlayer.time.intValue)) + return Float(elapsed) / Float(duration) + } + func updateProgressLabels(position: Float) { let duration = mediaPlayer.media?.length.intValue ?? 0 let elapsed = Int(Float(duration) * position) @@ -262,9 +276,21 @@ extension NCVideoVLCViewController: NCVideoControlsViewDelegate { } func videoControlsDidEndScrubbing(_ controlsView: NCVideoControlsView, progress: Float) { - mediaPlayer.position = progress + let progress = max(0, min(1, progress)) + let duration = mediaPlayer.media?.length.intValue ?? 0 + + if duration > 0 { + let targetTime = Int32( + (Float(duration) * progress).rounded() + ) + mediaPlayer.time = VLCTime(int: targetTime) + } else { + mediaPlayer.position = progress + } + isScrubbing = false - updateProgressControls() + updateProgressLabels(position: progress) + startProgressTimer() scheduleControlsHide() } } From 0bb59a20271c9fa99d0f74640d1f276b0d376c20 Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Tue, 11 Aug 2026 12:36:26 +0200 Subject: [PATCH 07/14] fix: preserve hidden video controls during autoplay Signed-off-by: Marino Faggiana --- .../AVPlayer/NCVideoAVPlayerPresenter.swift | 11 ++++ .../NCVideoAVPlayerViewController.swift | 62 ++++++++++++++++--- .../NCVideoAVPlayerViewControls.swift | 4 +- .../NCVideoViewerContentView+AVPlayer.swift | 1 + .../Video/VLC/NCVideoVLCPresenter.swift | 11 ++++ .../Video/VLC/NCVideoVLCViewController.swift | 56 ++++++++++++++--- .../Video/VLC/NCVideoVLCViewControls.swift | 2 +- .../VLC/NCVideoViewerContentView+VLC.swift | 1 + .../Core/NCMediaViewerModel.swift | 8 +++ .../Views/NCMediaViewerPagingView.swift | 17 ++++- 10 files changed, 153 insertions(+), 20 deletions(-) diff --git a/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoAVPlayerPresenter.swift b/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoAVPlayerPresenter.swift index f0b993c40d..63966bc108 100644 --- a/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoAVPlayerPresenter.swift +++ b/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoAVPlayerPresenter.swift @@ -20,6 +20,7 @@ enum NCVideoAVPlayerPresenter { preparedPlayback: NCVideoAVPreparedPlayback, userAgent: String?, shouldAutoPlayOnStart: Bool = true, + shouldShowControlsOnStart: Bool = true, isChromeHidden: Bool = false, contextMenuController: NCMainTabBarController?, playbackOptions: NCMediaPlaybackOptions, @@ -39,6 +40,7 @@ enum NCVideoAVPlayerPresenter { preparedPlayback: preparedPlayback, userAgent: userAgent, shouldAutoPlayOnStart: shouldAutoPlayOnStart, + shouldShowControlsOnStart: shouldShowControlsOnStart, isChromeHidden: isChromeHidden, contextMenuController: contextMenuController, playbackOptions: playbackOptions @@ -64,6 +66,7 @@ enum NCVideoAVPlayerPresenter { preparedPlayback: preparedPlayback, userAgent: userAgent, shouldAutoPlayOnStart: shouldAutoPlayOnStart, + shouldShowControlsOnStart: shouldShowControlsOnStart, isChromeHidden: isChromeHidden, contextMenuController: contextMenuController, playbackOptions: playbackOptions @@ -106,6 +109,7 @@ enum NCVideoAVPlayerPresenter { preparedPlayback: preparedPlayback, userAgent: userAgent, shouldAutoPlayOnStart: shouldAutoPlayOnStart, + shouldShowControlsOnStart: shouldShowControlsOnStart, isChromeHidden: isChromeHidden, contextMenuController: contextMenuController, playbackOptions: playbackOptions @@ -133,6 +137,13 @@ enum NCVideoAVPlayerPresenter { .foregroundColor: UIColor.white ] + if !shouldShowControlsOnStart { + navigationController.setNavigationBarHidden( + true, + animated: false + ) + } + presenter.present( navigationController, animated: false diff --git a/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoAVPlayerViewController.swift b/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoAVPlayerViewController.swift index 303e4eaa70..ad771f5e2b 100644 --- a/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoAVPlayerViewController.swift +++ b/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoAVPlayerViewController.swift @@ -39,6 +39,7 @@ final class NCVideoAVPlayerViewController: UIViewController { private var url: URL private var userAgent: String? private var shouldAutoPlayOnStart: Bool + private var shouldShowControlsOnStart: Bool private var isChromeHidden: Bool private weak var contextMenuController: NCMainTabBarController? internal var playbackOptions: NCMediaPlaybackOptions @@ -87,6 +88,8 @@ final class NCVideoAVPlayerViewController: UIViewController { private var preparedURL: URL? private var hasReportedPlaybackError = false internal var isPlaybackRequested = false + private var isRepeatRestartInProgress = false + private var suppressesInitialControlsUntilPlaybackStarts = false var isPictureInPictureActive: Bool { pictureInPictureController?.isPictureInPictureActive == true @@ -113,6 +116,7 @@ final class NCVideoAVPlayerViewController: UIViewController { preparedPlayback: NCVideoAVPreparedPlayback, userAgent: String?, shouldAutoPlayOnStart: Bool = true, + shouldShowControlsOnStart: Bool = true, isChromeHidden: Bool = false, contextMenuController: NCMainTabBarController?, playbackOptions: NCMediaPlaybackOptions @@ -123,6 +127,7 @@ final class NCVideoAVPlayerViewController: UIViewController { self.player = preparedPlayback.player self.userAgent = userAgent self.shouldAutoPlayOnStart = shouldAutoPlayOnStart + self.shouldShowControlsOnStart = shouldShowControlsOnStart self.isChromeHidden = isChromeHidden self.contextMenuController = contextMenuController self.playbackOptions = playbackOptions @@ -184,6 +189,12 @@ final class NCVideoAVPlayerViewController: UIViewController { ]) updateControlsNavigationBar() + + if !shouldShowControlsOnStart { + controlsView.alpha = 0 + controlsView.isHidden = true + } + view = rootView } @@ -206,8 +217,7 @@ final class NCVideoAVPlayerViewController: UIViewController { let shouldPreserveHiddenChromeBackground = isChromeHidden start() - showControls(animated: false) - stopControlsHideTimer() + applyControlsVisibilityOnStart() if shouldPreserveHiddenChromeBackground { updateViewerBackground(isChromeHidden: true) @@ -245,6 +255,7 @@ final class NCVideoAVPlayerViewController: UIViewController { preparedPlayback: NCVideoAVPreparedPlayback, userAgent: String?, shouldAutoPlayOnStart: Bool = true, + shouldShowControlsOnStart: Bool = true, isChromeHidden: Bool = false, contextMenuController: NCMainTabBarController?, playbackOptions: NCMediaPlaybackOptions @@ -261,6 +272,7 @@ final class NCVideoAVPlayerViewController: UIViewController { self.metadata = metadata self.userAgent = userAgent self.shouldAutoPlayOnStart = shouldAutoPlayOnStart + self.shouldShowControlsOnStart = shouldShowControlsOnStart self.contextMenuController = contextMenuController self.playbackOptions = playbackOptions updateViewerBackground(isChromeHidden: isChromeHidden) @@ -268,6 +280,7 @@ final class NCVideoAVPlayerViewController: UIViewController { if urlChanged { start() + applyControlsVisibilityOnStart() } updatePlayPauseButton() @@ -492,6 +505,9 @@ final class NCVideoAVPlayerViewController: UIViewController { private func start() { hasReportedPlaybackError = false + suppressesInitialControlsUntilPlaybackStarts = shouldAutoPlayOnStart && + !shouldShowControlsOnStart && + player.timeControlStatus != .playing isPlaybackRequested = shouldAutoPlayOnStart cancelPlaybackStartupTimeout() @@ -525,6 +541,8 @@ final class NCVideoAVPlayerViewController: UIViewController { private func stop() { preparedURL = nil isPlaybackRequested = false + isRepeatRestartInProgress = false + suppressesInitialControlsUntilPlaybackStarts = false cancelPlaybackStartupTimeout() player.pause() @@ -539,6 +557,15 @@ final class NCVideoAVPlayerViewController: UIViewController { updateProgressControls() } + private func applyControlsVisibilityOnStart() { + if shouldShowControlsOnStart { + showControls(animated: false) + stopControlsHideTimer() + } else { + hideControls(animated: false) + } + } + private func configurePlayerLayer() { playerContainerView.playerLayer.videoGravity = .resizeAspect playerContainerView.player = player @@ -685,7 +712,8 @@ final class NCVideoAVPlayerViewController: UIViewController { updatePlayPauseButton() } - if !controlsVisible, + if shouldShowControlsOnStart, + !controlsVisible, !isPictureInPictureActive { showControls(animated: false) scheduleControlsHide() @@ -735,6 +763,8 @@ final class NCVideoAVPlayerViewController: UIViewController { } hasReportedPlaybackError = true + isRepeatRestartInProgress = false + suppressesInitialControlsUntilPlaybackStarts = false isPlaybackRequested = false cancelPlaybackStartupTimeout() @@ -756,13 +786,18 @@ final class NCVideoAVPlayerViewController: UIViewController { switch player.timeControlStatus { case .playing: isPlaybackRequested = true + isRepeatRestartInProgress = false + suppressesInitialControlsUntilPlaybackStarts = false cancelPlaybackStartupTimeout() case .waitingToPlayAtSpecifiedRate: isPlaybackRequested = true case .paused: - if player.currentItem?.status == .readyToPlay || + if !isRepeatRestartInProgress, + !suppressesInitialControlsUntilPlaybackStarts, + !isScrubbing, + player.currentItem?.status == .readyToPlay || player.currentItem?.status == .failed || player.currentItem == nil { isPlaybackRequested = false @@ -775,6 +810,12 @@ final class NCVideoAVPlayerViewController: UIViewController { updatePlayPauseButton() guard player.timeControlStatus == .playing else { + guard !isRepeatRestartInProgress, + !suppressesInitialControlsUntilPlaybackStarts, + !isScrubbing else { + return + } + if !isPlaybackRequested { showControls(animated: false) stopControlsHideTimer() @@ -799,7 +840,6 @@ final class NCVideoAVPlayerViewController: UIViewController { case .playNextItem: updatePlayPauseButton() updateProgressControls() - showControls(animated: true) onPlaybackEnded?() return @@ -813,6 +853,7 @@ final class NCVideoAVPlayerViewController: UIViewController { } private func repeatCurrentItem() { + isRepeatRestartInProgress = true isPlaybackRequested = true updatePlayPauseButton() @@ -821,8 +862,7 @@ final class NCVideoAVPlayerViewController: UIViewController { toleranceBefore: .zero, toleranceAfter: .zero ) { [weak self, weak player] didFinish in - guard didFinish, - let self, + guard let self, let player else { return } @@ -832,6 +872,14 @@ final class NCVideoAVPlayerViewController: UIViewController { return } + guard didFinish else { + self.isRepeatRestartInProgress = false + self.isPlaybackRequested = false + self.updatePlayPauseButton() + self.showControls(animated: true) + return + } + player.play() self.updateProgressControls() self.scheduleControlsHide() diff --git a/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoAVPlayerViewControls.swift b/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoAVPlayerViewControls.swift index db62aed48c..907ccd0281 100644 --- a/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoAVPlayerViewControls.swift +++ b/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoAVPlayerViewControls.swift @@ -242,7 +242,7 @@ extension NCVideoAVPlayerViewController: NCVideoControlsViewDelegate { duration.isFinite, duration > 0 else { isScrubbing = false - scheduleControlsHide() + hideControls(animated: true) return } @@ -259,7 +259,7 @@ extension NCVideoAVPlayerViewController: NCVideoControlsViewDelegate { Task { @MainActor in self?.isScrubbing = false self?.updateProgressControls() - self?.scheduleControlsHide() + self?.hideControls(animated: true) } } } diff --git a/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoViewerContentView+AVPlayer.swift b/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoViewerContentView+AVPlayer.swift index 47c2c7f758..04fe54dd84 100644 --- a/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoViewerContentView+AVPlayer.swift +++ b/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoViewerContentView+AVPlayer.swift @@ -28,6 +28,7 @@ extension NCVideoViewerContentView { preparedPlayback: preparedPlayback, userAgent: userAgent, shouldAutoPlayOnStart: true, + shouldShowControlsOnStart: !shouldAutoPlay, isChromeHidden: isChromeHidden, contextMenuController: contextMenuController, playbackOptions: playbackOptions, diff --git a/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCPresenter.swift b/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCPresenter.swift index 5eb1cd9d91..4bcdf02457 100644 --- a/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCPresenter.swift +++ b/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCPresenter.swift @@ -23,6 +23,7 @@ enum NCVideoVLCPresenter { preparedPlayback: NCVideoVLCPreparedPlayback, userAgent: String?, shouldAutoPlayOnStart: Bool = true, + shouldShowControlsOnStart: Bool = true, isChromeHidden: Bool = false, contextMenuController: NCMainTabBarController?, playbackOptions: NCMediaPlaybackOptions, @@ -48,6 +49,7 @@ enum NCVideoVLCPresenter { preparedPlayback: preparedPlayback, userAgent: userAgent, shouldAutoPlayOnStart: shouldAutoPlayOnStart, + shouldShowControlsOnStart: shouldShowControlsOnStart, isChromeHidden: isChromeHidden, contextMenuController: contextMenuController, playbackOptions: playbackOptions @@ -73,6 +75,7 @@ enum NCVideoVLCPresenter { preparedPlayback: preparedPlayback, userAgent: userAgent, shouldAutoPlayOnStart: shouldAutoPlayOnStart, + shouldShowControlsOnStart: shouldShowControlsOnStart, isChromeHidden: isChromeHidden, contextMenuController: contextMenuController, playbackOptions: playbackOptions @@ -117,6 +120,7 @@ enum NCVideoVLCPresenter { preparedPlayback: preparedPlayback, userAgent: userAgent, shouldAutoPlayOnStart: shouldAutoPlayOnStart, + shouldShowControlsOnStart: shouldShowControlsOnStart, isChromeHidden: isChromeHidden, contextMenuController: contextMenuController, playbackOptions: playbackOptions @@ -144,6 +148,13 @@ enum NCVideoVLCPresenter { .foregroundColor: UIColor.white ] + if !shouldShowControlsOnStart { + navigationController.setNavigationBarHidden( + true, + animated: false + ) + } + presenter.present( navigationController, animated: false diff --git a/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCViewController.swift b/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCViewController.swift index 229364b6ab..5fa94abbd9 100644 --- a/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCViewController.swift +++ b/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCViewController.swift @@ -18,10 +18,13 @@ final class NCVideoVLCViewController: UIViewController { private var url: URL private var userAgent: String? private var shouldAutoPlayOnStart: Bool + private var shouldShowControlsOnStart: Bool private var isChromeHidden: Bool private weak var contextMenuController: NCMainTabBarController? internal var playbackOptions: NCMediaPlaybackOptions private var isReplayFromBeginningRequested = false + private var isRepeatRestartInProgress = false + private var suppressesInitialControlsUntilPlaybackStarts = false // MARK: - Paging Callbacks @@ -86,6 +89,7 @@ final class NCVideoVLCViewController: UIViewController { preparedPlayback: NCVideoVLCPreparedPlayback, userAgent: String?, shouldAutoPlayOnStart: Bool = true, + shouldShowControlsOnStart: Bool = true, isChromeHidden: Bool = false, contextMenuController: NCMainTabBarController?, playbackOptions: NCMediaPlaybackOptions @@ -95,6 +99,7 @@ final class NCVideoVLCViewController: UIViewController { self.url = preparedPlayback.url self.userAgent = userAgent self.shouldAutoPlayOnStart = shouldAutoPlayOnStart + self.shouldShowControlsOnStart = shouldShowControlsOnStart self.isChromeHidden = isChromeHidden self.contextMenuController = contextMenuController self.playbackOptions = playbackOptions @@ -157,6 +162,11 @@ final class NCVideoVLCViewController: UIViewController { controlsView.setTopActionsNavigationBar(navigationController?.navigationBar) + if !shouldShowControlsOnStart { + controlsView.alpha = 0 + controlsView.isHidden = true + } + view = rootView } @@ -177,8 +187,6 @@ final class NCVideoVLCViewController: UIViewController { super.viewDidAppear(animated) start() - showControls(animated: false) - stopControlsHideTimer() } override func viewDidLayoutSubviews() { @@ -212,6 +220,7 @@ final class NCVideoVLCViewController: UIViewController { preparedPlayback: NCVideoVLCPreparedPlayback, userAgent: String?, shouldAutoPlayOnStart: Bool = true, + shouldShowControlsOnStart: Bool = true, isChromeHidden: Bool = false, contextMenuController: NCMainTabBarController?, playbackOptions: NCMediaPlaybackOptions @@ -223,6 +232,7 @@ final class NCVideoVLCViewController: UIViewController { self.metadata = metadata self.userAgent = userAgent self.shouldAutoPlayOnStart = shouldAutoPlayOnStart + self.shouldShowControlsOnStart = shouldShowControlsOnStart self.isChromeHidden = isChromeHidden self.contextMenuController = contextMenuController self.playbackOptions = playbackOptions @@ -434,6 +444,10 @@ final class NCVideoVLCViewController: UIViewController { private func start() { hasEnteredPlaybackPipeline = false hasReportedPlaybackError = false + isRepeatRestartInProgress = false + suppressesInitialControlsUntilPlaybackStarts = shouldAutoPlayOnStart && + !shouldShowControlsOnStart && + mediaPlayer.state != .playing isPlaybackRequested = shouldAutoPlayOnStart cancelPlaybackStartupTimeout() attachDrawable() @@ -451,8 +465,16 @@ final class NCVideoVLCViewController: UIViewController { updateProgressControls() clearVLCTrackMenuItems() startProgressTimer() - showControls(animated: false) - stopControlsHideTimer() + applyControlsVisibilityOnStart() + } + + private func applyControlsVisibilityOnStart() { + if shouldShowControlsOnStart { + showControls(animated: false) + stopControlsHideTimer() + } else { + hideControls(animated: false) + } } func stop(completion: (() -> Void)? = nil) { @@ -468,6 +490,8 @@ final class NCVideoVLCViewController: UIViewController { cancelPlaybackStartupTimeout() isPlaybackRequested = false isReplayFromBeginningRequested = false + isRepeatRestartInProgress = false + suppressesInitialControlsUntilPlaybackStarts = false if mediaPlayer.media == nil || (mediaPlayer.state == .stopped && !hadPendingPlaybackRequest) { @@ -600,6 +624,8 @@ final class NCVideoVLCViewController: UIViewController { } hasReportedPlaybackError = true + isRepeatRestartInProgress = false + suppressesInitialControlsUntilPlaybackStarts = false isPlaybackRequested = false cancelPlaybackStartupTimeout() onPlaybackError?() @@ -659,6 +685,8 @@ final class NCVideoVLCViewController: UIViewController { switch mediaPlayer.state { case .playing: isPlaybackRequested = true + isRepeatRestartInProgress = false + suppressesInitialControlsUntilPlaybackStarts = false cancelPlaybackStartupTimeout() case .ended: @@ -667,14 +695,13 @@ final class NCVideoVLCViewController: UIViewController { switch playbackOptions.completionAction { case .repeatCurrentItem: + isRepeatRestartInProgress = true restartPlaybackFromBeginning() return case .playNextItem: updatePlayPauseButton() updateProgressLabels(position: 1) - showControls(animated: true) - stopControlsHideTimer() onPlaybackEnded?() return @@ -709,8 +736,15 @@ final class NCVideoVLCViewController: UIViewController { isPlaybackRequested = false cancelPlaybackStartupTimeout() - case .paused, - .error: + case .paused: + if !isRepeatRestartInProgress, + !suppressesInitialControlsUntilPlaybackStarts, + !isScrubbing { + isPlaybackRequested = false + } + + case .error: + isRepeatRestartInProgress = false isPlaybackRequested = false default: @@ -722,6 +756,12 @@ final class NCVideoVLCViewController: UIViewController { refreshVLCTrackMenuItemsWhenPlayerIsActive() guard mediaPlayer.state == .playing else { + guard !isRepeatRestartInProgress, + !suppressesInitialControlsUntilPlaybackStarts, + !isScrubbing else { + return + } + if !isPlaybackRequested { showControls(animated: false) stopControlsHideTimer() diff --git a/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCViewControls.swift b/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCViewControls.swift index c01c2540ef..c333d5e5b6 100644 --- a/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCViewControls.swift +++ b/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCViewControls.swift @@ -291,6 +291,6 @@ extension NCVideoVLCViewController: NCVideoControlsViewDelegate { isScrubbing = false updateProgressLabels(position: progress) startProgressTimer() - scheduleControlsHide() + hideControls(animated: true) } } diff --git a/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoViewerContentView+VLC.swift b/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoViewerContentView+VLC.swift index d2a4b88838..95acb85689 100644 --- a/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoViewerContentView+VLC.swift +++ b/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoViewerContentView+VLC.swift @@ -28,6 +28,7 @@ extension NCVideoViewerContentView { preparedPlayback: preparedPlayback, userAgent: userAgent, shouldAutoPlayOnStart: true, + shouldShowControlsOnStart: !shouldAutoPlay, isChromeHidden: isChromeHidden, contextMenuController: contextMenuController, playbackOptions: playbackOptions, diff --git a/iOSClient/Viewer/NCViewerMedia/Core/NCMediaViewerModel.swift b/iOSClient/Viewer/NCViewerMedia/Core/NCMediaViewerModel.swift index 52786b00a9..55a881aedd 100644 --- a/iOSClient/Viewer/NCViewerMedia/Core/NCMediaViewerModel.swift +++ b/iOSClient/Viewer/NCViewerMedia/Core/NCMediaViewerModel.swift @@ -484,6 +484,14 @@ final class NCMediaViewerModel: ObservableObject { isChromeHidden.toggle() } + func setChromeHidden(_ isHidden: Bool) { + guard isChromeHidden != isHidden else { + return + } + + isChromeHidden = isHidden + } + func previewURL( for metadata: tableMetadata, ext: String diff --git a/iOSClient/Viewer/NCViewerMedia/Views/NCMediaViewerPagingView.swift b/iOSClient/Viewer/NCViewerMedia/Views/NCMediaViewerPagingView.swift index c91711a058..47d28719c7 100644 --- a/iOSClient/Viewer/NCViewerMedia/Views/NCMediaViewerPagingView.swift +++ b/iOSClient/Viewer/NCViewerMedia/Views/NCMediaViewerPagingView.swift @@ -447,6 +447,10 @@ final class NCMediaViewerPagingCoordinator: NSObject, return } + if !shouldAutoPlay { + model.setChromeHidden(false) + } + // Stop the current media playback before programmatic page navigation. // This is intentionally broad because previous/next can move across image, // audio, AVPlayer, and VLC pages. @@ -497,13 +501,22 @@ final class NCMediaViewerPagingCoordinator: NSObject, return } + // Auto-next should remain immersive while the fullscreen player is + // replaced. Manual paging keeps its existing chrome behavior. + let wasChromeHidden = self.model.isChromeHidden + self.model.setChromeHidden(true) + // Video transitions must wait until the current fullscreen player // has fully disappeared. Audio has no fullscreen presenter and can // navigate immediately through the normal paging path above. NCVideoAVPlayerPresenter.dismiss { [weak self] in NCVideoVLCPresenter.dismiss { [weak self] in - guard let self, - self.model.selectedIndex == sourceIndex else { + guard let self else { + return + } + + guard self.model.selectedIndex == sourceIndex else { + self.model.setChromeHidden(wasChromeHidden) return } From 47e8869b991db1947778d4c670bd34a2a9022fe0 Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Tue, 11 Aug 2026 12:57:50 +0200 Subject: [PATCH 08/14] fix: stabilize video controls during playback transitions Share presentation state across AVPlayer and VLC to suppress controls during automatic playback, repeat restarts, and seeking. Signed-off-by: Marino Faggiana --- Nextcloud.xcodeproj/project.pbxproj | 8 ++ ...ideoPlaybackPresentationContextTests.swift | 109 ++++++++++++++++++ .../AVPlayer/NCVideoAVPlayerPresenter.swift | 10 +- .../NCVideoAVPlayerViewController.swift | 56 ++++----- .../NCVideoAVPlayerViewControls.swift | 8 +- .../NCVideoViewerContentView+AVPlayer.swift | 2 +- .../Video/VLC/NCVideoVLCPresenter.swift | 10 +- .../Video/VLC/NCVideoVLCViewController.swift | 54 ++++----- .../Video/VLC/NCVideoVLCViewControls.swift | 8 +- .../VLC/NCVideoViewerContentView+VLC.swift | 2 +- .../Core/NCMediaPlaybackOptions.swift | 5 +- .../NCVideoPlaybackPresentationContext.swift | 89 ++++++++++++++ 12 files changed, 278 insertions(+), 83 deletions(-) create mode 100644 Tests/NextcloudUnitTests/NCVideoPlaybackPresentationContextTests.swift create mode 100644 iOSClient/Viewer/NCViewerMedia/Core/NCVideoPlaybackPresentationContext.swift diff --git a/Nextcloud.xcodeproj/project.pbxproj b/Nextcloud.xcodeproj/project.pbxproj index 574006438a..3727f8d584 100644 --- a/Nextcloud.xcodeproj/project.pbxproj +++ b/Nextcloud.xcodeproj/project.pbxproj @@ -42,6 +42,8 @@ AAA7BC302D3E3B88008F1A22 /* CapabilityResponse.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAA7BC2F2D3E3B83008F1A22 /* CapabilityResponse.swift */; }; AABBCC0230A8000100F0A001 /* NCMediaPlaybackOptions.swift in Sources */ = {isa = PBXBuildFile; fileRef = AABBCC0130A8000100F0A001 /* NCMediaPlaybackOptions.swift */; }; AABBCC0430A8000100F0A001 /* NCMediaPlaybackOptionsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AABBCC0330A8000100F0A001 /* NCMediaPlaybackOptionsTests.swift */; }; + AABBCC0630A8000100F0A001 /* NCVideoPlaybackPresentationContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = AABBCC0530A8000100F0A001 /* NCVideoPlaybackPresentationContext.swift */; }; + AABBCC0830A8000100F0A001 /* NCVideoPlaybackPresentationContextTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AABBCC0730A8000100F0A001 /* NCVideoPlaybackPresentationContextTests.swift */; }; AABD0C8A2D5F67A400F009E6 /* XCUIElement.swift in Sources */ = {isa = PBXBuildFile; fileRef = AABD0C892D5F67A200F009E6 /* XCUIElement.swift */; }; AABD0C9B2D5F73FC00F009E6 /* Placeholder.swift in Sources */ = {isa = PBXBuildFile; fileRef = AABD0C9A2D5F73FA00F009E6 /* Placeholder.swift */; }; AAE330042D2ED20200B04903 /* NCShareNavigationTitleSetting.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAE330032D2ED1FF00B04903 /* NCShareNavigationTitleSetting.swift */; }; @@ -1284,6 +1286,8 @@ AAA7BC2F2D3E3B83008F1A22 /* CapabilityResponse.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CapabilityResponse.swift; sourceTree = ""; }; AABBCC0130A8000100F0A001 /* NCMediaPlaybackOptions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCMediaPlaybackOptions.swift; sourceTree = ""; }; AABBCC0330A8000100F0A001 /* NCMediaPlaybackOptionsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCMediaPlaybackOptionsTests.swift; sourceTree = ""; }; + AABBCC0530A8000100F0A001 /* NCVideoPlaybackPresentationContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCVideoPlaybackPresentationContext.swift; sourceTree = ""; }; + AABBCC0730A8000100F0A001 /* NCVideoPlaybackPresentationContextTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCVideoPlaybackPresentationContextTests.swift; sourceTree = ""; }; AABD0C862D5F58C400F009E6 /* Server.sh */ = {isa = PBXFileReference; lastKnownFileType = text.script.sh; path = Server.sh; sourceTree = ""; }; AABD0C892D5F67A200F009E6 /* XCUIElement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XCUIElement.swift; sourceTree = ""; }; AABD0C9A2D5F73FA00F009E6 /* Placeholder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Placeholder.swift; sourceTree = ""; }; @@ -2184,6 +2188,7 @@ isa = PBXGroup; children = ( AABBCC0330A8000100F0A001 /* NCMediaPlaybackOptionsTests.swift */, + AABBCC0730A8000100F0A001 /* NCVideoPlaybackPresentationContextTests.swift */, F34BDB3B2F574A58007A222C /* BidiSafeFilenameTests.swift */, C0DECA012F65000100C0D001 /* NCCameraRollTests.swift */, AA52EB452D42AC5A0089C348 /* Placeholder.swift */, @@ -2601,6 +2606,7 @@ isa = PBXGroup; children = ( AABBCC0130A8000100F0A001 /* NCMediaPlaybackOptions.swift */, + AABBCC0530A8000100F0A001 /* NCVideoPlaybackPresentationContext.swift */, F7CDB5BB2FA33CA300F72306 /* NCMediaViewerView.swift */, F7CDB5B82FA33CA300F72306 /* NCMediaViewerModel.swift */, ); @@ -4480,6 +4486,7 @@ buildActionMask = 2147483647; files = ( AABBCC0430A8000100F0A001 /* NCMediaPlaybackOptionsTests.swift in Sources */, + AABBCC0830A8000100F0A001 /* NCVideoPlaybackPresentationContextTests.swift in Sources */, AA52EB472D42AC9E0089C348 /* Placeholder.swift in Sources */, F34BDB3C2F574A58007A222C /* BidiSafeFilenameTests.swift in Sources */, C0DECA022F65000100C0D001 /* NCCameraRollTests.swift in Sources */, @@ -4892,6 +4899,7 @@ F7CDB5C42FA33CA300F72306 /* NCImageViewerContentView.swift in Sources */, F7CDB5C52FA33CA300F72306 /* NCMediaViewerModel.swift in Sources */, AABBCC0230A8000100F0A001 /* NCMediaPlaybackOptions.swift in Sources */, + AABBCC0630A8000100F0A001 /* NCVideoPlaybackPresentationContext.swift in Sources */, F7CDB5C62FA33CA300F72306 /* NCMediaViewerView.swift in Sources */, F7CDB5CC2FA33CA300F72306 /* NCNextcloudMediaViewerLoader.swift in Sources */, F76341182EBE0BC60056F538 /* NCNetworking+NextcloudKitDelegate.swift in Sources */, diff --git a/Tests/NextcloudUnitTests/NCVideoPlaybackPresentationContextTests.swift b/Tests/NextcloudUnitTests/NCVideoPlaybackPresentationContextTests.swift new file mode 100644 index 0000000000..caa1122cfc --- /dev/null +++ b/Tests/NextcloudUnitTests/NCVideoPlaybackPresentationContextTests.swift @@ -0,0 +1,109 @@ +// SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors +// SPDX-License-Identifier: GPL-2.0-or-later + +import Testing +@testable import Nextcloud + +@Suite("Video playback presentation context") +struct NCVideoPlaybackPresentationContextTests { + @Test("User-initiated playback starts with visible controls") + func userInitiatedPlaybackShowsControls() { + var context = NCVideoPlaybackPresentationContext( + startReason: .userInitiated + ) + + context.prepareForPlaybackStart() + + #expect(context.shouldShowControlsOnStart) + #expect(context.playbackTransition == .idle) + #expect(!context.shouldSuppressAutomaticControlsPresentation) + } + + @Test("Automatic advancement starts without presenting controls") + func automaticAdvanceSuppressesControls() { + var context = NCVideoPlaybackPresentationContext( + startReason: .automaticAdvance + ) + + context.prepareForPlaybackStart() + + #expect(!context.shouldShowControlsOnStart) + #expect(context.playbackTransition == .automaticStart) + #expect(context.shouldSuppressAutomaticControlsPresentation) + + context.finishPlaybackTransition() + + #expect(context.playbackTransition == .idle) + #expect(!context.shouldSuppressAutomaticControlsPresentation) + #expect(context.shouldShowControlsOnStart) + } + + @Test("Repeat restart suppresses automatic control presentation") + func repeatRestartSuppressesControls() { + var context = NCVideoPlaybackPresentationContext( + startReason: .userInitiated + ) + + context.beginRepeatRestart() + + #expect(context.playbackTransition == .repeatRestart) + #expect(context.shouldSuppressAutomaticControlsPresentation) + + context.finishPlaybackTransition() + + #expect(context.playbackTransition == .idle) + #expect(!context.shouldSuppressAutomaticControlsPresentation) + } + + @Test("Seeking is represented by the shared transition state") + func seekingUsesSharedTransition() { + var context = NCVideoPlaybackPresentationContext( + startReason: .userInitiated + ) + + context.beginSeeking() + + #expect(context.interaction == .seeking) + #expect(context.isSeeking) + #expect(context.shouldSuppressAutomaticControlsPresentation) + + context.finishSeeking() + + #expect(!context.isSeeking) + } + + @Test("Playback callbacks preserve seeking and complete automatic start") + func playbackCallbackPreservesSeekingAndCompletesAutomaticStart() { + var context = NCVideoPlaybackPresentationContext( + startReason: .automaticAdvance + ) + + context.prepareForPlaybackStart() + context.beginSeeking() + context.finishPlaybackTransition() + + #expect(context.isSeeking) + #expect(context.playbackTransition == .idle) + #expect(context.shouldShowControlsOnStart) + #expect(context.shouldSuppressAutomaticControlsPresentation) + + context.finishSeeking() + + #expect(!context.shouldSuppressAutomaticControlsPresentation) + } + + @Test("Reset clears playback and interaction transitions") + func resetClearsAllTransitions() { + var context = NCVideoPlaybackPresentationContext( + startReason: .automaticAdvance + ) + + context.prepareForPlaybackStart() + context.beginSeeking() + context.reset() + + #expect(context.playbackTransition == .idle) + #expect(context.interaction == .idle) + #expect(!context.shouldSuppressAutomaticControlsPresentation) + } +} diff --git a/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoAVPlayerPresenter.swift b/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoAVPlayerPresenter.swift index 63966bc108..d09cdfcc02 100644 --- a/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoAVPlayerPresenter.swift +++ b/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoAVPlayerPresenter.swift @@ -20,7 +20,7 @@ enum NCVideoAVPlayerPresenter { preparedPlayback: NCVideoAVPreparedPlayback, userAgent: String?, shouldAutoPlayOnStart: Bool = true, - shouldShowControlsOnStart: Bool = true, + playbackStartReason: NCVideoPlaybackPresentationContext.StartReason = .userInitiated, isChromeHidden: Bool = false, contextMenuController: NCMainTabBarController?, playbackOptions: NCMediaPlaybackOptions, @@ -40,7 +40,7 @@ enum NCVideoAVPlayerPresenter { preparedPlayback: preparedPlayback, userAgent: userAgent, shouldAutoPlayOnStart: shouldAutoPlayOnStart, - shouldShowControlsOnStart: shouldShowControlsOnStart, + playbackStartReason: playbackStartReason, isChromeHidden: isChromeHidden, contextMenuController: contextMenuController, playbackOptions: playbackOptions @@ -66,7 +66,7 @@ enum NCVideoAVPlayerPresenter { preparedPlayback: preparedPlayback, userAgent: userAgent, shouldAutoPlayOnStart: shouldAutoPlayOnStart, - shouldShowControlsOnStart: shouldShowControlsOnStart, + playbackStartReason: playbackStartReason, isChromeHidden: isChromeHidden, contextMenuController: contextMenuController, playbackOptions: playbackOptions @@ -109,7 +109,7 @@ enum NCVideoAVPlayerPresenter { preparedPlayback: preparedPlayback, userAgent: userAgent, shouldAutoPlayOnStart: shouldAutoPlayOnStart, - shouldShowControlsOnStart: shouldShowControlsOnStart, + playbackStartReason: playbackStartReason, isChromeHidden: isChromeHidden, contextMenuController: contextMenuController, playbackOptions: playbackOptions @@ -137,7 +137,7 @@ enum NCVideoAVPlayerPresenter { .foregroundColor: UIColor.white ] - if !shouldShowControlsOnStart { + if !playbackStartReason.shouldShowControlsOnStart { navigationController.setNavigationBarHidden( true, animated: false diff --git a/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoAVPlayerViewController.swift b/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoAVPlayerViewController.swift index ad771f5e2b..983d0b71e7 100644 --- a/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoAVPlayerViewController.swift +++ b/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoAVPlayerViewController.swift @@ -39,7 +39,6 @@ final class NCVideoAVPlayerViewController: UIViewController { private var url: URL private var userAgent: String? private var shouldAutoPlayOnStart: Bool - private var shouldShowControlsOnStart: Bool private var isChromeHidden: Bool private weak var contextMenuController: NCMainTabBarController? internal var playbackOptions: NCMediaPlaybackOptions @@ -75,7 +74,6 @@ final class NCVideoAVPlayerViewController: UIViewController { internal var controlsHideTimer: Timer? internal var controlsVisible = false - internal var isScrubbing = false private weak var closePanGesture: UIPanGestureRecognizer? private var pictureInPictureController: AVPictureInPictureController? @@ -88,8 +86,7 @@ final class NCVideoAVPlayerViewController: UIViewController { private var preparedURL: URL? private var hasReportedPlaybackError = false internal var isPlaybackRequested = false - private var isRepeatRestartInProgress = false - private var suppressesInitialControlsUntilPlaybackStarts = false + internal var playbackPresentationContext: NCVideoPlaybackPresentationContext var isPictureInPictureActive: Bool { pictureInPictureController?.isPictureInPictureActive == true @@ -116,7 +113,7 @@ final class NCVideoAVPlayerViewController: UIViewController { preparedPlayback: NCVideoAVPreparedPlayback, userAgent: String?, shouldAutoPlayOnStart: Bool = true, - shouldShowControlsOnStart: Bool = true, + playbackStartReason: NCVideoPlaybackPresentationContext.StartReason = .userInitiated, isChromeHidden: Bool = false, contextMenuController: NCMainTabBarController?, playbackOptions: NCMediaPlaybackOptions @@ -127,10 +124,12 @@ final class NCVideoAVPlayerViewController: UIViewController { self.player = preparedPlayback.player self.userAgent = userAgent self.shouldAutoPlayOnStart = shouldAutoPlayOnStart - self.shouldShowControlsOnStart = shouldShowControlsOnStart self.isChromeHidden = isChromeHidden self.contextMenuController = contextMenuController self.playbackOptions = playbackOptions + self.playbackPresentationContext = NCVideoPlaybackPresentationContext( + startReason: playbackStartReason + ) super.init( nibName: nil, @@ -190,7 +189,7 @@ final class NCVideoAVPlayerViewController: UIViewController { updateControlsNavigationBar() - if !shouldShowControlsOnStart { + if !playbackPresentationContext.shouldShowControlsOnStart { controlsView.alpha = 0 controlsView.isHidden = true } @@ -217,7 +216,6 @@ final class NCVideoAVPlayerViewController: UIViewController { let shouldPreserveHiddenChromeBackground = isChromeHidden start() - applyControlsVisibilityOnStart() if shouldPreserveHiddenChromeBackground { updateViewerBackground(isChromeHidden: true) @@ -255,7 +253,7 @@ final class NCVideoAVPlayerViewController: UIViewController { preparedPlayback: NCVideoAVPreparedPlayback, userAgent: String?, shouldAutoPlayOnStart: Bool = true, - shouldShowControlsOnStart: Bool = true, + playbackStartReason: NCVideoPlaybackPresentationContext.StartReason = .userInitiated, isChromeHidden: Bool = false, contextMenuController: NCMainTabBarController?, playbackOptions: NCMediaPlaybackOptions @@ -272,7 +270,7 @@ final class NCVideoAVPlayerViewController: UIViewController { self.metadata = metadata self.userAgent = userAgent self.shouldAutoPlayOnStart = shouldAutoPlayOnStart - self.shouldShowControlsOnStart = shouldShowControlsOnStart + self.playbackPresentationContext.updateStartReason(playbackStartReason) self.contextMenuController = contextMenuController self.playbackOptions = playbackOptions updateViewerBackground(isChromeHidden: isChromeHidden) @@ -280,7 +278,6 @@ final class NCVideoAVPlayerViewController: UIViewController { if urlChanged { start() - applyControlsVisibilityOnStart() } updatePlayPauseButton() @@ -505,10 +502,14 @@ final class NCVideoAVPlayerViewController: UIViewController { private func start() { hasReportedPlaybackError = false - suppressesInitialControlsUntilPlaybackStarts = shouldAutoPlayOnStart && - !shouldShowControlsOnStart && - player.timeControlStatus != .playing isPlaybackRequested = shouldAutoPlayOnStart + playbackPresentationContext.prepareForPlaybackStart() + applyControlsVisibilityOnStart() + + if player.timeControlStatus == .playing { + playbackPresentationContext.finishPlaybackTransition() + } + cancelPlaybackStartupTimeout() guard preparedURL != url else { @@ -541,8 +542,7 @@ final class NCVideoAVPlayerViewController: UIViewController { private func stop() { preparedURL = nil isPlaybackRequested = false - isRepeatRestartInProgress = false - suppressesInitialControlsUntilPlaybackStarts = false + playbackPresentationContext.reset() cancelPlaybackStartupTimeout() player.pause() @@ -558,7 +558,7 @@ final class NCVideoAVPlayerViewController: UIViewController { } private func applyControlsVisibilityOnStart() { - if shouldShowControlsOnStart { + if playbackPresentationContext.shouldShowControlsOnStart { showControls(animated: false) stopControlsHideTimer() } else { @@ -638,7 +638,7 @@ final class NCVideoAVPlayerViewController: UIViewController { queue: .main ) { [weak self] _ in guard let self, - !self.isScrubbing else { + !self.playbackPresentationContext.isSeeking else { return } @@ -712,7 +712,7 @@ final class NCVideoAVPlayerViewController: UIViewController { updatePlayPauseButton() } - if shouldShowControlsOnStart, + if playbackPresentationContext.shouldShowControlsOnStart, !controlsVisible, !isPictureInPictureActive { showControls(animated: false) @@ -763,8 +763,7 @@ final class NCVideoAVPlayerViewController: UIViewController { } hasReportedPlaybackError = true - isRepeatRestartInProgress = false - suppressesInitialControlsUntilPlaybackStarts = false + playbackPresentationContext.reset() isPlaybackRequested = false cancelPlaybackStartupTimeout() @@ -786,17 +785,14 @@ final class NCVideoAVPlayerViewController: UIViewController { switch player.timeControlStatus { case .playing: isPlaybackRequested = true - isRepeatRestartInProgress = false - suppressesInitialControlsUntilPlaybackStarts = false + playbackPresentationContext.finishPlaybackTransition() cancelPlaybackStartupTimeout() case .waitingToPlayAtSpecifiedRate: isPlaybackRequested = true case .paused: - if !isRepeatRestartInProgress, - !suppressesInitialControlsUntilPlaybackStarts, - !isScrubbing, + if !playbackPresentationContext.shouldSuppressAutomaticControlsPresentation, player.currentItem?.status == .readyToPlay || player.currentItem?.status == .failed || player.currentItem == nil { @@ -810,9 +806,7 @@ final class NCVideoAVPlayerViewController: UIViewController { updatePlayPauseButton() guard player.timeControlStatus == .playing else { - guard !isRepeatRestartInProgress, - !suppressesInitialControlsUntilPlaybackStarts, - !isScrubbing else { + guard !playbackPresentationContext.shouldSuppressAutomaticControlsPresentation else { return } @@ -853,7 +847,7 @@ final class NCVideoAVPlayerViewController: UIViewController { } private func repeatCurrentItem() { - isRepeatRestartInProgress = true + playbackPresentationContext.beginRepeatRestart() isPlaybackRequested = true updatePlayPauseButton() @@ -873,7 +867,7 @@ final class NCVideoAVPlayerViewController: UIViewController { } guard didFinish else { - self.isRepeatRestartInProgress = false + self.playbackPresentationContext.finishPlaybackTransition() self.isPlaybackRequested = false self.updatePlayPauseButton() self.showControls(animated: true) diff --git a/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoAVPlayerViewControls.swift b/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoAVPlayerViewControls.swift index 907ccd0281..ffb0b22df8 100644 --- a/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoAVPlayerViewControls.swift +++ b/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoAVPlayerViewControls.swift @@ -144,7 +144,7 @@ extension NCVideoAVPlayerViewController { ) { [weak self] _ in Task { @MainActor in guard let self, - !self.isScrubbing else { + !self.playbackPresentationContext.isSeeking else { return } @@ -211,7 +211,7 @@ extension NCVideoAVPlayerViewController: NCVideoControlsViewDelegate { } func videoControlsDidBeginScrubbing(_ controlsView: NCVideoControlsView) { - isScrubbing = true + playbackPresentationContext.beginSeeking() stopControlsHideTimer() } @@ -241,7 +241,7 @@ extension NCVideoAVPlayerViewController: NCVideoControlsViewDelegate { guard let duration = player.currentItem?.duration.seconds, duration.isFinite, duration > 0 else { - isScrubbing = false + playbackPresentationContext.finishSeeking() hideControls(animated: true) return } @@ -257,7 +257,7 @@ extension NCVideoAVPlayerViewController: NCVideoControlsViewDelegate { toleranceAfter: .zero ) { [weak self] _ in Task { @MainActor in - self?.isScrubbing = false + self?.playbackPresentationContext.finishSeeking() self?.updateProgressControls() self?.hideControls(animated: true) } diff --git a/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoViewerContentView+AVPlayer.swift b/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoViewerContentView+AVPlayer.swift index 04fe54dd84..cdc677e77a 100644 --- a/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoViewerContentView+AVPlayer.swift +++ b/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoViewerContentView+AVPlayer.swift @@ -28,7 +28,7 @@ extension NCVideoViewerContentView { preparedPlayback: preparedPlayback, userAgent: userAgent, shouldAutoPlayOnStart: true, - shouldShowControlsOnStart: !shouldAutoPlay, + playbackStartReason: shouldAutoPlay ? .automaticAdvance : .userInitiated, isChromeHidden: isChromeHidden, contextMenuController: contextMenuController, playbackOptions: playbackOptions, diff --git a/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCPresenter.swift b/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCPresenter.swift index 4bcdf02457..a5e0426f63 100644 --- a/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCPresenter.swift +++ b/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCPresenter.swift @@ -23,7 +23,7 @@ enum NCVideoVLCPresenter { preparedPlayback: NCVideoVLCPreparedPlayback, userAgent: String?, shouldAutoPlayOnStart: Bool = true, - shouldShowControlsOnStart: Bool = true, + playbackStartReason: NCVideoPlaybackPresentationContext.StartReason = .userInitiated, isChromeHidden: Bool = false, contextMenuController: NCMainTabBarController?, playbackOptions: NCMediaPlaybackOptions, @@ -49,7 +49,7 @@ enum NCVideoVLCPresenter { preparedPlayback: preparedPlayback, userAgent: userAgent, shouldAutoPlayOnStart: shouldAutoPlayOnStart, - shouldShowControlsOnStart: shouldShowControlsOnStart, + playbackStartReason: playbackStartReason, isChromeHidden: isChromeHidden, contextMenuController: contextMenuController, playbackOptions: playbackOptions @@ -75,7 +75,7 @@ enum NCVideoVLCPresenter { preparedPlayback: preparedPlayback, userAgent: userAgent, shouldAutoPlayOnStart: shouldAutoPlayOnStart, - shouldShowControlsOnStart: shouldShowControlsOnStart, + playbackStartReason: playbackStartReason, isChromeHidden: isChromeHidden, contextMenuController: contextMenuController, playbackOptions: playbackOptions @@ -120,7 +120,7 @@ enum NCVideoVLCPresenter { preparedPlayback: preparedPlayback, userAgent: userAgent, shouldAutoPlayOnStart: shouldAutoPlayOnStart, - shouldShowControlsOnStart: shouldShowControlsOnStart, + playbackStartReason: playbackStartReason, isChromeHidden: isChromeHidden, contextMenuController: contextMenuController, playbackOptions: playbackOptions @@ -148,7 +148,7 @@ enum NCVideoVLCPresenter { .foregroundColor: UIColor.white ] - if !shouldShowControlsOnStart { + if !playbackStartReason.shouldShowControlsOnStart { navigationController.setNavigationBarHidden( true, animated: false diff --git a/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCViewController.swift b/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCViewController.swift index 5fa94abbd9..a0c6cbc599 100644 --- a/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCViewController.swift +++ b/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCViewController.swift @@ -18,13 +18,11 @@ final class NCVideoVLCViewController: UIViewController { private var url: URL private var userAgent: String? private var shouldAutoPlayOnStart: Bool - private var shouldShowControlsOnStart: Bool private var isChromeHidden: Bool private weak var contextMenuController: NCMainTabBarController? internal var playbackOptions: NCMediaPlaybackOptions private var isReplayFromBeginningRequested = false - private var isRepeatRestartInProgress = false - private var suppressesInitialControlsUntilPlaybackStarts = false + internal var playbackPresentationContext: NCVideoPlaybackPresentationContext // MARK: - Paging Callbacks @@ -64,7 +62,6 @@ final class NCVideoVLCViewController: UIViewController { internal var progressTimer: Timer? internal var controlsHideTimer: Timer? internal var controlsVisible = false - internal var isScrubbing = false internal var isPlaybackRequested = false private weak var closePanGesture: UIPanGestureRecognizer? @@ -89,7 +86,7 @@ final class NCVideoVLCViewController: UIViewController { preparedPlayback: NCVideoVLCPreparedPlayback, userAgent: String?, shouldAutoPlayOnStart: Bool = true, - shouldShowControlsOnStart: Bool = true, + playbackStartReason: NCVideoPlaybackPresentationContext.StartReason = .userInitiated, isChromeHidden: Bool = false, contextMenuController: NCMainTabBarController?, playbackOptions: NCMediaPlaybackOptions @@ -99,10 +96,12 @@ final class NCVideoVLCViewController: UIViewController { self.url = preparedPlayback.url self.userAgent = userAgent self.shouldAutoPlayOnStart = shouldAutoPlayOnStart - self.shouldShowControlsOnStart = shouldShowControlsOnStart self.isChromeHidden = isChromeHidden self.contextMenuController = contextMenuController self.playbackOptions = playbackOptions + self.playbackPresentationContext = NCVideoPlaybackPresentationContext( + startReason: playbackStartReason + ) super.init( nibName: nil, @@ -162,7 +161,7 @@ final class NCVideoVLCViewController: UIViewController { controlsView.setTopActionsNavigationBar(navigationController?.navigationBar) - if !shouldShowControlsOnStart { + if !playbackPresentationContext.shouldShowControlsOnStart { controlsView.alpha = 0 controlsView.isHidden = true } @@ -220,7 +219,7 @@ final class NCVideoVLCViewController: UIViewController { preparedPlayback: NCVideoVLCPreparedPlayback, userAgent: String?, shouldAutoPlayOnStart: Bool = true, - shouldShowControlsOnStart: Bool = true, + playbackStartReason: NCVideoPlaybackPresentationContext.StartReason = .userInitiated, isChromeHidden: Bool = false, contextMenuController: NCMainTabBarController?, playbackOptions: NCMediaPlaybackOptions @@ -232,7 +231,7 @@ final class NCVideoVLCViewController: UIViewController { self.metadata = metadata self.userAgent = userAgent self.shouldAutoPlayOnStart = shouldAutoPlayOnStart - self.shouldShowControlsOnStart = shouldShowControlsOnStart + self.playbackPresentationContext.updateStartReason(playbackStartReason) self.isChromeHidden = isChromeHidden self.contextMenuController = contextMenuController self.playbackOptions = playbackOptions @@ -444,11 +443,14 @@ final class NCVideoVLCViewController: UIViewController { private func start() { hasEnteredPlaybackPipeline = false hasReportedPlaybackError = false - isRepeatRestartInProgress = false - suppressesInitialControlsUntilPlaybackStarts = shouldAutoPlayOnStart && - !shouldShowControlsOnStart && - mediaPlayer.state != .playing isPlaybackRequested = shouldAutoPlayOnStart + playbackPresentationContext.prepareForPlaybackStart() + applyControlsVisibilityOnStart() + + if mediaPlayer.state == .playing { + playbackPresentationContext.finishPlaybackTransition() + } + cancelPlaybackStartupTimeout() attachDrawable() @@ -465,11 +467,10 @@ final class NCVideoVLCViewController: UIViewController { updateProgressControls() clearVLCTrackMenuItems() startProgressTimer() - applyControlsVisibilityOnStart() } private func applyControlsVisibilityOnStart() { - if shouldShowControlsOnStart { + if playbackPresentationContext.shouldShowControlsOnStart { showControls(animated: false) stopControlsHideTimer() } else { @@ -490,8 +491,7 @@ final class NCVideoVLCViewController: UIViewController { cancelPlaybackStartupTimeout() isPlaybackRequested = false isReplayFromBeginningRequested = false - isRepeatRestartInProgress = false - suppressesInitialControlsUntilPlaybackStarts = false + playbackPresentationContext.reset() if mediaPlayer.media == nil || (mediaPlayer.state == .stopped && !hadPendingPlaybackRequest) { @@ -624,8 +624,7 @@ final class NCVideoVLCViewController: UIViewController { } hasReportedPlaybackError = true - isRepeatRestartInProgress = false - suppressesInitialControlsUntilPlaybackStarts = false + playbackPresentationContext.reset() isPlaybackRequested = false cancelPlaybackStartupTimeout() onPlaybackError?() @@ -685,8 +684,7 @@ final class NCVideoVLCViewController: UIViewController { switch mediaPlayer.state { case .playing: isPlaybackRequested = true - isRepeatRestartInProgress = false - suppressesInitialControlsUntilPlaybackStarts = false + playbackPresentationContext.finishPlaybackTransition() cancelPlaybackStartupTimeout() case .ended: @@ -695,7 +693,7 @@ final class NCVideoVLCViewController: UIViewController { switch playbackOptions.completionAction { case .repeatCurrentItem: - isRepeatRestartInProgress = true + playbackPresentationContext.beginRepeatRestart() restartPlaybackFromBeginning() return @@ -737,14 +735,12 @@ final class NCVideoVLCViewController: UIViewController { cancelPlaybackStartupTimeout() case .paused: - if !isRepeatRestartInProgress, - !suppressesInitialControlsUntilPlaybackStarts, - !isScrubbing { + if !playbackPresentationContext.shouldSuppressAutomaticControlsPresentation { isPlaybackRequested = false } case .error: - isRepeatRestartInProgress = false + playbackPresentationContext.reset() isPlaybackRequested = false default: @@ -756,9 +752,7 @@ final class NCVideoVLCViewController: UIViewController { refreshVLCTrackMenuItemsWhenPlayerIsActive() guard mediaPlayer.state == .playing else { - guard !isRepeatRestartInProgress, - !suppressesInitialControlsUntilPlaybackStarts, - !isScrubbing else { + guard !playbackPresentationContext.shouldSuppressAutomaticControlsPresentation else { return } @@ -1150,7 +1144,7 @@ extension NCVideoVLCViewController: VLCMediaPlayerDelegate { func mediaPlayerTimeChanged(_ aNotification: Notification) { Task { @MainActor in - guard !isScrubbing else { + guard !playbackPresentationContext.isSeeking else { return } diff --git a/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCViewControls.swift b/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCViewControls.swift index c333d5e5b6..d88e91c229 100644 --- a/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCViewControls.swift +++ b/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCViewControls.swift @@ -53,7 +53,7 @@ extension NCVideoVLCViewController { } func updateProgressControls() { - guard !isScrubbing else { + guard !playbackPresentationContext.isSeeking else { return } @@ -171,7 +171,7 @@ extension NCVideoVLCViewController { ) { [weak self] _ in Task { @MainActor in guard let self, - !self.isScrubbing else { + !self.playbackPresentationContext.isSeeking else { return } @@ -238,7 +238,7 @@ extension NCVideoVLCViewController: NCVideoControlsViewDelegate { func videoControlsDidBeginScrubbing(_ controlsView: NCVideoControlsView) { showControls(animated: true) stopControlsHideTimer() - isScrubbing = true + playbackPresentationContext.beginSeeking() } func videoControlsDidTapSubtitle(_ controlsView: NCVideoControlsView) { @@ -288,7 +288,7 @@ extension NCVideoVLCViewController: NCVideoControlsViewDelegate { mediaPlayer.position = progress } - isScrubbing = false + playbackPresentationContext.finishSeeking() updateProgressLabels(position: progress) startProgressTimer() hideControls(animated: true) diff --git a/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoViewerContentView+VLC.swift b/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoViewerContentView+VLC.swift index 95acb85689..7c8f55b655 100644 --- a/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoViewerContentView+VLC.swift +++ b/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoViewerContentView+VLC.swift @@ -28,7 +28,7 @@ extension NCVideoViewerContentView { preparedPlayback: preparedPlayback, userAgent: userAgent, shouldAutoPlayOnStart: true, - shouldShowControlsOnStart: !shouldAutoPlay, + playbackStartReason: shouldAutoPlay ? .automaticAdvance : .userInitiated, isChromeHidden: isChromeHidden, contextMenuController: contextMenuController, playbackOptions: playbackOptions, diff --git a/iOSClient/Viewer/NCViewerMedia/Core/NCMediaPlaybackOptions.swift b/iOSClient/Viewer/NCViewerMedia/Core/NCMediaPlaybackOptions.swift index 2eb6192dc1..fc507e2784 100644 --- a/iOSClient/Viewer/NCViewerMedia/Core/NCMediaPlaybackOptions.swift +++ b/iOSClient/Viewer/NCViewerMedia/Core/NCMediaPlaybackOptions.swift @@ -1,5 +1,6 @@ -// SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors -// SPDX-License-Identifier: GPL-2.0-or-later +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Marino Faggiana +// SPDX-License-Identifier: GPL-3.0-or-later import Combine diff --git a/iOSClient/Viewer/NCViewerMedia/Core/NCVideoPlaybackPresentationContext.swift b/iOSClient/Viewer/NCViewerMedia/Core/NCVideoPlaybackPresentationContext.swift new file mode 100644 index 0000000000..91223030b3 --- /dev/null +++ b/iOSClient/Viewer/NCViewerMedia/Core/NCVideoPlaybackPresentationContext.swift @@ -0,0 +1,89 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Marino Faggiana +// SPDX-License-Identifier: GPL-3.0-or-later + +struct NCVideoPlaybackPresentationContext: Equatable { + enum StartReason: Equatable { + case userInitiated + case automaticAdvance + + var shouldShowControlsOnStart: Bool { + self == .userInitiated + } + } + + enum PlaybackTransition: Equatable { + case idle + case automaticStart + case repeatRestart + } + + enum Interaction: Equatable { + case idle + case seeking + } + + // Player state callbacks can arrive while the user is seeking, so playback + // transitions and user interactions must remain independent. + private(set) var startReason: StartReason + private(set) var playbackTransition = PlaybackTransition.idle + private(set) var interaction = Interaction.idle + + var shouldShowControlsOnStart: Bool { + startReason.shouldShowControlsOnStart + } + + var shouldSuppressAutomaticControlsPresentation: Bool { + playbackTransition != .idle || interaction != .idle + } + + var isSeeking: Bool { + interaction == .seeking + } + + init(startReason: StartReason) { + self.startReason = startReason + } + + mutating func updateStartReason(_ startReason: StartReason) { + self.startReason = startReason + } + + mutating func prepareForPlaybackStart() { + playbackTransition = startReason == .automaticAdvance ? .automaticStart : .idle + } + + mutating func beginRepeatRestart() { + playbackTransition = .repeatRestart + } + + mutating func beginSeeking() { + interaction = .seeking + } + + mutating func finishPlaybackTransition() { + guard playbackTransition != .idle else { + return + } + + let completedTransition = playbackTransition + playbackTransition = .idle + + if completedTransition == .automaticStart { + startReason = .userInitiated + } + } + + mutating func finishSeeking() { + guard interaction == .seeking else { + return + } + + interaction = .idle + } + + mutating func reset() { + playbackTransition = .idle + interaction = .idle + } +} From babdeec6fb8f36baf1df9fc341cc16a07e3ce591 Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Tue, 11 Aug 2026 13:02:12 +0200 Subject: [PATCH 09/14] fix: stabilize audio viewer top action padding Signed-off-by: Marino Faggiana --- .../Audio/NCAudioViewerContentView.swift | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/iOSClient/Viewer/NCViewerMedia/Content/Audio/NCAudioViewerContentView.swift b/iOSClient/Viewer/NCViewerMedia/Content/Audio/NCAudioViewerContentView.swift index 202cd6d570..efcefa7813 100644 --- a/iOSClient/Viewer/NCViewerMedia/Content/Audio/NCAudioViewerContentView.swift +++ b/iOSClient/Viewer/NCViewerMedia/Content/Audio/NCAudioViewerContentView.swift @@ -77,14 +77,21 @@ struct NCAudioViewerContentView: View { let playButtonSize: CGFloat = isLandscape ? 64 : 72 let navigationBarHeight: CGFloat = isLandscape ? 32 : 44 let minimumNavigationBarBottom: CGFloat = isLandscape ? 32 : 64 - let fallbackNavigationBarBottom = max( - proxy.safeAreaInsets.top + navigationBarHeight, + // The navigation bar frame moves while hidden. Its safe area and + // bounds keep this inset stable when the bar becomes visible again. + let safeAreaTop = max( + proxy.safeAreaInsets.top, + navigationBar?.window?.safeAreaInsets.top ?? 0 + ) + let effectiveNavigationBarHeight = max( + navigationBar?.bounds.height ?? 0, + navigationBarHeight + ) + let navigationBarBottom = max( + safeAreaTop + effectiveNavigationBarHeight, minimumNavigationBarBottom ) - let topActionsPadding = max( - navigationBar?.frame.maxY ?? 0, - fallbackNavigationBarBottom - ) + 4 + let topActionsPadding = navigationBarBottom + 4 ZStack { Color.ncViewerBackground(backgroundStyle) From a0750cc8d43c255ec062673e29a9468d439ad6fa Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Tue, 11 Aug 2026 13:50:26 +0200 Subject: [PATCH 10/14] fix: handle duplicate media and failed auto-advance Deduplicate media viewer identifiers and skip repeated source items during next-media lookup. Restore stopped playback controls when no matching item can be advanced to. Signed-off-by: Marino Faggiana --- Nextcloud.xcodeproj/project.pbxproj | 20 ++-------- .../Placeholder.swift | 7 ---- .../NCCameraRollTests.swift | 5 ++- .../NCMediaPlaybackOptionsTests.swift | 5 ++- .../NCMediaViewerInitialModelTests.swift | 38 ++++++++++++++++++ ...ideoPlaybackPresentationContextTests.swift | 5 ++- Tests/NextcloudUnitTests/Placeholder.swift | 7 ---- .../Audio/NCAudioViewerContentView.swift | 12 +++--- .../AVPlayer/NCVideoAVPlayerPresenter.swift | 2 +- .../NCVideoAVPlayerViewController.swift | 21 +++++++++- .../Video/NCVideoViewerContentView.swift | 4 +- .../Video/VLC/NCVideoVLCPresenter.swift | 2 +- .../Video/VLC/NCVideoVLCViewController.swift | 28 ++++++++++--- .../Core/NCMediaPlaybackOptions.swift | 6 +++ .../Core/NCMediaViewerModel.swift | 16 ++++++-- .../Views/NCMediaViewerPageView.swift | 19 ++++++--- .../Views/NCMediaViewerPagingView.swift | 40 ++++++++++++++----- 17 files changed, 166 insertions(+), 71 deletions(-) delete mode 100644 Tests/NextcloudIntegrationTests/Placeholder.swift create mode 100644 Tests/NextcloudUnitTests/NCMediaViewerInitialModelTests.swift delete mode 100644 Tests/NextcloudUnitTests/Placeholder.swift diff --git a/Nextcloud.xcodeproj/project.pbxproj b/Nextcloud.xcodeproj/project.pbxproj index 3727f8d584..b9fa36ef33 100644 --- a/Nextcloud.xcodeproj/project.pbxproj +++ b/Nextcloud.xcodeproj/project.pbxproj @@ -21,7 +21,6 @@ AA517B812D660EFE00F8D37C /* Localizable.stringsdict in Resources */ = {isa = PBXBuildFile; fileRef = AA517B7E2D660EFE00F8D37C /* Localizable.stringsdict */; }; AA517B822D660EFE00F8D37C /* Localizable.stringsdict in Resources */ = {isa = PBXBuildFile; fileRef = AA517B7E2D660EFE00F8D37C /* Localizable.stringsdict */; }; AA517B832D660EFE00F8D37C /* Localizable.stringsdict in Resources */ = {isa = PBXBuildFile; fileRef = AA517B7E2D660EFE00F8D37C /* Localizable.stringsdict */; }; - AA52EB472D42AC9E0089C348 /* Placeholder.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA52EB452D42AC5A0089C348 /* Placeholder.swift */; }; AA62DF602D5DF1F1009E8894 /* PHAssetCollection+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = F351D1A52D0AF24A00930F94 /* PHAssetCollection+Extension.swift */; }; AA74AA972D3172D100BE3458 /* UITestError.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA74AA962D3172CE00BE3458 /* UITestError.swift */; }; AA8D31532D41052300FE2775 /* NCManageDatabase+DownloadLimit.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA8D31522D41052300FE2775 /* NCManageDatabase+DownloadLimit.swift */; }; @@ -44,8 +43,8 @@ AABBCC0430A8000100F0A001 /* NCMediaPlaybackOptionsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AABBCC0330A8000100F0A001 /* NCMediaPlaybackOptionsTests.swift */; }; AABBCC0630A8000100F0A001 /* NCVideoPlaybackPresentationContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = AABBCC0530A8000100F0A001 /* NCVideoPlaybackPresentationContext.swift */; }; AABBCC0830A8000100F0A001 /* NCVideoPlaybackPresentationContextTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AABBCC0730A8000100F0A001 /* NCVideoPlaybackPresentationContextTests.swift */; }; + AABBCC0A30A8000100F0A001 /* NCMediaViewerInitialModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AABBCC0930A8000100F0A001 /* NCMediaViewerInitialModelTests.swift */; }; AABD0C8A2D5F67A400F009E6 /* XCUIElement.swift in Sources */ = {isa = PBXBuildFile; fileRef = AABD0C892D5F67A200F009E6 /* XCUIElement.swift */; }; - AABD0C9B2D5F73FC00F009E6 /* Placeholder.swift in Sources */ = {isa = PBXBuildFile; fileRef = AABD0C9A2D5F73FA00F009E6 /* Placeholder.swift */; }; AAE330042D2ED20200B04903 /* NCShareNavigationTitleSetting.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAE330032D2ED1FF00B04903 /* NCShareNavigationTitleSetting.swift */; }; AAFC0D042F9AA10000F0A001 /* NCFocusedAutoUploadIntroView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAFC0D012F9AA10000F0A001 /* NCFocusedAutoUploadIntroView.swift */; }; AAFC0D052F9AA10000F0A001 /* NCFocusedAutoUploadProgressView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAFC0D022F9AA10000F0A001 /* NCFocusedAutoUploadProgressView.swift */; }; @@ -1265,7 +1264,6 @@ AA52EB2C2D4297570089C348 /* el */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = el; path = el.lproj/Intent.strings; sourceTree = ""; }; AA52EB2D2D4297570089C348 /* el */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = el; path = el.lproj/Localizable.strings; sourceTree = ""; }; AA52EB2E2D4297570089C348 /* el */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = el; path = el.lproj/InfoPlist.strings; sourceTree = ""; }; - AA52EB452D42AC5A0089C348 /* Placeholder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Placeholder.swift; sourceTree = ""; }; AA74AA962D3172CE00BE3458 /* UITestError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UITestError.swift; sourceTree = ""; }; AA8D31522D41052300FE2775 /* NCManageDatabase+DownloadLimit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NCManageDatabase+DownloadLimit.swift"; sourceTree = ""; }; AA8D31652D411FA100FE2775 /* NCShareDateCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCShareDateCell.swift; sourceTree = ""; }; @@ -1288,9 +1286,9 @@ AABBCC0330A8000100F0A001 /* NCMediaPlaybackOptionsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCMediaPlaybackOptionsTests.swift; sourceTree = ""; }; AABBCC0530A8000100F0A001 /* NCVideoPlaybackPresentationContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCVideoPlaybackPresentationContext.swift; sourceTree = ""; }; AABBCC0730A8000100F0A001 /* NCVideoPlaybackPresentationContextTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCVideoPlaybackPresentationContextTests.swift; sourceTree = ""; }; + AABBCC0930A8000100F0A001 /* NCMediaViewerInitialModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCMediaViewerInitialModelTests.swift; sourceTree = ""; }; AABD0C862D5F58C400F009E6 /* Server.sh */ = {isa = PBXFileReference; lastKnownFileType = text.script.sh; path = Server.sh; sourceTree = ""; }; AABD0C892D5F67A200F009E6 /* XCUIElement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XCUIElement.swift; sourceTree = ""; }; - AABD0C9A2D5F73FA00F009E6 /* Placeholder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Placeholder.swift; sourceTree = ""; }; AACCAB522CFE041F00DA1786 /* sl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sl; path = sl.lproj/Intent.strings; sourceTree = ""; }; AACCAB532CFE041F00DA1786 /* sl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sl; path = sl.lproj/Localizable.strings; sourceTree = ""; }; AACCAB542CFE041F00DA1786 /* sl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sl; path = sl.lproj/InfoPlist.strings; sourceTree = ""; }; @@ -2189,9 +2187,9 @@ children = ( AABBCC0330A8000100F0A001 /* NCMediaPlaybackOptionsTests.swift */, AABBCC0730A8000100F0A001 /* NCVideoPlaybackPresentationContextTests.swift */, + AABBCC0930A8000100F0A001 /* NCMediaViewerInitialModelTests.swift */, F34BDB3B2F574A58007A222C /* BidiSafeFilenameTests.swift */, C0DECA012F65000100C0D001 /* NCCameraRollTests.swift */, - AA52EB452D42AC5A0089C348 /* Placeholder.swift */, ); path = NextcloudUnitTests; sourceTree = ""; @@ -2244,18 +2242,9 @@ path = NextcloudUITests; sourceTree = ""; }; - C04E2F212A17BB4D001BAD85 /* NextcloudIntegrationTests */ = { - isa = PBXGroup; - children = ( - AABD0C9A2D5F73FA00F009E6 /* Placeholder.swift */, - ); - path = NextcloudIntegrationTests; - sourceTree = ""; - }; F30A962A2A27A9C800D7BCFE /* Tests */ = { isa = PBXGroup; children = ( - C04E2F212A17BB4D001BAD85 /* NextcloudIntegrationTests */, AA52EB442D42AC500089C348 /* NextcloudUnitTests */, C0046CDB2A17B98400D87C9D /* NextcloudUITests */, AABD0C862D5F58C400F009E6 /* Server.sh */, @@ -4487,7 +4476,7 @@ files = ( AABBCC0430A8000100F0A001 /* NCMediaPlaybackOptionsTests.swift in Sources */, AABBCC0830A8000100F0A001 /* NCVideoPlaybackPresentationContextTests.swift in Sources */, - AA52EB472D42AC9E0089C348 /* Placeholder.swift in Sources */, + AABBCC0A30A8000100F0A001 /* NCMediaViewerInitialModelTests.swift in Sources */, F34BDB3C2F574A58007A222C /* BidiSafeFilenameTests.swift in Sources */, C0DECA022F65000100C0D001 /* NCCameraRollTests.swift in Sources */, F372087D2BAB4C0F006B5430 /* TestConstants.swift in Sources */, @@ -4512,7 +4501,6 @@ F3374A962D6744A4002A38F9 /* BaseUIXCTestCase.swift in Sources */, F37208812BAB5979006B5430 /* TestConstants.swift in Sources */, F33D303E2D8B129600531D64 /* AutoUploadUITests.swift in Sources */, - AABD0C9B2D5F73FC00F009E6 /* Placeholder.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; diff --git a/Tests/NextcloudIntegrationTests/Placeholder.swift b/Tests/NextcloudIntegrationTests/Placeholder.swift deleted file mode 100644 index 6cc0aa6c4b..0000000000 --- a/Tests/NextcloudIntegrationTests/Placeholder.swift +++ /dev/null @@ -1,7 +0,0 @@ -// SPDX-FileCopyrightText: Nextcloud GmbH -// SPDX-FileCopyrightText: 2025 Iva Horn -// SPDX-License-Identifier: GPL-3.0-or-later - -// There are no unit tests yet. -// This is an empty placeholder file. -// The compiler requires at least an empty file for every target. diff --git a/Tests/NextcloudUnitTests/NCCameraRollTests.swift b/Tests/NextcloudUnitTests/NCCameraRollTests.swift index 2988e53f7b..fc4b30aa5d 100644 --- a/Tests/NextcloudUnitTests/NCCameraRollTests.swift +++ b/Tests/NextcloudUnitTests/NCCameraRollTests.swift @@ -1,5 +1,6 @@ -// SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors -// SPDX-License-Identifier: GPL-2.0-or-later +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Marino Faggiana +// SPDX-License-Identifier: GPL-3.0-or-later import AVFoundation import Foundation diff --git a/Tests/NextcloudUnitTests/NCMediaPlaybackOptionsTests.swift b/Tests/NextcloudUnitTests/NCMediaPlaybackOptionsTests.swift index 3fa87f632c..64a2782117 100644 --- a/Tests/NextcloudUnitTests/NCMediaPlaybackOptionsTests.swift +++ b/Tests/NextcloudUnitTests/NCMediaPlaybackOptionsTests.swift @@ -1,5 +1,6 @@ -// SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors -// SPDX-License-Identifier: GPL-2.0-or-later +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Marino Faggiana +// SPDX-License-Identifier: GPL-3.0-or-later import Testing @testable import Nextcloud diff --git a/Tests/NextcloudUnitTests/NCMediaViewerInitialModelTests.swift b/Tests/NextcloudUnitTests/NCMediaViewerInitialModelTests.swift new file mode 100644 index 0000000000..a09a43bcef --- /dev/null +++ b/Tests/NextcloudUnitTests/NCMediaViewerInitialModelTests.swift @@ -0,0 +1,38 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Marino Faggiana +// SPDX-License-Identifier: GPL-3.0-or-later + +import Testing +@testable import Nextcloud + +@Suite("Media viewer initial model") +@MainActor +struct NCMediaViewerInitialModelTests { + @Test("Duplicate media identifiers produce a single viewer page") + func removesDuplicateIdentifiers() { + let metadata = tableMetadata() + metadata.ocId = "current" + + let model = NCMediaViewerInitialModel( + currentMetadata: metadata, + ocIds: ["first", "current", "current", "last", "first"] + ) + + #expect(model.normalizedOcIds == ["first", "current", "last"]) + #expect(model.currentSelectedIndex == 1) + } + + @Test("Missing current media is inserted before the supplied identifiers") + func insertsMissingCurrentIdentifier() { + let metadata = tableMetadata() + metadata.ocId = "current" + + let model = NCMediaViewerInitialModel( + currentMetadata: metadata, + ocIds: ["next", "next"] + ) + + #expect(model.normalizedOcIds == ["current", "next"]) + #expect(model.currentSelectedIndex == 0) + } +} diff --git a/Tests/NextcloudUnitTests/NCVideoPlaybackPresentationContextTests.swift b/Tests/NextcloudUnitTests/NCVideoPlaybackPresentationContextTests.swift index caa1122cfc..30eb81b494 100644 --- a/Tests/NextcloudUnitTests/NCVideoPlaybackPresentationContextTests.swift +++ b/Tests/NextcloudUnitTests/NCVideoPlaybackPresentationContextTests.swift @@ -1,5 +1,6 @@ -// SPDX-FileCopyrightText: 2026 Nextcloud GmbH and Nextcloud contributors -// SPDX-License-Identifier: GPL-2.0-or-later +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Marino Faggiana +// SPDX-License-Identifier: GPL-3.0-or-later import Testing @testable import Nextcloud diff --git a/Tests/NextcloudUnitTests/Placeholder.swift b/Tests/NextcloudUnitTests/Placeholder.swift deleted file mode 100644 index 6cc0aa6c4b..0000000000 --- a/Tests/NextcloudUnitTests/Placeholder.swift +++ /dev/null @@ -1,7 +0,0 @@ -// SPDX-FileCopyrightText: Nextcloud GmbH -// SPDX-FileCopyrightText: 2025 Iva Horn -// SPDX-License-Identifier: GPL-3.0-or-later - -// There are no unit tests yet. -// This is an empty placeholder file. -// The compiler requires at least an empty file for every target. diff --git a/iOSClient/Viewer/NCViewerMedia/Content/Audio/NCAudioViewerContentView.swift b/iOSClient/Viewer/NCViewerMedia/Content/Audio/NCAudioViewerContentView.swift index efcefa7813..1707b2c155 100644 --- a/iOSClient/Viewer/NCViewerMedia/Content/Audio/NCAudioViewerContentView.swift +++ b/iOSClient/Viewer/NCViewerMedia/Content/Audio/NCAudioViewerContentView.swift @@ -20,7 +20,7 @@ struct NCAudioViewerContentView: View { @ObservedObject var playbackOptions: NCMediaPlaybackOptions let onPrevious: (_ shouldAutoPlay: Bool) -> Void let onNext: (_ shouldAutoPlay: Bool) -> Void - let onPlayNextMedia: () -> Void + let onPlayNextMedia: NCMediaPlaybackAdvanceRequest let onAutoPlayConsumed: () -> Void let onToggleChrome: () -> Void @@ -38,7 +38,9 @@ struct NCAudioViewerContentView: View { playbackOptions: NCMediaPlaybackOptions, onPrevious: @escaping (_ shouldAutoPlay: Bool) -> Void = { _ in }, onNext: @escaping (_ shouldAutoPlay: Bool) -> Void = { _ in }, - onPlayNextMedia: @escaping () -> Void = {}, + onPlayNextMedia: @escaping NCMediaPlaybackAdvanceRequest = { completion in + completion(false) + }, onAutoPlayConsumed: @escaping () -> Void = {}, onToggleChrome: @escaping () -> Void = {} ) { @@ -462,13 +464,13 @@ final class NCAudioViewerModel: ObservableObject { private var currentURL: URL? private var loadedURL: URL? private weak var playbackOptions: NCMediaPlaybackOptions? - private var onPlayNextMedia: (() -> Void)? + private var onPlayNextMedia: NCMediaPlaybackAdvanceRequest? // MARK: - Public API func configurePlaybackCompletion( options: NCMediaPlaybackOptions, - onPlayNextMedia: @escaping () -> Void + onPlayNextMedia: @escaping NCMediaPlaybackAdvanceRequest ) { playbackOptions = options self.onPlayNextMedia = onPlayNextMedia @@ -698,7 +700,7 @@ final class NCAudioViewerModel: ObservableObject { case .playNextItem: self.currentTime = self.duration self.isPlaying = false - self.onPlayNextMedia?() + self.onPlayNextMedia? { _ in } case .stop: self.currentTime = self.duration diff --git a/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoAVPlayerPresenter.swift b/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoAVPlayerPresenter.swift index d09cdfcc02..71fd3fc8f2 100644 --- a/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoAVPlayerPresenter.swift +++ b/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoAVPlayerPresenter.swift @@ -28,7 +28,7 @@ enum NCVideoAVPlayerPresenter { canGoNext: Bool = false, onPrevious: (() -> Void)? = nil, onNext: (() -> Void)? = nil, - onPlaybackEnded: (() -> Void)? = nil, + onPlaybackEnded: NCMediaPlaybackAdvanceRequest? = nil, onClose: ((_ ocId: String?) -> Void)? = nil, onPlaybackError: (() -> Void)? = nil ) -> Bool { diff --git a/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoAVPlayerViewController.swift b/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoAVPlayerViewController.swift index 983d0b71e7..b642407e72 100644 --- a/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoAVPlayerViewController.swift +++ b/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoAVPlayerViewController.swift @@ -47,7 +47,7 @@ final class NCVideoAVPlayerViewController: UIViewController { var onPrevious: (() -> Void)? var onNext: (() -> Void)? - var onPlaybackEnded: (() -> Void)? + var onPlaybackEnded: NCMediaPlaybackAdvanceRequest? var onClose: ((_ ocId: String?) -> Void)? var onPlaybackError: (() -> Void)? var canGoPrevious = false @@ -834,16 +834,33 @@ final class NCVideoAVPlayerViewController: UIViewController { case .playNextItem: updatePlayPauseButton() updateProgressControls() - onPlaybackEnded?() + + guard let onPlaybackEnded else { + finishPlaybackWithoutAdvance() + return + } + + onPlaybackEnded { [weak self] didAdvance in + guard !didAdvance else { + return + } + + self?.finishPlaybackWithoutAdvance() + } return case .stop: break } + finishPlaybackWithoutAdvance() + } + + private func finishPlaybackWithoutAdvance() { updatePlayPauseButton() updateProgressControls() showControls(animated: true) + stopControlsHideTimer() } private func repeatCurrentItem() { diff --git a/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoViewerContentView.swift b/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoViewerContentView.swift index cecc27cbdc..13534daf1f 100644 --- a/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoViewerContentView.swift +++ b/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoViewerContentView.swift @@ -22,7 +22,7 @@ struct NCVideoViewerContentView: View { @ObservedObject var playbackOptions: NCMediaPlaybackOptions let onPreviousPage: (() -> Void)? let onNextPage: (() -> Void)? - let onPlayNextMedia: (() -> Void)? + let onPlayNextMedia: NCMediaPlaybackAdvanceRequest? let onAutoPlayConsumed: (() -> Void)? let onToggleChrome: (() -> Void)? let onClose: ((_ ocId: String?) -> Void)? @@ -62,7 +62,7 @@ struct NCVideoViewerContentView: View { playbackOptions: NCMediaPlaybackOptions, onPreviousPage: (() -> Void)? = nil, onNextPage: (() -> Void)? = nil, - onPlayNextMedia: (() -> Void)? = nil, + onPlayNextMedia: NCMediaPlaybackAdvanceRequest? = nil, onAutoPlayConsumed: (() -> Void)? = nil, onToggleChrome: (() -> Void)? = nil, onClose: ((_ ocId: String?) -> Void)? = nil, diff --git a/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCPresenter.swift b/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCPresenter.swift index a5e0426f63..706d0b5976 100644 --- a/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCPresenter.swift +++ b/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCPresenter.swift @@ -31,7 +31,7 @@ enum NCVideoVLCPresenter { canGoNext: Bool = false, onPrevious: (() -> Void)? = nil, onNext: (() -> Void)? = nil, - onPlaybackEnded: (() -> Void)? = nil, + onPlaybackEnded: NCMediaPlaybackAdvanceRequest? = nil, onClose: ((_ ocId: String?) -> Void)? = nil, onPlaybackError: (() -> Void)? = nil ) -> Bool { diff --git a/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCViewController.swift b/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCViewController.swift index a0c6cbc599..241b26fa8a 100644 --- a/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCViewController.swift +++ b/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCViewController.swift @@ -28,7 +28,7 @@ final class NCVideoVLCViewController: UIViewController { var onPrevious: (() -> Void)? var onNext: (() -> Void)? - var onPlaybackEnded: (() -> Void)? + var onPlaybackEnded: NCMediaPlaybackAdvanceRequest? var onClose: ((_ ocId: String?) -> Void)? var onPlaybackError: (() -> Void)? var canGoPrevious = false @@ -700,17 +700,26 @@ final class NCVideoVLCViewController: UIViewController { case .playNextItem: updatePlayPauseButton() updateProgressLabels(position: 1) - onPlaybackEnded?() + + guard let onPlaybackEnded else { + finishPlaybackWithoutAdvance() + return + } + + onPlaybackEnded { [weak self] didAdvance in + guard !didAdvance else { + return + } + + self?.finishPlaybackWithoutAdvance() + } return case .stop: break } - updatePlayPauseButton() - updateProgressLabels(position: 1) - showControls(animated: true) - stopControlsHideTimer() + finishPlaybackWithoutAdvance() return case .stopped: @@ -811,6 +820,13 @@ final class NCVideoVLCViewController: UIViewController { } } + private func finishPlaybackWithoutAdvance() { + updatePlayPauseButton() + updateProgressLabels(position: 1) + showControls(animated: true) + stopControlsHideTimer() + } + func selectSubtitleTrack(index: Int32) { mediaPlayer.currentVideoSubTitleIndex = index diff --git a/iOSClient/Viewer/NCViewerMedia/Core/NCMediaPlaybackOptions.swift b/iOSClient/Viewer/NCViewerMedia/Core/NCMediaPlaybackOptions.swift index fc507e2784..e19846de75 100644 --- a/iOSClient/Viewer/NCViewerMedia/Core/NCMediaPlaybackOptions.swift +++ b/iOSClient/Viewer/NCViewerMedia/Core/NCMediaPlaybackOptions.swift @@ -10,6 +10,12 @@ enum NCMediaPlaybackCompletionAction: Equatable { case playNextItem } +typealias NCMediaPlaybackAdvanceCompletion = (_ didAdvance: Bool) -> Void + +// Auto-advance lookup can finish after the player reaches its end state. The +// result lets the player restore normal stopped controls when no item exists. +typealias NCMediaPlaybackAdvanceRequest = (@escaping NCMediaPlaybackAdvanceCompletion) -> Void + @MainActor final class NCMediaPlaybackOptions: ObservableObject { @Published private(set) var isRepeatEnabled: Bool diff --git a/iOSClient/Viewer/NCViewerMedia/Core/NCMediaViewerModel.swift b/iOSClient/Viewer/NCViewerMedia/Core/NCMediaViewerModel.swift index 55a881aedd..48721f081f 100644 --- a/iOSClient/Viewer/NCViewerMedia/Core/NCMediaViewerModel.swift +++ b/iOSClient/Viewer/NCViewerMedia/Core/NCMediaViewerModel.swift @@ -61,11 +61,18 @@ struct NCMediaViewerInitialModel { } var normalizedOcIds: [String] { + let candidates: [String] + if ocIds.contains(currentMetadata.ocId) { - return ocIds + candidates = ocIds } else { - return [currentMetadata.ocId] + ocIds + candidates = [currentMetadata.ocId] + ocIds } + + // Search and grouped data sources can expose the same item in more + // than one section. A media item must still have only one viewer page. + var seenOcIds = Set() + return candidates.filter { seenOcIds.insert($0).inserted } } var currentSelectedIndex: Int { @@ -550,12 +557,15 @@ final class NCMediaViewerModel: ObservableObject { return nil } + let sourceOcId = ocIds[index] + for candidateIndex in ocIds.index(after: index).. Void let onNextPage: (_ shouldAutoPlay: Bool) -> Void - let onNextMediaOfSameType: (_ classFile: String) -> Void + let onNextMediaOfSameType: ( + _ classFile: String, + _ completion: @escaping NCMediaPlaybackAdvanceCompletion + ) -> Void let onClose: (_ ocId: String?) -> Void let onAutoPlayConsumed: () -> Void let onZoomChanged: (Bool) -> Void @@ -214,8 +217,11 @@ struct NCMediaViewerPageView: View { playbackOptions: model.playbackOptions, onPreviousPage: goToPreviousPageFromVideo, onNextPage: goToNextPageFromVideo, - onPlayNextMedia: { [onNextMediaOfSameType] in - onNextMediaOfSameType(NKTypeClassFile.video.rawValue) + onPlayNextMedia: { [onNextMediaOfSameType] completion in + onNextMediaOfSameType( + NKTypeClassFile.video.rawValue, + completion + ) }, onAutoPlayConsumed: consumeAutoPlayIfNeeded, onToggleChrome: onToggleChrome, @@ -252,8 +258,11 @@ struct NCMediaViewerPageView: View { playbackOptions: model.playbackOptions, onPrevious: goToPreviousPage, onNext: goToNextPage, - onPlayNextMedia: { [onNextMediaOfSameType] in - onNextMediaOfSameType(NKTypeClassFile.audio.rawValue) + onPlayNextMedia: { [onNextMediaOfSameType] completion in + onNextMediaOfSameType( + NKTypeClassFile.audio.rawValue, + completion + ) }, onAutoPlayConsumed: consumeAutoPlayIfNeeded, onToggleChrome: onToggleChrome diff --git a/iOSClient/Viewer/NCViewerMedia/Views/NCMediaViewerPagingView.swift b/iOSClient/Viewer/NCViewerMedia/Views/NCMediaViewerPagingView.swift index 47d28719c7..e223509638 100644 --- a/iOSClient/Viewer/NCViewerMedia/Views/NCMediaViewerPagingView.swift +++ b/iOSClient/Viewer/NCViewerMedia/Views/NCMediaViewerPagingView.swift @@ -481,15 +481,29 @@ final class NCMediaViewerPagingCoordinator: NSObject, private func moveToNextMediaOfSameType( classFile: String, - after sourceIndex: Int + after sourceIndex: Int, + completion: @escaping NCMediaPlaybackAdvanceCompletion ) { Task { @MainActor [weak self] in - guard let self, - let targetIndex = await self.model.nextMediaIndex( - after: sourceIndex, - matchingClassFile: classFile - ), - self.model.selectedIndex == sourceIndex else { + guard let self else { + return + } + + guard self.model.selectedIndex == sourceIndex else { + completion(false) + return + } + + guard let targetIndex = await self.model.nextMediaIndex( + after: sourceIndex, + matchingClassFile: classFile + ) else { + completion(false) + return + } + + guard self.model.selectedIndex == sourceIndex else { + completion(false) return } @@ -498,6 +512,7 @@ final class NCMediaViewerPagingCoordinator: NSObject, at: targetIndex, shouldAutoPlay: true ) + completion(true) return } @@ -524,6 +539,7 @@ final class NCMediaViewerPagingCoordinator: NSObject, at: targetIndex, shouldAutoPlay: true ) + completion(true) } } } @@ -555,10 +571,11 @@ final class NCMediaViewerPagingCoordinator: NSObject, shouldAutoPlay: shouldAutoPlay ) }, - onNextMediaOfSameType: { [weak self] classFile in + onNextMediaOfSameType: { [weak self] classFile, completion in self?.moveToNextMediaOfSameType( classFile: classFile, - after: page.index + after: page.index, + completion: completion ) }, onAutoPlayConsumed: { [weak model] in @@ -844,7 +861,10 @@ final class NCMediaViewerPagingCell: UICollectionViewCell { onToggleChrome: @escaping () -> Void, onPreviousPage: @escaping (_ shouldAutoPlay: Bool) -> Void, onNextPage: @escaping (_ shouldAutoPlay: Bool) -> Void, - onNextMediaOfSameType: @escaping (_ classFile: String) -> Void, + onNextMediaOfSameType: @escaping ( + _ classFile: String, + _ completion: @escaping NCMediaPlaybackAdvanceCompletion + ) -> Void, onAutoPlayConsumed: @escaping () -> Void, onZoomChanged: @escaping (Bool) -> Void, onClose: @escaping (_ ocId: String?) -> Void, From 533f41f07bee2db98d0ded3001c413e3e90f475d Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Tue, 11 Aug 2026 18:00:53 +0200 Subject: [PATCH 11/14] fix: stabilize playback during media page transitions Deactivate media while paging and resume playback only after the target page settles. Signed-off-by: Marino Faggiana --- Nextcloud.xcodeproj/project.pbxproj | 12 +- .../NCMediaViewerInitialModelTests.swift | 38 ---- .../NCMediaViewerModelTests.swift | 129 +++++++++++ .../Audio/NCAudioViewerContentView.swift | 29 ++- .../AVPlayer/NCVideoAVPlayerPresenter.swift | 38 +++- .../NCVideoAVPlayerViewController.swift | 29 +-- .../NCVideoViewerContentView+AVPlayer.swift | 26 +-- .../Video/NCVideoViewerContentView.swift | 42 ++-- .../Video/VLC/NCVideoVLCPresenter.swift | 40 ++-- .../Video/VLC/NCVideoVLCViewController.swift | 7 + .../VLC/NCVideoViewerContentView+VLC.swift | 2 + .../Core/NCMediaPlaybackOptions.swift | 14 +- .../Core/NCMediaViewerModel.swift | 117 +++++++--- .../Views/NCMediaViewerPageView.swift | 3 +- .../Views/NCMediaViewerPagingView.swift | 202 ++++++++++++------ 15 files changed, 493 insertions(+), 235 deletions(-) delete mode 100644 Tests/NextcloudUnitTests/NCMediaViewerInitialModelTests.swift create mode 100644 Tests/NextcloudUnitTests/NCMediaViewerModelTests.swift diff --git a/Nextcloud.xcodeproj/project.pbxproj b/Nextcloud.xcodeproj/project.pbxproj index b9fa36ef33..59363fce4b 100644 --- a/Nextcloud.xcodeproj/project.pbxproj +++ b/Nextcloud.xcodeproj/project.pbxproj @@ -43,7 +43,7 @@ AABBCC0430A8000100F0A001 /* NCMediaPlaybackOptionsTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AABBCC0330A8000100F0A001 /* NCMediaPlaybackOptionsTests.swift */; }; AABBCC0630A8000100F0A001 /* NCVideoPlaybackPresentationContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = AABBCC0530A8000100F0A001 /* NCVideoPlaybackPresentationContext.swift */; }; AABBCC0830A8000100F0A001 /* NCVideoPlaybackPresentationContextTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AABBCC0730A8000100F0A001 /* NCVideoPlaybackPresentationContextTests.swift */; }; - AABBCC0A30A8000100F0A001 /* NCMediaViewerInitialModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AABBCC0930A8000100F0A001 /* NCMediaViewerInitialModelTests.swift */; }; + AABBCC0A30A8000100F0A001 /* NCMediaViewerModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AABBCC0930A8000100F0A001 /* NCMediaViewerModelTests.swift */; }; AABD0C8A2D5F67A400F009E6 /* XCUIElement.swift in Sources */ = {isa = PBXBuildFile; fileRef = AABD0C892D5F67A200F009E6 /* XCUIElement.swift */; }; AAE330042D2ED20200B04903 /* NCShareNavigationTitleSetting.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAE330032D2ED1FF00B04903 /* NCShareNavigationTitleSetting.swift */; }; AAFC0D042F9AA10000F0A001 /* NCFocusedAutoUploadIntroView.swift in Sources */ = {isa = PBXBuildFile; fileRef = AAFC0D012F9AA10000F0A001 /* NCFocusedAutoUploadIntroView.swift */; }; @@ -1286,7 +1286,7 @@ AABBCC0330A8000100F0A001 /* NCMediaPlaybackOptionsTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCMediaPlaybackOptionsTests.swift; sourceTree = ""; }; AABBCC0530A8000100F0A001 /* NCVideoPlaybackPresentationContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCVideoPlaybackPresentationContext.swift; sourceTree = ""; }; AABBCC0730A8000100F0A001 /* NCVideoPlaybackPresentationContextTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCVideoPlaybackPresentationContextTests.swift; sourceTree = ""; }; - AABBCC0930A8000100F0A001 /* NCMediaViewerInitialModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCMediaViewerInitialModelTests.swift; sourceTree = ""; }; + AABBCC0930A8000100F0A001 /* NCMediaViewerModelTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCMediaViewerModelTests.swift; sourceTree = ""; }; AABD0C862D5F58C400F009E6 /* Server.sh */ = {isa = PBXFileReference; lastKnownFileType = text.script.sh; path = Server.sh; sourceTree = ""; }; AABD0C892D5F67A200F009E6 /* XCUIElement.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = XCUIElement.swift; sourceTree = ""; }; AACCAB522CFE041F00DA1786 /* sl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = sl; path = sl.lproj/Intent.strings; sourceTree = ""; }; @@ -2187,7 +2187,7 @@ children = ( AABBCC0330A8000100F0A001 /* NCMediaPlaybackOptionsTests.swift */, AABBCC0730A8000100F0A001 /* NCVideoPlaybackPresentationContextTests.swift */, - AABBCC0930A8000100F0A001 /* NCMediaViewerInitialModelTests.swift */, + AABBCC0930A8000100F0A001 /* NCMediaViewerModelTests.swift */, F34BDB3B2F574A58007A222C /* BidiSafeFilenameTests.swift */, C0DECA012F65000100C0D001 /* NCCameraRollTests.swift */, ); @@ -4476,7 +4476,7 @@ files = ( AABBCC0430A8000100F0A001 /* NCMediaPlaybackOptionsTests.swift in Sources */, AABBCC0830A8000100F0A001 /* NCVideoPlaybackPresentationContextTests.swift in Sources */, - AABBCC0A30A8000100F0A001 /* NCMediaViewerInitialModelTests.swift in Sources */, + AABBCC0A30A8000100F0A001 /* NCMediaViewerModelTests.swift in Sources */, F34BDB3C2F574A58007A222C /* BidiSafeFilenameTests.swift in Sources */, C0DECA022F65000100C0D001 /* NCCameraRollTests.swift in Sources */, F372087D2BAB4C0F006B5430 /* TestConstants.swift in Sources */, @@ -6323,7 +6323,7 @@ CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 2; + CURRENT_PROJECT_VERSION = 1; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = NKUJUXUJ3B; @@ -6391,7 +6391,7 @@ CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 2; + CURRENT_PROJECT_VERSION = 1; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = NKUJUXUJ3B; diff --git a/Tests/NextcloudUnitTests/NCMediaViewerInitialModelTests.swift b/Tests/NextcloudUnitTests/NCMediaViewerInitialModelTests.swift deleted file mode 100644 index a09a43bcef..0000000000 --- a/Tests/NextcloudUnitTests/NCMediaViewerInitialModelTests.swift +++ /dev/null @@ -1,38 +0,0 @@ -// SPDX-FileCopyrightText: Nextcloud GmbH -// SPDX-FileCopyrightText: 2026 Marino Faggiana -// SPDX-License-Identifier: GPL-3.0-or-later - -import Testing -@testable import Nextcloud - -@Suite("Media viewer initial model") -@MainActor -struct NCMediaViewerInitialModelTests { - @Test("Duplicate media identifiers produce a single viewer page") - func removesDuplicateIdentifiers() { - let metadata = tableMetadata() - metadata.ocId = "current" - - let model = NCMediaViewerInitialModel( - currentMetadata: metadata, - ocIds: ["first", "current", "current", "last", "first"] - ) - - #expect(model.normalizedOcIds == ["first", "current", "last"]) - #expect(model.currentSelectedIndex == 1) - } - - @Test("Missing current media is inserted before the supplied identifiers") - func insertsMissingCurrentIdentifier() { - let metadata = tableMetadata() - metadata.ocId = "current" - - let model = NCMediaViewerInitialModel( - currentMetadata: metadata, - ocIds: ["next", "next"] - ) - - #expect(model.normalizedOcIds == ["current", "next"]) - #expect(model.currentSelectedIndex == 0) - } -} diff --git a/Tests/NextcloudUnitTests/NCMediaViewerModelTests.swift b/Tests/NextcloudUnitTests/NCMediaViewerModelTests.swift new file mode 100644 index 0000000000..80d1ec2eb4 --- /dev/null +++ b/Tests/NextcloudUnitTests/NCMediaViewerModelTests.swift @@ -0,0 +1,129 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Marino Faggiana +// SPDX-License-Identifier: GPL-3.0-or-later + +import Testing +@testable import Nextcloud + +@Suite("Media viewer model") +@MainActor +struct NCMediaViewerModelTests { + @Test("Duplicate media identifiers produce a single viewer page") + func removesDuplicateIdentifiers() { + let metadata = tableMetadata() + metadata.ocId = "current" + + let model = NCMediaViewerInitialModel( + currentMetadata: metadata, + ocIds: ["first", "current", "current", "last", "first"] + ) + + #expect(model.normalizedOcIds == ["first", "current", "last"]) + #expect(model.currentSelectedIndex == 1) + } + + @Test("Missing current media is inserted before the supplied identifiers") + func insertsMissingCurrentIdentifier() { + let metadata = tableMetadata() + metadata.ocId = "current" + + let model = NCMediaViewerInitialModel( + currentMetadata: metadata, + ocIds: ["next", "next"] + ) + + #expect(model.normalizedOcIds == ["current", "next"]) + #expect(model.currentSelectedIndex == 0) + } + + @Test("Programmatic paging deactivates media until the target settles") + func programmaticPagingActivatesOnlySettledTarget() { + let model = makeViewerModel() + + #expect(model.activePageIndex == 0) + + model.beginPageTransition( + to: 1, + shouldAutoPlay: true + ) + + #expect(model.pageTransition == .programmatic(targetIndex: 1)) + #expect(model.selectedIndex == 0) + #expect(model.activePageIndex == nil) + #expect(model.autoPlayTargetIndex == 1) + + model.finishPageTransition(at: 1) + + #expect(model.pageTransition == .idle) + #expect(model.selectedIndex == 1) + #expect(model.activePageIndex == 1) + #expect(model.autoPlayTargetIndex == 1) + } + + @Test("Interactive paging keeps intermediate pages inactive") + func interactivePagingKeepsIntermediatePagesInactive() { + let model = makeViewerModel() + + model.beginPageTransition( + to: nil, + shouldAutoPlay: false + ) + model.updateSelectedIndexDuringTransition(1) + + #expect(model.pageTransition == .interactive) + #expect(model.selectedIndex == 1) + #expect(model.activePageIndex == nil) + + model.finishPageTransition(at: 1) + + #expect(model.pageTransition == .idle) + #expect(model.activePageIndex == 1) + } + + @Test("Settling on an unexpected page cancels pending autoplay") + func unexpectedSettledPageCancelsAutoPlay() { + let model = makeViewerModel() + + model.beginPageTransition( + to: 2, + shouldAutoPlay: true + ) + model.finishPageTransition(at: 1) + + #expect(model.pageTransition == .idle) + #expect(model.activePageIndex == 1) + #expect(model.autoPlayTargetIndex == nil) + } + + @Test("Selection updates are ignored outside an interactive transition") + func ignoresTransitionUpdatesWhileIdle() { + let model = makeViewerModel() + + model.updateSelectedIndexDuringTransition(1) + + #expect(model.selectedIndex == 0) + #expect(model.activePageIndex == 0) + } + + @Test("Loading a page does not implicitly change the selection") + func loadingDoesNotSelectPage() async { + let model = makeViewerModel() + + await model.displayPage(at: 1) + + #expect(model.selectedIndex == 0) + #expect(model.activePageIndex == 0) + } + + private func makeViewerModel() -> NCMediaViewerModel { + let metadata = tableMetadata() + metadata.ocId = "first" + + return NCMediaViewerModel( + currentMetadata: metadata, + ocIds: ["first", "second", "third"], + session: NCSession().getSession(account: ""), + loader: NCMediaViewerLoader() + ) + } +} diff --git a/iOSClient/Viewer/NCViewerMedia/Content/Audio/NCAudioViewerContentView.swift b/iOSClient/Viewer/NCViewerMedia/Content/Audio/NCAudioViewerContentView.swift index 1707b2c155..bde50e243c 100644 --- a/iOSClient/Viewer/NCViewerMedia/Content/Audio/NCAudioViewerContentView.swift +++ b/iOSClient/Viewer/NCViewerMedia/Content/Audio/NCAudioViewerContentView.swift @@ -16,6 +16,7 @@ struct NCAudioViewerContentView: View { let navigationBar: UINavigationBar? let canGoPrevious: Bool let canGoNext: Bool + let isSelected: Bool let shouldAutoPlay: Bool @ObservedObject var playbackOptions: NCMediaPlaybackOptions let onPrevious: (_ shouldAutoPlay: Bool) -> Void @@ -34,6 +35,7 @@ struct NCAudioViewerContentView: View { navigationBar: UINavigationBar? = nil, canGoPrevious: Bool = false, canGoNext: Bool = false, + isSelected: Bool = true, shouldAutoPlay: Bool = false, playbackOptions: NCMediaPlaybackOptions, onPrevious: @escaping (_ shouldAutoPlay: Bool) -> Void = { _ in }, @@ -51,6 +53,7 @@ struct NCAudioViewerContentView: View { self.navigationBar = navigationBar self.canGoPrevious = canGoPrevious self.canGoNext = canGoNext + self.isSelected = isSelected self.shouldAutoPlay = shouldAutoPlay self.playbackOptions = playbackOptions self.onPrevious = onPrevious @@ -128,7 +131,7 @@ struct NCAudioViewerContentView: View { ), in: 0...max(model.duration, 1) ) - .disabled(model.duration <= 0) + .disabled(!isSelected || model.duration <= 0) HStack { Text(formatTime(model.currentTime)) @@ -151,6 +154,7 @@ struct NCAudioViewerContentView: View { .foregroundStyle(primaryForegroundStyle) } .buttonStyle(.plain) + .disabled(!isSelected) Button { model.restart() @@ -160,7 +164,7 @@ struct NCAudioViewerContentView: View { .foregroundStyle(mutedForegroundStyle) } .buttonStyle(.plain) - .disabled(model.duration <= 0) + .disabled(!isSelected || model.duration <= 0) .offset( x: -(playButtonSize / 2 + buttonSpacing + sideButtonSize / 2) ) @@ -199,6 +203,10 @@ struct NCAudioViewerContentView: View { .frame(maxWidth: .infinity, maxHeight: .infinity) } .task(id: localURL) { + guard isSelected else { + return + } + model.configurePlaybackCompletion( options: playbackOptions, onPlayNextMedia: onPlayNextMedia @@ -206,6 +214,21 @@ struct NCAudioViewerContentView: View { await model.load(url: localURL) await consumeAutoPlayIfNeeded() } + .onChange(of: isSelected) { _, selected in + guard selected else { + model.stop() + return + } + + Task { @MainActor in + model.configurePlaybackCompletion( + options: playbackOptions, + onPlayNextMedia: onPlayNextMedia + ) + await model.load(url: localURL) + await consumeAutoPlayIfNeeded() + } + } .onChange(of: shouldAutoPlay) { _, newValue in guard newValue else { return @@ -254,7 +277,7 @@ struct NCAudioViewerContentView: View { Button(action: action) { Image(systemName: systemName) .font(.system(size: 17, weight: .regular)) - .foregroundStyle(isActive ? Color.accentColor : Color.primary) + .foregroundStyle(isActive ? Color.accentColor : primaryForegroundStyle) .shadow( color: .black.opacity(0.35), radius: 2, diff --git a/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoAVPlayerPresenter.swift b/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoAVPlayerPresenter.swift index 71fd3fc8f2..594db240c4 100644 --- a/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoAVPlayerPresenter.swift +++ b/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoAVPlayerPresenter.swift @@ -12,6 +12,7 @@ enum NCVideoAVPlayerPresenter { private static weak var currentViewController: NCVideoAVPlayerViewController? private static var currentURL: URL? private static var isPresenting = false + private static var pendingDismissCompletions: [() -> Void]? // MARK: - Public API // Presents or updates the single AVPlayer fullscreen controller. @@ -33,6 +34,11 @@ enum NCVideoAVPlayerPresenter { onPlaybackError: (() -> Void)? = nil ) -> Bool { let url = preparedPlayback.url + + guard pendingDismissCompletions == nil else { + return false + } + if currentURL == url, let currentViewController { currentViewController.update( @@ -167,17 +173,27 @@ enum NCVideoAVPlayerPresenter { } static func dismissCurrent(completion: (() -> Void)? = nil) { + if pendingDismissCompletions != nil { + if let completion { + pendingDismissCompletions?.append(completion) + } + return + } + + pendingDismissCompletions = completion.map { [$0] } ?? [] + guard let currentViewController else { - completion?() + finishDismissal(for: nil) return } + currentViewController.stopForDismissal() + let controllerToDismiss = currentViewController.navigationController ?? currentViewController controllerToDismiss.dismiss(animated: false) { - clearCurrent(currentViewController) - completion?() + finishDismissal(for: currentViewController) } } @@ -185,6 +201,22 @@ enum NCVideoAVPlayerPresenter { dismissCurrent(completion: completion) } + private static func finishDismissal( + for viewController: NCVideoAVPlayerViewController? + ) { + if let viewController { + clearCurrent(viewController) + } else { + currentViewController = nil + currentURL = nil + isPresenting = false + } + + let completions = pendingDismissCompletions ?? [] + pendingDismissCompletions = nil + completions.forEach { $0() } + } + // MARK: - Private private static func topViewController() -> UIViewController? { let windowScene = UIApplication.shared.connectedScenes diff --git a/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoAVPlayerViewController.swift b/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoAVPlayerViewController.swift index b642407e72..b819f51048 100644 --- a/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoAVPlayerViewController.swift +++ b/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoAVPlayerViewController.swift @@ -337,33 +337,17 @@ final class NCVideoAVPlayerViewController: UIViewController { func close() { let closeCallback = onClose let closingOcId = metadata.ocId - let controllerToDismiss = navigationController ?? self - NCVideoAVPlayerPresenter.clearCurrent(self) - - controllerToDismiss.dismiss(animated: false) { [weak self] in - self?.stopControlsHideTimer() - self?.stop() - - DispatchQueue.main.async { - closeCallback?(closingOcId) - } + NCVideoAVPlayerPresenter.dismiss { + closeCallback?(closingOcId) } } func closeImmediately() { let closeCallback = onClose - let controllerToDismiss = navigationController ?? self - - NCVideoAVPlayerPresenter.clearCurrent(self) - - controllerToDismiss.dismiss(animated: false) { [weak self] in - self?.stopControlsHideTimer() - self?.stop() - DispatchQueue.main.async { - closeCallback?(nil) - } + NCVideoAVPlayerPresenter.dismiss { + closeCallback?(nil) } } @@ -557,6 +541,11 @@ final class NCVideoAVPlayerViewController: UIViewController { updateProgressControls() } + func stopForDismissal() { + stopControlsHideTimer() + stop() + } + private func applyControlsVisibilityOnStart() { if playbackPresentationContext.shouldShowControlsOnStart { showControls(animated: false) diff --git a/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoViewerContentView+AVPlayer.swift b/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoViewerContentView+AVPlayer.swift index cdc677e77a..f999de312a 100644 --- a/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoViewerContentView+AVPlayer.swift +++ b/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoViewerContentView+AVPlayer.swift @@ -20,6 +20,7 @@ extension NCVideoViewerContentView { } guard presentedAVPlayerURL != preparedPlayback.url else { + consumePendingAutoPlayIfNeeded() return true } @@ -49,6 +50,7 @@ extension NCVideoViewerContentView { } presentedAVPlayerURL = preparedPlayback.url + consumePendingAutoPlayIfNeeded() return true } @@ -61,25 +63,17 @@ extension NCVideoViewerContentView { @MainActor func goToPreviousPageFromAVPlayer() { - performFullscreenPageTransition( - dismissPlayer: { - NCVideoAVPlayerPresenter.dismiss() - }, - changePage: { - onPreviousPage?() - } - ) + NCVideoAVPlayerPresenter.dismiss { + resetPlaybackPresentationState() + onPreviousPage?() + } } @MainActor func goToNextPageFromAVPlayer() { - performFullscreenPageTransition( - dismissPlayer: { - NCVideoAVPlayerPresenter.dismiss() - }, - changePage: { - onNextPage?() - } - ) + NCVideoAVPlayerPresenter.dismiss { + resetPlaybackPresentationState() + onNextPage?() + } } } diff --git a/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoViewerContentView.swift b/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoViewerContentView.swift index 13534daf1f..214c7b17b0 100644 --- a/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoViewerContentView.swift +++ b/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoViewerContentView.swift @@ -172,7 +172,6 @@ private extension NCVideoViewerContentView { return } - onAutoPlayConsumed?() isLaunchingPlayback = true downloadAndPlayVideo() return @@ -184,18 +183,14 @@ private extension NCVideoViewerContentView { return } - if requestAVPlayerPresentation(preparedPlayback: preparedPlayback) { - onAutoPlayConsumed?() - } + requestAVPlayerPresentation(preparedPlayback: preparedPlayback) case .vlc(let preparedPlayback): guard isCurrentPlaybackVideo() else { return } - if requestVLCPresentation(preparedPlayback: preparedPlayback) { - onAutoPlayConsumed?() - } + requestVLCPresentation(preparedPlayback: preparedPlayback) case .loading, .failed: @@ -397,18 +392,10 @@ private extension NCVideoViewerContentView { switch playback.engine { case .avFoundation(let preparedPlayback): - if requestAVPlayerPresentation(preparedPlayback: preparedPlayback) { - if shouldAutoPlay { - onAutoPlayConsumed?() - } - } + requestAVPlayerPresentation(preparedPlayback: preparedPlayback) case .vlc(let preparedPlayback): - if requestVLCPresentation(preparedPlayback: preparedPlayback) { - if shouldAutoPlay { - onAutoPlayConsumed?() - } - } + requestVLCPresentation(preparedPlayback: preparedPlayback) case .loading, .failed: @@ -645,6 +632,7 @@ private extension NCVideoViewerContentView { @MainActor func downloadAndPlayVideo() { guard let downloadVideo else { + consumePendingAutoPlayIfNeeded() isLaunchingPlayback = false errorMessage = "" return @@ -693,6 +681,7 @@ private extension NCVideoViewerContentView { playbackDownloadTask = nil isDownloadingPlayback = false isLaunchingPlayback = false + consumePendingAutoPlayIfNeeded() errorMessage = "" } } @@ -709,6 +698,7 @@ private extension NCVideoViewerContentView { isDownloadingPlayback = false isLaunchingPlayback = false hasRequestedPlayback = false + consumePendingAutoPlayIfNeeded() guard let cancelVideoDownload else { return @@ -784,6 +774,15 @@ extension NCVideoViewerContentView { isLaunchingPlayback = false } + @MainActor + func consumePendingAutoPlayIfNeeded() { + guard shouldAutoPlay else { + return + } + + onAutoPlayConsumed?() + } + @MainActor func showPlaybackError() { resetPlaybackPresentationState() @@ -803,15 +802,6 @@ extension NCVideoViewerContentView { } } - @MainActor - func performFullscreenPageTransition( - dismissPlayer: @escaping () -> Void, - changePage: @escaping () -> Void - ) { - resetPlaybackPresentationState() - dismissPlayer() - changePage() - } } // MARK: - URL Resolution diff --git a/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCPresenter.swift b/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCPresenter.swift index 706d0b5976..4a17acde92 100644 --- a/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCPresenter.swift +++ b/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCPresenter.swift @@ -13,8 +13,7 @@ enum NCVideoVLCPresenter { private static weak var currentViewController: NCVideoVLCViewController? private static var currentURL: URL? private static var isPresenting = false - private static var isDismissing = false - private static var dismissCompletions: [() -> Void] = [] + private static var pendingDismissCompletions: [() -> Void]? // MARK: - Public API // Presents or updates the single VLC fullscreen controller. @@ -37,7 +36,7 @@ enum NCVideoVLCPresenter { ) -> Bool { let url = preparedPlayback.url - guard !isDismissing else { + guard pendingDismissCompletions == nil else { logPresentationRejected("dismissal in progress") return false } @@ -185,29 +184,30 @@ enum NCVideoVLCPresenter { } static func dismissCurrent(completion: (() -> Void)? = nil) { - if let completion { - dismissCompletions.append(completion) + if pendingDismissCompletions != nil { + if let completion { + pendingDismissCompletions?.append(completion) + } + return } - guard !isDismissing else { return } + pendingDismissCompletions = completion.map { [$0] } ?? [] + guard let viewController = currentViewController else { finishDismissal(for: nil) return } - isDismissing = true - viewController.stop { [weak viewController] in - guard let viewController else { - finishDismissal(for: nil) - return - } + viewController.stopForDismissal() - let controllerToDismiss = - viewController.navigationController ?? viewController + let controllerToDismiss = + viewController.navigationController ?? viewController - controllerToDismiss.dismiss(animated: false) { - finishDismissal(for: viewController) - } + // Page navigation must not depend on MobileVLCKit's asynchronous + // `.stopped` callback. Dismissal owns the UI transition while the + // controller continues its internal player cleanup independently. + controllerToDismiss.dismiss(animated: false) { + finishDismissal(for: viewController) } } @@ -226,10 +226,8 @@ enum NCVideoVLCPresenter { isPresenting = false } - isDismissing = false - - let completions = dismissCompletions - dismissCompletions.removeAll() + let completions = pendingDismissCompletions ?? [] + pendingDismissCompletions = nil completions.forEach { $0() } } diff --git a/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCViewController.swift b/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCViewController.swift index 241b26fa8a..aed7cc35b0 100644 --- a/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCViewController.swift +++ b/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCViewController.swift @@ -503,6 +503,13 @@ final class NCVideoVLCViewController: UIViewController { mediaPlayer.stop() } + func stopForDismissal() { + // A queued URL replacement no longer applies once this controller is + // leaving the screen and must not restart playback after dismissal. + stopCompletions.removeAll() + stop() + } + private func finishStop() { isStopInFlight = false mediaPlayer.media = nil diff --git a/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoViewerContentView+VLC.swift b/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoViewerContentView+VLC.swift index 7c8f55b655..a373037ed4 100644 --- a/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoViewerContentView+VLC.swift +++ b/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoViewerContentView+VLC.swift @@ -20,6 +20,7 @@ extension NCVideoViewerContentView { } guard presentedVLCURL != preparedPlayback.url else { + consumePendingAutoPlayIfNeeded() return true } @@ -49,6 +50,7 @@ extension NCVideoViewerContentView { } presentedVLCURL = preparedPlayback.url + consumePendingAutoPlayIfNeeded() return true } diff --git a/iOSClient/Viewer/NCViewerMedia/Core/NCMediaPlaybackOptions.swift b/iOSClient/Viewer/NCViewerMedia/Core/NCMediaPlaybackOptions.swift index e19846de75..4ce3c61f80 100644 --- a/iOSClient/Viewer/NCViewerMedia/Core/NCMediaPlaybackOptions.swift +++ b/iOSClient/Viewer/NCViewerMedia/Core/NCMediaPlaybackOptions.swift @@ -4,12 +4,6 @@ import Combine -enum NCMediaPlaybackCompletionAction: Equatable { - case stop - case repeatCurrentItem - case playNextItem -} - typealias NCMediaPlaybackAdvanceCompletion = (_ didAdvance: Bool) -> Void // Auto-advance lookup can finish after the player reaches its end state. The @@ -18,6 +12,12 @@ typealias NCMediaPlaybackAdvanceRequest = (@escaping NCMediaPlaybackAdvanceCompl @MainActor final class NCMediaPlaybackOptions: ObservableObject { + enum CompletionAction: Equatable { + case stop + case repeatCurrentItem + case playNextItem + } + @Published private(set) var isRepeatEnabled: Bool @Published private(set) var isAutoAdvanceEnabled: Bool @@ -29,7 +29,7 @@ final class NCMediaPlaybackOptions: ObservableObject { self.isAutoAdvanceEnabled = preferences?.mediaViewerAutoAdvance ?? false } - var completionAction: NCMediaPlaybackCompletionAction { + var completionAction: CompletionAction { if isRepeatEnabled { return .repeatCurrentItem } diff --git a/iOSClient/Viewer/NCViewerMedia/Core/NCMediaViewerModel.swift b/iOSClient/Viewer/NCViewerMedia/Core/NCMediaViewerModel.swift index 48721f081f..9e56d45ca5 100644 --- a/iOSClient/Viewer/NCViewerMedia/Core/NCMediaViewerModel.swift +++ b/iOSClient/Viewer/NCViewerMedia/Core/NCMediaViewerModel.swift @@ -100,10 +100,28 @@ private struct NCMediaViewerLoadingTask { // Coordinates media paging, loading, and prefetching. @MainActor final class NCMediaViewerModel: ObservableObject { + enum PageTransition: Equatable { + case idle + case interactive + case programmatic(targetIndex: Int) + + var isIdle: Bool { + self == .idle + } + + var isProgrammatic: Bool { + if case .programmatic = self { + return true + } + + return false + } + } // MARK: - Published State @Published private(set) var selectedIndex: Int + @Published private(set) var pageTransition = PageTransition.idle @Published private(set) var revision: Int = 0 @Published private(set) var thumbnailReloadRevision: Int = 0 @Published private(set) var isChromeHidden = false @@ -143,6 +161,10 @@ final class NCMediaViewerModel: ObservableObject { selectedIndex } + var activePageIndex: Int? { + pageTransition.isIdle ? selectedIndex : nil + } + var selectedOcId: String? { guard ocIds.indices.contains(selectedIndex) else { return nil @@ -176,22 +198,80 @@ final class NCMediaViewerModel: ObservableObject { return cachedPagesByOcId[ocId]?.metadata } - func requestAutoPlay(at index: Int) { - guard ocIds.indices.contains(index) else { + func clearAutoPlayIfNeeded(for index: Int) { + guard autoPlayTargetIndex == index else { + return + } + + setAutoPlayTargetIndex(nil) + } + + private func setAutoPlayTargetIndex(_ index: Int?) { + guard autoPlayTargetIndex != index else { return } autoPlayTargetIndex = index - revision &+= 1 } - func clearAutoPlayIfNeeded(for index: Int) { - guard autoPlayTargetIndex == index else { + /// Deactivates media content while the collection view moves between pages. + /// The target becomes active only after the paging animation settles. + func beginPageTransition( + to targetIndex: Int?, + shouldAutoPlay: Bool + ) { + if let targetIndex, + !ocIds.indices.contains(targetIndex) { + return + } + + if let targetIndex { + pageTransition = .programmatic(targetIndex: targetIndex) + } else { + pageTransition = .interactive + } + + setAutoPlayTargetIndex( + shouldAutoPlay ? targetIndex : nil + ) + } + + /// Updates the visible selection during an interactive transition without + /// activating media playback on an intermediate page. + func updateSelectedIndexDuringTransition(_ index: Int) { + guard ocIds.indices.contains(index) else { + return + } + + guard pageTransition == .interactive else { + return + } + + guard selectedIndex != index else { return } - autoPlayTargetIndex = nil - revision &+= 1 + selectedIndex = index + } + + /// Commits the page that actually settled after a transition. + func finishPageTransition(at index: Int) { + guard ocIds.indices.contains(index) else { + return + } + + if selectedIndex != index { + selectedIndex = index + } + + if pageTransition != .idle { + pageTransition = .idle + } + + if let autoPlayTargetIndex, + autoPlayTargetIndex != index { + setAutoPlayTargetIndex(nil) + } } @MainActor @@ -278,18 +358,16 @@ final class NCMediaViewerModel: ObservableObject { } func displayPage(at index: Int) async { - guard ocIds.indices.contains(index) else { + guard ocIds.indices.contains(index), + selectedIndex == index else { return } - if selectedIndex == index, - let ocId = ocId(at: index), + if let ocId = ocId(at: index), !pageState(for: ocId).needsSelectedPageLoading { return } - selectedIndex = index - prefetchNeighborPages(around: index) await loadPageIfNeeded(index: index) } @@ -300,10 +378,11 @@ final class NCMediaViewerModel: ObservableObject { } guard selectedIndex != index else { + finishPageTransition(at: index) return } - selectedIndex = index + finishPageTransition(at: index) let ocId = ocIds[index] @@ -466,18 +545,6 @@ final class NCMediaViewerModel: ObservableObject { await loader.cancelDownload(for: ocId) } - func setSelectedIndex(_ index: Int) { - guard ocIds.indices.contains(index) else { - return - } - - guard selectedIndex != index else { - return - } - - selectedIndex = index - } - func prefetchVisiblePageIfNeeded(index: Int) async { guard ocIds.indices.contains(index) else { return diff --git a/iOSClient/Viewer/NCViewerMedia/Views/NCMediaViewerPageView.swift b/iOSClient/Viewer/NCViewerMedia/Views/NCMediaViewerPageView.swift index 43a61cf668..1392a88ec2 100644 --- a/iOSClient/Viewer/NCViewerMedia/Views/NCMediaViewerPageView.swift +++ b/iOSClient/Viewer/NCViewerMedia/Views/NCMediaViewerPageView.swift @@ -29,7 +29,7 @@ struct NCMediaViewerPageView: View { let navigationBar: UINavigationBar? private var isSelected: Bool { - model.selectedIndex == page.index + model.activePageIndex == page.index } // MARK: - Body @@ -254,6 +254,7 @@ struct NCMediaViewerPageView: View { navigationBar: navigationBar, canGoPrevious: canGoPrevious, canGoNext: canGoNext, + isSelected: isSelected, shouldAutoPlay: effectiveShouldAutoPlay, playbackOptions: model.playbackOptions, onPrevious: goToPreviousPage, diff --git a/iOSClient/Viewer/NCViewerMedia/Views/NCMediaViewerPagingView.swift b/iOSClient/Viewer/NCViewerMedia/Views/NCMediaViewerPagingView.swift index e223509638..85acc45b36 100644 --- a/iOSClient/Viewer/NCViewerMedia/Views/NCMediaViewerPagingView.swift +++ b/iOSClient/Viewer/NCViewerMedia/Views/NCMediaViewerPagingView.swift @@ -130,8 +130,6 @@ final class NCMediaViewerPagingCoordinator: NSObject, private var lastCollectionViewBoundsSize: CGSize = .zero private var cancellable: AnyCancellable? private var lastVisibleIndex: Int? - private var isUserPaging = false - private var isAutoPlayPaging = false private var isAdjustingLayout = false // MARK: - Init @@ -281,10 +279,12 @@ final class NCMediaViewerPagingCoordinator: NSObject, return } - jumpToIndex( + guard jumpToIndex( index, animated: animated - ) + ) else { + return + } didScrollToInitialIndex = true lastVisibleIndex = index @@ -301,10 +301,9 @@ final class NCMediaViewerPagingCoordinator: NSObject, } func jumpToSelectedIndexIfNeeded(animated: Bool) { - // During an autoplay transition, selectedIndex still represents the - // source page until the scroll reaches the target. Every manual paging - // path keeps its original continuous selection behavior. - guard !isAutoPlayPaging else { + // Programmatic transitions own the collection offset until they settle. + // Model publications must not snap the collection back to the source. + guard !model.pageTransition.isProgrammatic else { return } @@ -329,35 +328,49 @@ final class NCMediaViewerPagingCoordinator: NSObject, ) } + @discardableResult private func scrollToIndex( _ index: Int, animated: Bool - ) { + ) -> Bool { guard model.numberOfPages > 0 else { - return + return false } guard index >= 0, index < model.numberOfPages else { - return + return false + } + + guard let collectionView else { + return false + } + + collectionView.layoutIfNeeded() + + guard collectionView.bounds.width > 0 else { + return false } let didChangePage = lastVisibleIndex != index - lastVisibleIndex = index + let targetOffsetX = CGFloat(index) * collectionView.bounds.width + let shouldAnimate = animated && + abs(collectionView.contentOffset.x - targetOffsetX) > 0.5 + + guard jumpToIndex( + index, + animated: shouldAnimate + ) else { + return false + } if didChangePage { onZoomChanged(false) } - jumpToIndex( - index, - animated: animated - ) - - if !animated { - isUserPaging = false - isAutoPlayPaging = false - model.setSelectedIndex(index) + if !shouldAnimate { + lastVisibleIndex = index + model.finishPageTransition(at: index) refreshVisibleCells() Task { @@ -368,20 +381,24 @@ final class NCMediaViewerPagingCoordinator: NSObject, updateCollectionBackground(for: index) updateVisibleMetadataTitle(for: index) refreshVisibleCells() + return true } + @discardableResult private func jumpToIndex( _ index: Int, animated: Bool - ) { + ) -> Bool { guard let collectionView else { - return + return false } collectionView.layoutIfNeeded() - guard collectionView.bounds.width > 0 else { - return + guard collectionView.bounds.width > 0, + index >= 0, + index < model.numberOfPages else { + return false } if animated { @@ -401,6 +418,8 @@ final class NCMediaViewerPagingCoordinator: NSObject, animated: false ) } + + return true } // MARK: - Visible Cell Refresh @@ -426,31 +445,56 @@ final class NCMediaViewerPagingCoordinator: NSObject, // MARK: - Page Navigation + @discardableResult private func moveToPage( offset: Int, shouldAutoPlay: Bool - ) { + ) -> Bool { let targetIndex = model.selectedIndex + offset - moveToPage( + return moveToPage( at: targetIndex, shouldAutoPlay: shouldAutoPlay ) } + @discardableResult private func moveToPage( at targetIndex: Int, shouldAutoPlay: Bool - ) { + ) -> Bool { + guard model.pageTransition.isIdle else { + return false + } + guard targetIndex >= 0, targetIndex < model.numberOfPages else { - return + return false + } + + guard let collectionView else { + return false + } + + collectionView.layoutIfNeeded() + + guard collectionView.bounds.width > 0 else { + return false } + let sourceIndex = model.selectedIndex + if !shouldAutoPlay { model.setChromeHidden(false) } + // Deactivate the source before publishing the viewer-wide stop. This + // prevents synchronous player cleanup from reloading the source page. + model.beginPageTransition( + to: targetIndex, + shouldAutoPlay: shouldAutoPlay + ) + // Stop the current media playback before programmatic page navigation. // This is intentionally broad because previous/next can move across image, // audio, AVPlayer, and VLC pages. @@ -459,24 +503,22 @@ final class NCMediaViewerPagingCoordinator: NSObject, object: nil ) - // Mark the transition before publishing the autoplay target. Publishing - // causes SwiftUI to call updateUIView, which must not restore the source - // index while the collection view is moving to the target page. - isUserPaging = true - isAutoPlayPaging = shouldAutoPlay - - if shouldAutoPlay { - model.requestAutoPlay(at: targetIndex) - } - updateCollectionBackground(for: targetIndex) updateVisibleMetadataTitle(for: targetIndex) refreshVisibleCells() - scrollToIndex( + guard scrollToIndex( targetIndex, animated: true - ) + ) else { + model.finishPageTransition(at: sourceIndex) + updateCollectionBackground(for: sourceIndex) + updateVisibleMetadataTitle(for: sourceIndex) + refreshVisibleCells() + return false + } + + return true } private func moveToNextMediaOfSameType( @@ -485,16 +527,20 @@ final class NCMediaViewerPagingCoordinator: NSObject, completion: @escaping NCMediaPlaybackAdvanceCompletion ) { Task { @MainActor [weak self] in - guard let self else { + guard let model = self?.model, + self?.collectionView != nil else { + completion(false) return } - guard self.model.selectedIndex == sourceIndex else { + guard model.playbackOptions.completionAction == .playNextItem, + model.pageTransition.isIdle, + model.activePageIndex == sourceIndex else { completion(false) return } - guard let targetIndex = await self.model.nextMediaIndex( + guard let targetIndex = await model.nextMediaIndex( after: sourceIndex, matchingClassFile: classFile ) else { @@ -502,17 +548,22 @@ final class NCMediaViewerPagingCoordinator: NSObject, return } - guard self.model.selectedIndex == sourceIndex else { + guard let self, + self.model === model, + self.collectionView != nil, + model.playbackOptions.completionAction == .playNextItem, + model.pageTransition.isIdle, + model.activePageIndex == sourceIndex else { completion(false) return } guard classFile == NKTypeClassFile.video.rawValue else { - self.moveToPage( + let didMove = self.moveToPage( at: targetIndex, shouldAutoPlay: true ) - completion(true) + completion(didMove) return } @@ -527,19 +578,30 @@ final class NCMediaViewerPagingCoordinator: NSObject, NCVideoAVPlayerPresenter.dismiss { [weak self] in NCVideoVLCPresenter.dismiss { [weak self] in guard let self else { + completion(false) return } - guard self.model.selectedIndex == sourceIndex else { - self.model.setChromeHidden(wasChromeHidden) + guard self.model === model, + self.collectionView != nil, + model.playbackOptions.completionAction == .playNextItem, + model.pageTransition.isIdle, + model.activePageIndex == sourceIndex else { + model.setChromeHidden(wasChromeHidden) + completion(false) return } - self.moveToPage( + let didMove = self.moveToPage( at: targetIndex, shouldAutoPlay: true ) - completion(true) + + if !didMove { + model.setChromeHidden(wasChromeHidden) + } + + completion(didMove) } } } @@ -551,8 +613,6 @@ final class NCMediaViewerPagingCoordinator: NSObject, cell.configure( model: model, page: page, - isSelected: !isUserPaging && page.index == model.selectedIndex, - isChromeHidden: model.isChromeHidden, backgroundColor: pageBackgroundColor, canGoPrevious: page.index > 0, canGoNext: page.index < model.numberOfPages - 1, @@ -572,7 +632,12 @@ final class NCMediaViewerPagingCoordinator: NSObject, ) }, onNextMediaOfSameType: { [weak self] classFile, completion in - self?.moveToNextMediaOfSameType( + guard let self else { + completion(false) + return + } + + self.moveToNextMediaOfSameType( classFile: classFile, after: page.index, completion: completion @@ -583,8 +648,8 @@ final class NCMediaViewerPagingCoordinator: NSObject, }, onZoomChanged: { [weak self] isZoomed in guard let self, - !self.isUserPaging, - page.index == self.model.selectedIndex else { + self.model.pageTransition.isIdle, + page.index == self.model.activePageIndex else { return } @@ -647,9 +712,12 @@ final class NCMediaViewerPagingCoordinator: NSObject, // MARK: - UIScrollViewDelegate func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { - // A user drag takes ownership of any in-flight autoplay transition. - isAutoPlayPaging = false - isUserPaging = true + // A user drag takes ownership of any in-flight transition and clears a + // pending autoplay request until the settled page is known. + model.beginPageTransition( + to: nil, + shouldAutoPlay: false + ) // Stop the current media playback before manual page navigation. // This is intentionally broad because dragging can move across image, @@ -684,7 +752,7 @@ final class NCMediaViewerPagingCoordinator: NSObject, lastVisibleIndex = index onZoomChanged(false) - model.setSelectedIndex(index) + model.updateSelectedIndexDuringTransition(index) updateCollectionBackground(for: index) updateVisibleMetadataTitle(for: index) refreshVisibleCells() @@ -732,9 +800,9 @@ final class NCMediaViewerPagingCoordinator: NSObject, } func scrollViewDidScroll(_ scrollView: UIScrollView) { - // Auto-next keeps selectedIndex on the source page until it settles. - // Manual swipes preserve their original update behavior. - guard !isAutoPlayPaging else { + // Programmatic navigation commits selection only after it settles. + // Interactive swipes continue updating thumbnails and titles. + guard !model.pageTransition.isProgrammatic else { return } @@ -752,7 +820,7 @@ final class NCMediaViewerPagingCoordinator: NSObject, lastVisibleIndex = index onZoomChanged(false) - model.setSelectedIndex(index) + model.updateSelectedIndexDuringTransition(index) updateCollectionBackground(for: index) updateVisibleMetadataTitle(for: index) refreshVisibleCells() @@ -789,11 +857,9 @@ final class NCMediaViewerPagingCoordinator: NSObject, } // The settled page is now the selected page. - isUserPaging = false - isAutoPlayPaging = false lastVisibleIndex = index - model.setSelectedIndex(index) + model.finishPageTransition(at: index) updateCollectionBackground(for: index) updateVisibleMetadataTitle(for: index) refreshVisibleCells() @@ -853,8 +919,6 @@ final class NCMediaViewerPagingCell: UICollectionViewCell { func configure( model: NCMediaViewerModel, page: NCMediaViewerPageModel, - isSelected: Bool, - isChromeHidden: Bool, backgroundColor: UIColor, canGoPrevious: Bool, canGoNext: Bool, From 94417b660f47f4062321baaaad4a1c8a451f8656 Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Tue, 11 Aug 2026 18:06:37 +0200 Subject: [PATCH 12/14] fix: skip animation for non-adjacent media transitions Signed-off-by: Marino Faggiana --- .../NCViewerMedia/Views/NCMediaViewerPagingView.swift | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/iOSClient/Viewer/NCViewerMedia/Views/NCMediaViewerPagingView.swift b/iOSClient/Viewer/NCViewerMedia/Views/NCMediaViewerPagingView.swift index 85acc45b36..a8be29f1c3 100644 --- a/iOSClient/Viewer/NCViewerMedia/Views/NCMediaViewerPagingView.swift +++ b/iOSClient/Viewer/NCViewerMedia/Views/NCMediaViewerPagingView.swift @@ -483,6 +483,10 @@ final class NCMediaViewerPagingCoordinator: NSObject, } let sourceIndex = model.selectedIndex + // UIKit animates the full content offset, including every page between + // the source and destination. Keep the familiar animation for adjacent + // navigation, but jump directly when auto-advance skips other media. + let shouldAnimateTransition = abs(targetIndex - sourceIndex) == 1 if !shouldAutoPlay { model.setChromeHidden(false) @@ -509,7 +513,7 @@ final class NCMediaViewerPagingCoordinator: NSObject, guard scrollToIndex( targetIndex, - animated: true + animated: shouldAnimateTransition ) else { model.finishPageTransition(at: sourceIndex) updateCollectionBackground(for: sourceIndex) From 2695576119b19863e729915a7eab89d07f9ab61f Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Wed, 12 Aug 2026 08:46:53 +0200 Subject: [PATCH 13/14] fix: prevent cover flash during local video auto-advance Signed-off-by: Marino Faggiana --- .../Content/Video/NCVideoViewerContentView.swift | 13 +++++++++++++ .../NCViewerMedia/Views/NCMediaViewerPageView.swift | 1 + 2 files changed, 14 insertions(+) diff --git a/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoViewerContentView.swift b/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoViewerContentView.swift index 214c7b17b0..31019ef5ff 100644 --- a/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoViewerContentView.swift +++ b/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoViewerContentView.swift @@ -19,6 +19,7 @@ struct NCVideoViewerContentView: View { let canGoPrevious: Bool let canGoNext: Bool let shouldAutoPlay: Bool + let isAutomaticAdvanceTarget: Bool @ObservedObject var playbackOptions: NCMediaPlaybackOptions let onPreviousPage: (() -> Void)? let onNextPage: (() -> Void)? @@ -59,6 +60,7 @@ struct NCVideoViewerContentView: View { canGoPrevious: Bool = false, canGoNext: Bool = false, shouldAutoPlay: Bool = false, + isAutomaticAdvanceTarget: Bool = false, playbackOptions: NCMediaPlaybackOptions, onPreviousPage: (() -> Void)? = nil, onNextPage: (() -> Void)? = nil, @@ -80,6 +82,7 @@ struct NCVideoViewerContentView: View { self.canGoPrevious = canGoPrevious self.canGoNext = canGoNext self.shouldAutoPlay = shouldAutoPlay + self.isAutomaticAdvanceTarget = isAutomaticAdvanceTarget self.playbackOptions = playbackOptions self.onPreviousPage = onPreviousPage self.onNextPage = onNextPage @@ -214,6 +217,12 @@ private extension NCVideoViewerContentView { if case .failed(let message) = playback.engine, !requiresUserTrustedCertificateDownload { failedView(message) + } else if shouldHideCoverDuringAutomaticLocalPlayback { + // Local auto-advance should not briefly expose the underlying + // cover between fullscreen video presentations. + Color.black + .ignoresSafeArea() + .allowsHitTesting(false) } else { NCVideoPlaybackCoverView( previewURL: previewURL, @@ -235,6 +244,10 @@ private extension NCVideoViewerContentView { } } + var shouldHideCoverDuringAutomaticLocalPlayback: Bool { + isAutomaticAdvanceTarget && localURL != nil + } + @ViewBuilder var requestedPlaybackView: some View { switch playback.engine { diff --git a/iOSClient/Viewer/NCViewerMedia/Views/NCMediaViewerPageView.swift b/iOSClient/Viewer/NCViewerMedia/Views/NCMediaViewerPageView.swift index 1392a88ec2..90c392f39a 100644 --- a/iOSClient/Viewer/NCViewerMedia/Views/NCMediaViewerPageView.swift +++ b/iOSClient/Viewer/NCViewerMedia/Views/NCMediaViewerPageView.swift @@ -214,6 +214,7 @@ struct NCMediaViewerPageView: View { canGoPrevious: canGoPrevious, canGoNext: canGoNext, shouldAutoPlay: effectiveShouldAutoPlay, + isAutomaticAdvanceTarget: model.autoPlayTargetIndex == page.index, playbackOptions: model.playbackOptions, onPreviousPage: goToPreviousPageFromVideo, onNextPage: goToNextPageFromVideo, From de227a769963901883fc53bb355c02fdd255d727 Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Wed, 12 Aug 2026 08:50:02 +0200 Subject: [PATCH 14/14] chore: bump project build number to 2 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 59363fce4b..c25611872e 100644 --- a/Nextcloud.xcodeproj/project.pbxproj +++ b/Nextcloud.xcodeproj/project.pbxproj @@ -6323,7 +6323,7 @@ CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = dwarf; DEVELOPMENT_TEAM = NKUJUXUJ3B; @@ -6391,7 +6391,7 @@ CLANG_WARN_UNREACHABLE_CODE = YES; CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; COPY_PHASE_STRIP = NO; - CURRENT_PROJECT_VERSION = 1; + CURRENT_PROJECT_VERSION = 2; DEAD_CODE_STRIPPING = YES; DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; DEVELOPMENT_TEAM = NKUJUXUJ3B;