diff --git a/Nextcloud.xcodeproj/project.pbxproj b/Nextcloud.xcodeproj/project.pbxproj index 7e9a731547..c25611872e 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 */; }; @@ -40,8 +39,12 @@ 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 */; }; + AABBCC0630A8000100F0A001 /* NCVideoPlaybackPresentationContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = AABBCC0530A8000100F0A001 /* NCVideoPlaybackPresentationContext.swift */; }; + AABBCC0830A8000100F0A001 /* NCVideoPlaybackPresentationContextTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AABBCC0730A8000100F0A001 /* NCVideoPlaybackPresentationContextTests.swift */; }; + AABBCC0A30A8000100F0A001 /* NCMediaViewerModelTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = AABBCC0930A8000100F0A001 /* NCMediaViewerModelTests.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 */; }; @@ -1261,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 = ""; }; @@ -1280,9 +1282,13 @@ 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 = ""; }; + 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 /* 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 = ""; }; - 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 = ""; }; @@ -2179,9 +2185,11 @@ AA52EB442D42AC500089C348 /* NextcloudUnitTests */ = { isa = PBXGroup; children = ( + AABBCC0330A8000100F0A001 /* NCMediaPlaybackOptionsTests.swift */, + AABBCC0730A8000100F0A001 /* NCVideoPlaybackPresentationContextTests.swift */, + AABBCC0930A8000100F0A001 /* NCMediaViewerModelTests.swift */, F34BDB3B2F574A58007A222C /* BidiSafeFilenameTests.swift */, C0DECA012F65000100C0D001 /* NCCameraRollTests.swift */, - AA52EB452D42AC5A0089C348 /* Placeholder.swift */, ); path = NextcloudUnitTests; sourceTree = ""; @@ -2234,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 */, @@ -2595,6 +2594,8 @@ F749ED342FAF0EE200CE8DFA /* Core */ = { isa = PBXGroup; children = ( + AABBCC0130A8000100F0A001 /* NCMediaPlaybackOptions.swift */, + AABBCC0530A8000100F0A001 /* NCVideoPlaybackPresentationContext.swift */, F7CDB5BB2FA33CA300F72306 /* NCMediaViewerView.swift */, F7CDB5B82FA33CA300F72306 /* NCMediaViewerModel.swift */, ); @@ -4473,7 +4474,9 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - AA52EB472D42AC9E0089C348 /* Placeholder.swift in Sources */, + AABBCC0430A8000100F0A001 /* NCMediaPlaybackOptionsTests.swift in Sources */, + AABBCC0830A8000100F0A001 /* NCVideoPlaybackPresentationContextTests.swift in Sources */, + AABBCC0A30A8000100F0A001 /* NCMediaViewerModelTests.swift in Sources */, F34BDB3C2F574A58007A222C /* BidiSafeFilenameTests.swift in Sources */, C0DECA022F65000100C0D001 /* NCCameraRollTests.swift in Sources */, F372087D2BAB4C0F006B5430 /* TestConstants.swift in Sources */, @@ -4498,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; }; @@ -4884,6 +4886,8 @@ F7CDB5C32FA33CA300F72306 /* NCMediaViewerPageView.swift in Sources */, 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 */, @@ -6319,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; @@ -6387,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; 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 new file mode 100644 index 0000000000..64a2782117 --- /dev/null +++ b/Tests/NextcloudUnitTests/NCMediaPlaybackOptionsTests.swift @@ -0,0 +1,77 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Marino Faggiana +// SPDX-License-Identifier: GPL-3.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(preferences: nil) + + #expect(options.completionAction == .stop) + } + + @Test("Automatic advance plays the next compatible item") + func advancesAutomatically() { + let options = NCMediaPlaybackOptions(preferences: nil) + + options.toggleAutoAdvance() + + #expect(options.completionAction == .playNextItem) + } + + @Test("Disabling repeat restores normal stop behavior") + func disablingRepeatRestoresStop() { + let options = NCMediaPlaybackOptions(preferences: nil) + + options.toggleRepeat() + #expect(options.completionAction == .repeatCurrentItem) + + options.toggleRepeat() + #expect(options.completionAction == .stop) + } + + @Test("Repeat takes precedence over automatic advance") + func repeatTakesPrecedence() { + let options = NCMediaPlaybackOptions(preferences: nil) + + options.toggleAutoAdvance() + options.toggleRepeat() + + #expect(options.completionAction == .repeatCurrentItem) + + options.toggleRepeat() + + #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/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/Tests/NextcloudUnitTests/NCVideoPlaybackPresentationContextTests.swift b/Tests/NextcloudUnitTests/NCVideoPlaybackPresentationContextTests.swift new file mode 100644 index 0000000000..30eb81b494 --- /dev/null +++ b/Tests/NextcloudUnitTests/NCVideoPlaybackPresentationContextTests.swift @@ -0,0 +1,110 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Marino Faggiana +// SPDX-License-Identifier: GPL-3.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/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/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/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..bde50e243c 100644 --- a/iOSClient/Viewer/NCViewerMedia/Content/Audio/NCAudioViewerContentView.swift +++ b/iOSClient/Viewer/NCViewerMedia/Content/Audio/NCAudioViewerContentView.swift @@ -13,11 +13,15 @@ struct NCAudioViewerContentView: View { let localURL: URL let previewURL: URL? let backgroundStyle: NCViewerBackgroundStyle + let navigationBar: UINavigationBar? let canGoPrevious: Bool let canGoNext: Bool + let isSelected: Bool let shouldAutoPlay: Bool + @ObservedObject var playbackOptions: NCMediaPlaybackOptions let onPrevious: (_ shouldAutoPlay: Bool) -> Void let onNext: (_ shouldAutoPlay: Bool) -> Void + let onPlayNextMedia: NCMediaPlaybackAdvanceRequest let onAutoPlayConsumed: () -> Void let onToggleChrome: () -> Void @@ -28,11 +32,17 @@ struct NCAudioViewerContentView: View { localURL: URL, previewURL: URL? = nil, backgroundStyle: NCViewerBackgroundStyle = .system, + navigationBar: UINavigationBar? = nil, canGoPrevious: Bool = false, canGoNext: Bool = false, + isSelected: Bool = true, shouldAutoPlay: Bool = false, + playbackOptions: NCMediaPlaybackOptions, onPrevious: @escaping (_ shouldAutoPlay: Bool) -> Void = { _ in }, onNext: @escaping (_ shouldAutoPlay: Bool) -> Void = { _ in }, + onPlayNextMedia: @escaping NCMediaPlaybackAdvanceRequest = { completion in + completion(false) + }, onAutoPlayConsumed: @escaping () -> Void = {}, onToggleChrome: @escaping () -> Void = {} ) { @@ -40,11 +50,15 @@ struct NCAudioViewerContentView: View { self.localURL = localURL self.previewURL = previewURL self.backgroundStyle = backgroundStyle + self.navigationBar = navigationBar self.canGoPrevious = canGoPrevious self.canGoNext = canGoNext + self.isSelected = isSelected self.shouldAutoPlay = shouldAutoPlay + self.playbackOptions = playbackOptions self.onPrevious = onPrevious self.onNext = onNext + self.onPlayNextMedia = onPlayNextMedia self.onAutoPlayConsumed = onAutoPlayConsumed self.onToggleChrome = onToggleChrome @@ -66,6 +80,23 @@ 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 + // 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 = navigationBarBottom + 4 ZStack { Color.ncViewerBackground(backgroundStyle) @@ -100,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)) @@ -114,16 +145,7 @@ struct NCAudioViewerContentView: View { } .padding(.horizontal, sliderHorizontalPadding) - HStack(spacing: buttonSpacing) { - Button { - model.toggleLoop() - } label: { - Image(systemName: model.isLoopEnabled ? "repeat.circle.fill" : "repeat.circle") - .font(.system(size: sideButtonSize, weight: .regular)) - .foregroundStyle(model.isLoopEnabled ? primaryForegroundStyle : mutedForegroundStyle) - } - .buttonStyle(.plain) - + ZStack { Button { model.togglePlayback() } label: { @@ -132,32 +154,89 @@ struct NCAudioViewerContentView: View { .foregroundStyle(primaryForegroundStyle) } .buttonStyle(.plain) + .disabled(!isSelected) 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) + .disabled(!isSelected || model.duration <= 0) + .offset( + x: -(playButtonSize / 2 + buttonSpacing + sideButtonSize / 2) + ) } + .frame(height: playButtonSize) + .frame(maxWidth: .infinity) } .padding(.top, topPadding) + + 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() + } + + Spacer() + } + .padding(.leading, 28) + .padding(.top, topActionsPadding) + + Spacer() + } } .frame(maxWidth: .infinity, maxHeight: .infinity) } .task(id: localURL) { + guard isSelected else { + return + } + + model.configurePlaybackCompletion( + options: playbackOptions, + onPlayNextMedia: onPlayNextMedia + ) await model.load(url: localURL) - consumeAutoPlayIfNeeded() + 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 } - 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 @@ -189,6 +268,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 : primaryForegroundStyle) + .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 { @@ -273,7 +375,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 } @@ -300,6 +411,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. @@ -337,7 +478,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 +486,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: NCMediaPlaybackAdvanceRequest? // MARK: - Public API + func configurePlaybackCompletion( + options: NCMediaPlaybackOptions, + onPlayNextMedia: @escaping NCMediaPlaybackAdvanceRequest + ) { + playbackOptions = options + self.onPlayNextMedia = onPlayNextMedia + } + func load(url: URL) async { guard currentURL != url else { return @@ -426,10 +576,6 @@ final class NCAudioViewerModel: ObservableObject { } } - func toggleLoop() { - isLoopEnabled.toggle() - } - func restart() { seek(to: 0) @@ -555,7 +701,8 @@ final class NCAudioViewerModel: ObservableObject { return } - if self.isLoopEnabled { + switch self.playbackOptions?.completionAction ?? .stop { + case .repeatCurrentItem: self.currentTime = 0 player.seek( @@ -572,7 +719,13 @@ final class NCAudioViewerModel: ObservableObject { self.isPlaying = true } } - } else { + + case .playNextItem: + self.currentTime = self.duration + self.isPlaying = false + self.onPlayNextMedia? { _ in } + + 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..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. @@ -20,16 +21,24 @@ enum NCVideoAVPlayerPresenter { preparedPlayback: NCVideoAVPreparedPlayback, userAgent: String?, shouldAutoPlayOnStart: Bool = true, + playbackStartReason: NCVideoPlaybackPresentationContext.StartReason = .userInitiated, isChromeHidden: Bool = false, contextMenuController: NCMainTabBarController?, + playbackOptions: NCMediaPlaybackOptions, canGoPrevious: Bool = false, canGoNext: Bool = false, onPrevious: (() -> Void)? = nil, onNext: (() -> Void)? = nil, + onPlaybackEnded: NCMediaPlaybackAdvanceRequest? = nil, onClose: ((_ ocId: String?) -> Void)? = nil, onPlaybackError: (() -> Void)? = nil - ) { + ) -> Bool { let url = preparedPlayback.url + + guard pendingDismissCompletions == nil else { + return false + } + if currentURL == url, let currentViewController { currentViewController.update( @@ -37,21 +46,24 @@ enum NCVideoAVPlayerPresenter { preparedPlayback: preparedPlayback, userAgent: userAgent, shouldAutoPlayOnStart: shouldAutoPlayOnStart, + playbackStartReason: playbackStartReason, 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 { @@ -60,18 +72,21 @@ enum NCVideoAVPlayerPresenter { preparedPlayback: preparedPlayback, userAgent: userAgent, shouldAutoPlayOnStart: shouldAutoPlayOnStart, + playbackStartReason: playbackStartReason, 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 +96,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 @@ -100,13 +115,16 @@ enum NCVideoAVPlayerPresenter { preparedPlayback: preparedPlayback, userAgent: userAgent, shouldAutoPlayOnStart: shouldAutoPlayOnStart, + playbackStartReason: playbackStartReason, 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 @@ -125,12 +143,21 @@ enum NCVideoAVPlayerPresenter { .foregroundColor: UIColor.white ] + if !playbackStartReason.shouldShowControlsOnStart { + navigationController.setNavigationBarHidden( + true, + animated: false + ) + } + presenter.present( navigationController, animated: false ) { isPresenting = false } + + return true } static func clearCurrent( @@ -146,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) } } @@ -164,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 fdf9deee27..b819f51048 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: NCMediaPlaybackAdvanceRequest? var onClose: ((_ ocId: String?) -> Void)? var onPlaybackError: (() -> Void)? var canGoPrevious = false @@ -72,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? @@ -85,6 +86,7 @@ final class NCVideoAVPlayerViewController: UIViewController { private var preparedURL: URL? private var hasReportedPlaybackError = false internal var isPlaybackRequested = false + internal var playbackPresentationContext: NCVideoPlaybackPresentationContext var isPictureInPictureActive: Bool { pictureInPictureController?.isPictureInPictureActive == true @@ -111,8 +113,10 @@ final class NCVideoAVPlayerViewController: UIViewController { preparedPlayback: NCVideoAVPreparedPlayback, userAgent: String?, shouldAutoPlayOnStart: Bool = true, + playbackStartReason: NCVideoPlaybackPresentationContext.StartReason = .userInitiated, isChromeHidden: Bool = false, - contextMenuController: NCMainTabBarController? + contextMenuController: NCMainTabBarController?, + playbackOptions: NCMediaPlaybackOptions ) { self.metadata = metadata self.preparedPlayback = preparedPlayback @@ -122,6 +126,10 @@ final class NCVideoAVPlayerViewController: UIViewController { self.shouldAutoPlayOnStart = shouldAutoPlayOnStart self.isChromeHidden = isChromeHidden self.contextMenuController = contextMenuController + self.playbackOptions = playbackOptions + self.playbackPresentationContext = NCVideoPlaybackPresentationContext( + startReason: playbackStartReason + ) super.init( nibName: nil, @@ -159,6 +167,7 @@ final class NCVideoAVPlayerViewController: UIViewController { playerContainerView.playerLayer.videoGravity = .resizeAspect controlsView.delegate = self + updatePlaybackOptionsControls() controlsView.alpha = 0 controlsView.isHidden = true controlsView.translatesAutoresizingMaskIntoConstraints = false @@ -179,6 +188,12 @@ final class NCVideoAVPlayerViewController: UIViewController { ]) updateControlsNavigationBar() + + if !playbackPresentationContext.shouldShowControlsOnStart { + controlsView.alpha = 0 + controlsView.isHidden = true + } + view = rootView } @@ -201,8 +216,6 @@ final class NCVideoAVPlayerViewController: UIViewController { let shouldPreserveHiddenChromeBackground = isChromeHidden start() - showControls(animated: false) - stopControlsHideTimer() if shouldPreserveHiddenChromeBackground { updateViewerBackground(isChromeHidden: true) @@ -240,8 +253,10 @@ final class NCVideoAVPlayerViewController: UIViewController { preparedPlayback: NCVideoAVPreparedPlayback, userAgent: String?, shouldAutoPlayOnStart: Bool = true, + playbackStartReason: NCVideoPlaybackPresentationContext.StartReason = .userInitiated, isChromeHidden: Bool = false, - contextMenuController: NCMainTabBarController? + contextMenuController: NCMainTabBarController?, + playbackOptions: NCMediaPlaybackOptions ) { let urlChanged = self.url != preparedPlayback.url @@ -255,7 +270,9 @@ final class NCVideoAVPlayerViewController: UIViewController { self.metadata = metadata self.userAgent = userAgent self.shouldAutoPlayOnStart = shouldAutoPlayOnStart + self.playbackPresentationContext.updateStartReason(playbackStartReason) self.contextMenuController = contextMenuController + self.playbackOptions = playbackOptions updateViewerBackground(isChromeHidden: isChromeHidden) updateTitleLabel(metadata: metadata) @@ -265,6 +282,7 @@ final class NCVideoAVPlayerViewController: UIViewController { updatePlayPauseButton() updateProgressControls() + updatePlaybackOptionsControls() } private var viewerBackgroundColor: UIColor { @@ -319,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) } } @@ -485,6 +487,13 @@ final class NCVideoAVPlayerViewController: UIViewController { private func start() { hasReportedPlaybackError = false isPlaybackRequested = shouldAutoPlayOnStart + playbackPresentationContext.prepareForPlaybackStart() + applyControlsVisibilityOnStart() + + if player.timeControlStatus == .playing { + playbackPresentationContext.finishPlaybackTransition() + } + cancelPlaybackStartupTimeout() guard preparedURL != url else { @@ -517,6 +526,7 @@ final class NCVideoAVPlayerViewController: UIViewController { private func stop() { preparedURL = nil isPlaybackRequested = false + playbackPresentationContext.reset() cancelPlaybackStartupTimeout() player.pause() @@ -531,6 +541,20 @@ final class NCVideoAVPlayerViewController: UIViewController { updateProgressControls() } + func stopForDismissal() { + stopControlsHideTimer() + stop() + } + + private func applyControlsVisibilityOnStart() { + if playbackPresentationContext.shouldShowControlsOnStart { + showControls(animated: false) + stopControlsHideTimer() + } else { + hideControls(animated: false) + } + } + private func configurePlayerLayer() { playerContainerView.playerLayer.videoGravity = .resizeAspect playerContainerView.player = player @@ -603,7 +627,7 @@ final class NCVideoAVPlayerViewController: UIViewController { queue: .main ) { [weak self] _ in guard let self, - !self.isScrubbing else { + !self.playbackPresentationContext.isSeeking else { return } @@ -677,7 +701,8 @@ final class NCVideoAVPlayerViewController: UIViewController { updatePlayPauseButton() } - if !controlsVisible, + if playbackPresentationContext.shouldShowControlsOnStart, + !controlsVisible, !isPictureInPictureActive { showControls(animated: false) scheduleControlsHide() @@ -727,6 +752,7 @@ final class NCVideoAVPlayerViewController: UIViewController { } hasReportedPlaybackError = true + playbackPresentationContext.reset() isPlaybackRequested = false cancelPlaybackStartupTimeout() @@ -748,13 +774,15 @@ final class NCVideoAVPlayerViewController: UIViewController { switch player.timeControlStatus { case .playing: isPlaybackRequested = true + playbackPresentationContext.finishPlaybackTransition() cancelPlaybackStartupTimeout() case .waitingToPlayAtSpecifiedRate: isPlaybackRequested = true case .paused: - if player.currentItem?.status == .readyToPlay || + if !playbackPresentationContext.shouldSuppressAutomaticControlsPresentation, + player.currentItem?.status == .readyToPlay || player.currentItem?.status == .failed || player.currentItem == nil { isPlaybackRequested = false @@ -767,6 +795,10 @@ final class NCVideoAVPlayerViewController: UIViewController { updatePlayPauseButton() guard player.timeControlStatus == .playing else { + guard !playbackPresentationContext.shouldSuppressAutomaticControlsPresentation else { + return + } + if !isPlaybackRequested { showControls(animated: false) stopControlsHideTimer() @@ -783,9 +815,83 @@ final class NCVideoAVPlayerViewController: UIViewController { isPlaybackRequested = false cancelPlaybackStartupTimeout() + switch playbackOptions.completionAction { + case .repeatCurrentItem: + repeatCurrentItem() + return + + case .playNextItem: + updatePlayPauseButton() + updateProgressControls() + + 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() { + playbackPresentationContext.beginRepeatRestart() + isPlaybackRequested = true + updatePlayPauseButton() + + player.seek( + to: .zero, + toleranceBefore: .zero, + toleranceAfter: .zero + ) { [weak self, weak player] didFinish in + guard let self, + let player else { + return + } + + Task { @MainActor in + guard self.player === player else { + return + } + + guard didFinish else { + self.playbackPresentationContext.finishPlaybackTransition() + self.isPlaybackRequested = false + self.updatePlayPauseButton() + self.showControls(animated: true) + return + } + + player.play() + self.updateProgressControls() + self.scheduleControlsHide() + } + } + } + + internal func updatePlaybackOptionsControls() { + controlsView.updatePlaybackOptions( + isRepeatEnabled: playbackOptions.isRepeatEnabled, + isAutoAdvanceEnabled: playbackOptions.isAutoAdvanceEnabled + ) } private func updateControlsNavigationBar() { diff --git a/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoAVPlayerViewControls.swift b/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoAVPlayerViewControls.swift index 8c59d3bd4c..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 } @@ -194,12 +194,24 @@ 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() } func videoControlsDidBeginScrubbing(_ controlsView: NCVideoControlsView) { - isScrubbing = true + playbackPresentationContext.beginSeeking() stopControlsHideTimer() } @@ -229,8 +241,8 @@ extension NCVideoAVPlayerViewController: NCVideoControlsViewDelegate { guard let duration = player.currentItem?.duration.seconds, duration.isFinite, duration > 0 else { - isScrubbing = false - scheduleControlsHide() + playbackPresentationContext.finishSeeking() + hideControls(animated: true) return } @@ -245,9 +257,9 @@ extension NCVideoAVPlayerViewController: NCVideoControlsViewDelegate { toleranceAfter: .zero ) { [weak self] _ in Task { @MainActor in - self?.isScrubbing = false + self?.playbackPresentationContext.finishSeeking() 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 b7272b2220..f999de312a 100644 --- a/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoViewerContentView+AVPlayer.swift +++ b/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoViewerContentView+AVPlayer.swift @@ -6,37 +6,52 @@ 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 + consumePendingAutoPlayIfNeeded() + return true } - presentedAVPlayerURL = preparedPlayback.url - - NCVideoAVPlayerPresenter.present( + let didPresent = NCVideoAVPlayerPresenter.present( metadata: metadata, preparedPlayback: preparedPlayback, userAgent: userAgent, shouldAutoPlayOnStart: true, + playbackStartReason: shouldAutoPlay ? .automaticAdvance : .userInitiated, 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 + consumePendingAutoPlayIfNeeded() + return true } @MainActor @@ -48,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/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..31019ef5ff 100644 --- a/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoViewerContentView.swift +++ b/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoViewerContentView.swift @@ -18,8 +18,13 @@ struct NCVideoViewerContentView: View { let navigationBar: UINavigationBar? let canGoPrevious: Bool let canGoNext: Bool + let shouldAutoPlay: Bool + let isAutomaticAdvanceTarget: Bool + @ObservedObject var playbackOptions: NCMediaPlaybackOptions let onPreviousPage: (() -> Void)? let onNextPage: (() -> Void)? + let onPlayNextMedia: NCMediaPlaybackAdvanceRequest? + let onAutoPlayConsumed: (() -> Void)? let onToggleChrome: (() -> Void)? let onClose: ((_ ocId: String?) -> Void)? let downloadVideo: (@MainActor () async throws -> URL)? @@ -54,8 +59,13 @@ struct NCVideoViewerContentView: View { navigationBar: UINavigationBar? = nil, canGoPrevious: Bool = false, canGoNext: Bool = false, + shouldAutoPlay: Bool = false, + isAutomaticAdvanceTarget: Bool = false, + playbackOptions: NCMediaPlaybackOptions, onPreviousPage: (() -> Void)? = nil, onNextPage: (() -> Void)? = nil, + onPlayNextMedia: NCMediaPlaybackAdvanceRequest? = nil, + onAutoPlayConsumed: (() -> Void)? = nil, onToggleChrome: (() -> Void)? = nil, onClose: ((_ ocId: String?) -> Void)? = nil, downloadVideo: (@MainActor () async throws -> URL)? = nil, @@ -71,8 +81,13 @@ struct NCVideoViewerContentView: View { self.navigationBar = navigationBar self.canGoPrevious = canGoPrevious self.canGoNext = canGoNext + self.shouldAutoPlay = shouldAutoPlay + self.isAutomaticAdvanceTarget = isAutomaticAdvanceTarget + 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 +104,10 @@ struct NCVideoViewerContentView: View { .background(videoBackgroundColor) .task(id: taskIdentifier) { await loadVideoIfSelected() + scheduleAutoPlayIfNeeded() + } + .onAppear { + scheduleAutoPlayIfNeeded() } .onChange(of: isSelected) { _, selected in loadGeneration = UUID() @@ -100,7 +119,18 @@ 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 +141,67 @@ 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 + } + + isLaunchingPlayback = true + downloadAndPlayVideo() + return + } + + switch playback.engine { + case .avFoundation(let preparedPlayback): + guard isCurrentPlaybackVideo() else { + return + } + + requestAVPlayerPresentation(preparedPlayback: preparedPlayback) + + case .vlc(let preparedPlayback): + guard isCurrentPlaybackVideo() else { + return + } + + requestVLCPresentation(preparedPlayback: preparedPlayback) + + case .loading, + .failed: + break + } + } +} + // MARK: - Main Content private extension NCVideoViewerContentView { @@ -126,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, @@ -147,6 +244,10 @@ private extension NCVideoViewerContentView { } } + var shouldHideCoverDuringAutomaticLocalPlayback: Bool { + isAutomaticAdvanceTarget && localURL != nil + } + @ViewBuilder var requestedPlaybackView: some View { switch playback.engine { @@ -544,6 +645,7 @@ private extension NCVideoViewerContentView { @MainActor func downloadAndPlayVideo() { guard let downloadVideo else { + consumePendingAutoPlayIfNeeded() isLaunchingPlayback = false errorMessage = "" return @@ -592,6 +694,7 @@ private extension NCVideoViewerContentView { playbackDownloadTask = nil isDownloadingPlayback = false isLaunchingPlayback = false + consumePendingAutoPlayIfNeeded() errorMessage = "" } } @@ -608,6 +711,7 @@ private extension NCVideoViewerContentView { isDownloadingPlayback = false isLaunchingPlayback = false hasRequestedPlayback = false + consumePendingAutoPlayIfNeeded() guard let cancelVideoDownload else { return @@ -683,6 +787,15 @@ extension NCVideoViewerContentView { isLaunchingPlayback = false } + @MainActor + func consumePendingAutoPlayIfNeeded() { + guard shouldAutoPlay else { + return + } + + onAutoPlayConsumed?() + } + @MainActor func showPlaybackError() { resetPlaybackPresentationState() @@ -702,15 +815,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 4448f82fdf..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. @@ -23,18 +22,21 @@ enum NCVideoVLCPresenter { preparedPlayback: NCVideoVLCPreparedPlayback, userAgent: String?, shouldAutoPlayOnStart: Bool = true, + playbackStartReason: NCVideoPlaybackPresentationContext.StartReason = .userInitiated, isChromeHidden: Bool = false, contextMenuController: NCMainTabBarController?, + playbackOptions: NCMediaPlaybackOptions, canGoPrevious: Bool = false, canGoNext: Bool = false, onPrevious: (() -> Void)? = nil, onNext: (() -> Void)? = nil, + onPlaybackEnded: NCMediaPlaybackAdvanceRequest? = nil, onClose: ((_ ocId: String?) -> Void)? = nil, onPlaybackError: (() -> Void)? = nil ) -> Bool { let url = preparedPlayback.url - guard !isDismissing else { + guard pendingDismissCompletions == nil else { logPresentationRejected("dismissal in progress") return false } @@ -46,11 +48,14 @@ enum NCVideoVLCPresenter { preparedPlayback: preparedPlayback, userAgent: userAgent, shouldAutoPlayOnStart: shouldAutoPlayOnStart, + playbackStartReason: playbackStartReason, 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 @@ -69,11 +74,14 @@ enum NCVideoVLCPresenter { preparedPlayback: preparedPlayback, userAgent: userAgent, shouldAutoPlayOnStart: shouldAutoPlayOnStart, + playbackStartReason: playbackStartReason, 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 @@ -111,11 +119,14 @@ enum NCVideoVLCPresenter { preparedPlayback: preparedPlayback, userAgent: userAgent, shouldAutoPlayOnStart: shouldAutoPlayOnStart, + playbackStartReason: playbackStartReason, 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 @@ -136,6 +147,13 @@ enum NCVideoVLCPresenter { .foregroundColor: UIColor.white ] + if !playbackStartReason.shouldShowControlsOnStart { + navigationController.setNavigationBarHidden( + true, + animated: false + ) + } + presenter.present( navigationController, animated: false @@ -166,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) } } @@ -207,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 a94661fee1..aed7cc35b0 100644 --- a/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCViewController.swift +++ b/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCViewController.swift @@ -20,12 +20,15 @@ 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 + internal var playbackPresentationContext: NCVideoPlaybackPresentationContext // MARK: - Paging Callbacks var onPrevious: (() -> Void)? var onNext: (() -> Void)? + var onPlaybackEnded: NCMediaPlaybackAdvanceRequest? var onClose: ((_ ocId: String?) -> Void)? var onPlaybackError: (() -> Void)? var canGoPrevious = false @@ -59,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? @@ -84,8 +86,10 @@ final class NCVideoVLCViewController: UIViewController { preparedPlayback: NCVideoVLCPreparedPlayback, userAgent: String?, shouldAutoPlayOnStart: Bool = true, + playbackStartReason: NCVideoPlaybackPresentationContext.StartReason = .userInitiated, isChromeHidden: Bool = false, - contextMenuController: NCMainTabBarController? + contextMenuController: NCMainTabBarController?, + playbackOptions: NCMediaPlaybackOptions ) { self.metadata = metadata self.preparedPlayback = preparedPlayback @@ -94,6 +98,10 @@ final class NCVideoVLCViewController: UIViewController { self.shouldAutoPlayOnStart = shouldAutoPlayOnStart self.isChromeHidden = isChromeHidden self.contextMenuController = contextMenuController + self.playbackOptions = playbackOptions + self.playbackPresentationContext = NCVideoPlaybackPresentationContext( + startReason: playbackStartReason + ) super.init( nibName: nil, @@ -131,6 +139,7 @@ final class NCVideoVLCViewController: UIViewController { controlsView.delegate = self controlsView.setTopActionsMode(.vlcTracks) + updatePlaybackOptionsControls() controlsView.alpha = 0 controlsView.isHidden = true controlsView.translatesAutoresizingMaskIntoConstraints = false @@ -152,6 +161,11 @@ final class NCVideoVLCViewController: UIViewController { controlsView.setTopActionsNavigationBar(navigationController?.navigationBar) + if !playbackPresentationContext.shouldShowControlsOnStart { + controlsView.alpha = 0 + controlsView.isHidden = true + } + view = rootView } @@ -172,8 +186,6 @@ final class NCVideoVLCViewController: UIViewController { super.viewDidAppear(animated) start() - showControls(animated: false) - stopControlsHideTimer() } override func viewDidLayoutSubviews() { @@ -207,8 +219,10 @@ final class NCVideoVLCViewController: UIViewController { preparedPlayback: NCVideoVLCPreparedPlayback, userAgent: String?, shouldAutoPlayOnStart: Bool = true, + playbackStartReason: NCVideoPlaybackPresentationContext.StartReason = .userInitiated, isChromeHidden: Bool = false, - contextMenuController: NCMainTabBarController? + contextMenuController: NCMainTabBarController?, + playbackOptions: NCMediaPlaybackOptions ) { let urlChanged = self.url != preparedPlayback.url let applyConfiguration = { [weak self] in @@ -217,12 +231,15 @@ final class NCVideoVLCViewController: UIViewController { self.metadata = metadata self.userAgent = userAgent self.shouldAutoPlayOnStart = shouldAutoPlayOnStart + self.playbackPresentationContext.updateStartReason(playbackStartReason) self.isChromeHidden = isChromeHidden self.contextMenuController = contextMenuController + self.playbackOptions = playbackOptions self.updateViewerBackgroundIfNeeded() self.updateTitleLabel(metadata: metadata) self.refreshVLCTrackMenuItemsWhenPlayerIsActive() self.updatePlayPauseButton() + self.updatePlaybackOptionsControls() } guard urlChanged else { @@ -427,6 +444,13 @@ final class NCVideoVLCViewController: UIViewController { hasEnteredPlaybackPipeline = false hasReportedPlaybackError = false isPlaybackRequested = shouldAutoPlayOnStart + playbackPresentationContext.prepareForPlaybackStart() + applyControlsVisibilityOnStart() + + if mediaPlayer.state == .playing { + playbackPresentationContext.finishPlaybackTransition() + } + cancelPlaybackStartupTimeout() attachDrawable() @@ -443,8 +467,15 @@ final class NCVideoVLCViewController: UIViewController { updateProgressControls() clearVLCTrackMenuItems() startProgressTimer() - showControls(animated: false) - stopControlsHideTimer() + } + + private func applyControlsVisibilityOnStart() { + if playbackPresentationContext.shouldShowControlsOnStart { + showControls(animated: false) + stopControlsHideTimer() + } else { + hideControls(animated: false) + } } func stop(completion: (() -> Void)? = nil) { @@ -460,6 +491,7 @@ final class NCVideoVLCViewController: UIViewController { cancelPlaybackStartupTimeout() isPlaybackRequested = false isReplayFromBeginningRequested = false + playbackPresentationContext.reset() if mediaPlayer.media == nil || (mediaPlayer.state == .stopped && !hadPendingPlaybackRequest) { @@ -471,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 @@ -592,6 +631,7 @@ final class NCVideoVLCViewController: UIViewController { } hasReportedPlaybackError = true + playbackPresentationContext.reset() isPlaybackRequested = false cancelPlaybackStartupTimeout() onPlaybackError?() @@ -651,15 +691,42 @@ final class NCVideoVLCViewController: UIViewController { switch mediaPlayer.state { case .playing: isPlaybackRequested = true + playbackPresentationContext.finishPlaybackTransition() cancelPlaybackStartupTimeout() case .ended: isPlaybackRequested = false stopProgressTimer() - updatePlayPauseButton() - updateProgressLabels(position: 1) - showControls(animated: true) - stopControlsHideTimer() + + switch playbackOptions.completionAction { + case .repeatCurrentItem: + playbackPresentationContext.beginRepeatRestart() + restartPlaybackFromBeginning() + return + + case .playNextItem: + updatePlayPauseButton() + updateProgressLabels(position: 1) + + guard let onPlaybackEnded else { + finishPlaybackWithoutAdvance() + return + } + + onPlaybackEnded { [weak self] didAdvance in + guard !didAdvance else { + return + } + + self?.finishPlaybackWithoutAdvance() + } + return + + case .stop: + break + } + + finishPlaybackWithoutAdvance() return case .stopped: @@ -683,8 +750,13 @@ final class NCVideoVLCViewController: UIViewController { isPlaybackRequested = false cancelPlaybackStartupTimeout() - case .paused, - .error: + case .paused: + if !playbackPresentationContext.shouldSuppressAutomaticControlsPresentation { + isPlaybackRequested = false + } + + case .error: + playbackPresentationContext.reset() isPlaybackRequested = false default: @@ -696,6 +768,10 @@ final class NCVideoVLCViewController: UIViewController { refreshVLCTrackMenuItemsWhenPlayerIsActive() guard mediaPlayer.state == .playing else { + guard !playbackPresentationContext.shouldSuppressAutomaticControlsPresentation else { + return + } + if !isPlaybackRequested { showControls(animated: false) stopControlsHideTimer() @@ -723,6 +799,13 @@ final class NCVideoVLCViewController: UIViewController { scheduleControlsHide() } + internal func updatePlaybackOptionsControls() { + controlsView.updatePlaybackOptions( + isRepeatEnabled: playbackOptions.isRepeatEnabled, + isAutoAdvanceEnabled: playbackOptions.isAutoAdvanceEnabled + ) + } + // MARK: - VLC Track Menus func refreshVLCTrackMenuItems() { @@ -744,6 +827,13 @@ final class NCVideoVLCViewController: UIViewController { } } + private func finishPlaybackWithoutAdvance() { + updatePlayPauseButton() + updateProgressLabels(position: 1) + showControls(animated: true) + stopControlsHideTimer() + } + func selectSubtitleTrack(index: Int32) { mediaPlayer.currentVideoSubTitleIndex = index @@ -1077,7 +1167,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 b9549d120c..d88e91c229 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() { @@ -50,15 +53,26 @@ extension NCVideoVLCViewController { } func updateProgressControls() { - guard !isScrubbing else { + guard !playbackPresentationContext.isSeeking else { 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) @@ -157,7 +171,7 @@ extension NCVideoVLCViewController { ) { [weak self] _ in Task { @MainActor in guard let self, - !self.isScrubbing else { + !self.playbackPresentationContext.isSeeking else { return } @@ -209,10 +223,22 @@ 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() - isScrubbing = true + playbackPresentationContext.beginSeeking() } func videoControlsDidTapSubtitle(_ controlsView: NCVideoControlsView) { @@ -250,9 +276,21 @@ extension NCVideoVLCViewController: NCVideoControlsViewDelegate { } func videoControlsDidEndScrubbing(_ controlsView: NCVideoControlsView, progress: Float) { - mediaPlayer.position = progress - isScrubbing = false - updateProgressControls() - scheduleControlsHide() + 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 + } + + 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 7417fa1b4d..a373037ed4 100644 --- a/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoViewerContentView+VLC.swift +++ b/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoViewerContentView+VLC.swift @@ -6,19 +6,22 @@ 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 + consumePendingAutoPlayIfNeeded() + return true } let didPresent = NCVideoVLCPresenter.present( @@ -26,12 +29,15 @@ extension NCVideoViewerContentView { preparedPlayback: preparedPlayback, userAgent: userAgent, shouldAutoPlayOnStart: true, + playbackStartReason: shouldAutoPlay ? .automaticAdvance : .userInitiated, isChromeHidden: isChromeHidden, contextMenuController: contextMenuController, + playbackOptions: playbackOptions, canGoPrevious: canGoPrevious, canGoNext: canGoNext, onPrevious: goToPreviousPageFromVLC, onNext: goToNextPageFromVLC, + onPlaybackEnded: onPlayNextMedia, onClose: closeFromFullscreenVideo, onPlaybackError: handleVLCPlaybackError ) @@ -40,10 +46,12 @@ extension NCVideoViewerContentView { presentedVLCURL = nil hasRequestedPlayback = false isLaunchingPlayback = false - return + return false } presentedVLCURL = preparedPlayback.url + consumePendingAutoPlayIfNeeded() + 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..4ce3c61f80 --- /dev/null +++ b/iOSClient/Viewer/NCViewerMedia/Core/NCMediaPlaybackOptions.swift @@ -0,0 +1,53 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Marino Faggiana +// SPDX-License-Identifier: GPL-3.0-or-later + +import Combine + +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 { + enum CompletionAction: Equatable { + case stop + case repeatCurrentItem + case playNextItem + } + + @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: CompletionAction { + if isRepeatEnabled { + return .repeatCurrentItem + } + + if isAutoAdvanceEnabled { + return .playNextItem + } + + return .stop + } + + func toggleRepeat() { + isRepeatEnabled.toggle() + preferences?.mediaViewerRepeatCurrentItem = isRepeatEnabled + } + + func toggleAutoAdvance() { + isAutoAdvanceEnabled.toggle() + preferences?.mediaViewerAutoAdvance = isAutoAdvanceEnabled + } +} diff --git a/iOSClient/Viewer/NCViewerMedia/Core/NCMediaViewerModel.swift b/iOSClient/Viewer/NCViewerMedia/Core/NCMediaViewerModel.swift index f60e100b7b..9e56d45ca5 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 { @@ -93,15 +100,35 @@ 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 @Published private(set) var autoPlayTargetIndex: Int? + let playbackOptions = NCMediaPlaybackOptions() + // MARK: - Dependencies private let loader: NCMediaViewerLoading @@ -134,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 @@ -167,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 } - autoPlayTargetIndex = nil - revision &+= 1 + guard selectedIndex != index else { + return + } + + 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 @@ -269,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) } @@ -291,10 +378,11 @@ final class NCMediaViewerModel: ObservableObject { } guard selectedIndex != index else { + finishPageTransition(at: index) return } - selectedIndex = index + finishPageTransition(at: index) let ocId = ocIds[index] @@ -457,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 @@ -482,6 +558,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 @@ -531,6 +615,36 @@ 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 + } + + let sourceOcId = ocIds[index] + + 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/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 + } +} diff --git a/iOSClient/Viewer/NCViewerMedia/Views/NCMediaViewerPageView.swift b/iOSClient/Viewer/NCViewerMedia/Views/NCMediaViewerPageView.swift index bff54bfaf9..90c392f39a 100644 --- a/iOSClient/Viewer/NCViewerMedia/Views/NCMediaViewerPageView.swift +++ b/iOSClient/Viewer/NCViewerMedia/Views/NCMediaViewerPageView.swift @@ -15,9 +15,12 @@ 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, + _ completion: @escaping NCMediaPlaybackAdvanceCompletion + ) -> Void let onClose: (_ ocId: String?) -> Void let onAutoPlayConsumed: () -> Void let onZoomChanged: (Bool) -> Void @@ -26,7 +29,7 @@ struct NCMediaViewerPageView: View { let navigationBar: UINavigationBar? private var isSelected: Bool { - model.selectedIndex == page.index + model.activePageIndex == page.index } // MARK: - Body @@ -103,7 +106,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 +213,18 @@ struct NCMediaViewerPageView: View { navigationBar: navigationBar, canGoPrevious: canGoPrevious, canGoNext: canGoNext, + shouldAutoPlay: effectiveShouldAutoPlay, + isAutomaticAdvanceTarget: model.autoPlayTargetIndex == page.index, + playbackOptions: model.playbackOptions, onPreviousPage: goToPreviousPageFromVideo, onNextPage: goToNextPageFromVideo, + onPlayNextMedia: { [onNextMediaOfSameType] completion in + onNextMediaOfSameType( + NKTypeClassFile.video.rawValue, + completion + ) + }, + onAutoPlayConsumed: consumeAutoPlayIfNeeded, onToggleChrome: onToggleChrome, onClose: onClose, downloadVideo: { @@ -239,11 +252,20 @@ struct NCMediaViewerPageView: View { localURL: localURL, previewURL: previewURL, backgroundStyle: backgroundStyle, + navigationBar: navigationBar, canGoPrevious: canGoPrevious, canGoNext: canGoNext, + isSelected: isSelected, shouldAutoPlay: effectiveShouldAutoPlay, + playbackOptions: model.playbackOptions, onPrevious: goToPreviousPage, onNext: goToNextPage, + 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 c977bd305f..a8be29f1c3 100644 --- a/iOSClient/Viewer/NCViewerMedia/Views/NCMediaViewerPagingView.swift +++ b/iOSClient/Viewer/NCViewerMedia/Views/NCMediaViewerPagingView.swift @@ -130,7 +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 isAdjustingLayout = false // MARK: - Init @@ -280,10 +279,12 @@ final class NCMediaViewerPagingCoordinator: NSObject, return } - jumpToIndex( + guard jumpToIndex( index, animated: animated - ) + ) else { + return + } didScrollToInitialIndex = true lastVisibleIndex = index @@ -300,6 +301,12 @@ final class NCMediaViewerPagingCoordinator: NSObject, } func jumpToSelectedIndexIfNeeded(animated: Bool) { + // 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 + } + guard model.numberOfPages > 0 else { return } @@ -321,34 +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 - model.setSelectedIndex(index) + if !shouldAnimate { + lastVisibleIndex = index + model.finishPageTransition(at: index) refreshVisibleCells() Task { @@ -359,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 { @@ -392,6 +418,8 @@ final class NCMediaViewerPagingCoordinator: NSObject, animated: false ) } + + return true } // MARK: - Visible Cell Refresh @@ -417,17 +445,60 @@ final class NCMediaViewerPagingCoordinator: NSObject, // MARK: - Page Navigation + @discardableResult private func moveToPage( offset: Int, shouldAutoPlay: Bool - ) { + ) -> Bool { let targetIndex = model.selectedIndex + offset + 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 + // 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) + } + + // 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. @@ -436,21 +507,108 @@ final class NCMediaViewerPagingCoordinator: NSObject, object: nil ) - if shouldAutoPlay { - model.requestAutoPlay(at: targetIndex) - } - - // Selection is finalized when the scroll animation ends. - isUserPaging = true - updateCollectionBackground(for: targetIndex) updateVisibleMetadataTitle(for: targetIndex) refreshVisibleCells() - scrollToIndex( + guard scrollToIndex( targetIndex, - animated: true - ) + animated: shouldAnimateTransition + ) else { + model.finishPageTransition(at: sourceIndex) + updateCollectionBackground(for: sourceIndex) + updateVisibleMetadataTitle(for: sourceIndex) + refreshVisibleCells() + return false + } + + return true + } + + private func moveToNextMediaOfSameType( + classFile: String, + after sourceIndex: Int, + completion: @escaping NCMediaPlaybackAdvanceCompletion + ) { + Task { @MainActor [weak self] in + guard let model = self?.model, + self?.collectionView != nil else { + completion(false) + return + } + + guard model.playbackOptions.completionAction == .playNextItem, + model.pageTransition.isIdle, + model.activePageIndex == sourceIndex else { + completion(false) + return + } + + guard let targetIndex = await model.nextMediaIndex( + after: sourceIndex, + matchingClassFile: classFile + ) else { + completion(false) + return + } + + 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 { + let didMove = self.moveToPage( + at: targetIndex, + shouldAutoPlay: true + ) + completion(didMove) + 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 else { + completion(false) + return + } + + guard self.model === model, + self.collectionView != nil, + model.playbackOptions.completionAction == .playNextItem, + model.pageTransition.isIdle, + model.activePageIndex == sourceIndex else { + model.setChromeHidden(wasChromeHidden) + completion(false) + return + } + + let didMove = self.moveToPage( + at: targetIndex, + shouldAutoPlay: true + ) + + if !didMove { + model.setChromeHidden(wasChromeHidden) + } + + completion(didMove) + } + } + } } private func configure(cell: NCMediaViewerPagingCell, page: NCMediaViewerPageModel) { @@ -459,12 +617,9 @@ 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, - shouldAutoPlay: model.autoPlayTargetIndex == page.index, onToggleChrome: { [weak model] in model?.toggleChromeVisibility() }, @@ -480,13 +635,25 @@ final class NCMediaViewerPagingCoordinator: NSObject, shouldAutoPlay: shouldAutoPlay ) }, + onNextMediaOfSameType: { [weak self] classFile, completion in + guard let self else { + completion(false) + return + } + + self.moveToNextMediaOfSameType( + classFile: classFile, + after: page.index, + completion: completion + ) + }, onAutoPlayConsumed: { [weak model] in model?.clearAutoPlayIfNeeded(for: page.index) }, 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 } @@ -549,7 +716,12 @@ final class NCMediaViewerPagingCoordinator: NSObject, // MARK: - UIScrollViewDelegate func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { - 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, @@ -584,7 +756,7 @@ final class NCMediaViewerPagingCoordinator: NSObject, lastVisibleIndex = index onZoomChanged(false) - model.setSelectedIndex(index) + model.updateSelectedIndexDuringTransition(index) updateCollectionBackground(for: index) updateVisibleMetadataTitle(for: index) refreshVisibleCells() @@ -632,6 +804,12 @@ final class NCMediaViewerPagingCoordinator: NSObject, } func scrollViewDidScroll(_ scrollView: UIScrollView) { + // Programmatic navigation commits selection only after it settles. + // Interactive swipes continue updating thumbnails and titles. + guard !model.pageTransition.isProgrammatic else { + return + } + guard isScrollGeometryStable(scrollView) else { return } @@ -646,7 +824,7 @@ final class NCMediaViewerPagingCoordinator: NSObject, lastVisibleIndex = index onZoomChanged(false) - model.setSelectedIndex(index) + model.updateSelectedIndexDuringTransition(index) updateCollectionBackground(for: index) updateVisibleMetadataTitle(for: index) refreshVisibleCells() @@ -683,10 +861,9 @@ final class NCMediaViewerPagingCoordinator: NSObject, } // The settled page is now the selected page. - isUserPaging = false lastVisibleIndex = index - model.setSelectedIndex(index) + model.finishPageTransition(at: index) updateCollectionBackground(for: index) updateVisibleMetadataTitle(for: index) refreshVisibleCells() @@ -746,15 +923,16 @@ final class NCMediaViewerPagingCell: UICollectionViewCell { func configure( model: NCMediaViewerModel, page: NCMediaViewerPageModel, - isSelected: Bool, - isChromeHidden: Bool, 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, + _ completion: @escaping NCMediaPlaybackAdvanceCompletion + ) -> Void, onAutoPlayConsumed: @escaping () -> Void, onZoomChanged: @escaping (Bool) -> Void, onClose: @escaping (_ ocId: String?) -> Void, @@ -771,9 +949,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,