From 25d8e85512d8ac4da6e904dce26d2e9cb2e5e34b Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Fri, 22 May 2026 16:58:53 +0200 Subject: [PATCH 01/54] Refactor media viewer paging and playback architecture --- Nextcloud.xcodeproj/project.pbxproj | 248 +++- .../Data/NCManageDatabase+Metadata.swift | 20 + iOSClient/Files/NCFiles.swift | 30 +- .../Collection Common/Cell/NCCellMain.swift | 12 + .../Cell/NCRecommendationsCell.swift | 11 + .../NCCollectionViewCommon+CellDelegate.swift | 2 +- ...ionViewCommon+CollectionViewDelegate.swift | 17 +- ...tionViewCommon+TransitionSourceBlink.swift | 123 ++ .../NCCollectionViewCommon.swift | 4 +- .../NCSectionFirstHeader.swift | 8 +- iOSClient/Main/Create/NCCreate.swift | 8 +- .../Main/NCMainNavigationController.swift | 1 - iOSClient/Main/NCPickerViewController.swift | 2 +- .../NCMedia+CollectionViewDelegate.swift | 71 +- .../Media/NCMediaNavigationController.swift | 2 +- iOSClient/Menu/NCContextMenuMain.swift | 2 + .../Menu/NCContextMenuPlayerTracks.swift | 148 --- iOSClient/Menu/NCContextMenuViewer.swift | 38 +- iOSClient/NCGlobal.swift | 1 + .../NCNetworking+Recommendations.swift | 4 +- .../NCNetworking+TransferDelegate.swift | 85 +- .../NCViewerRichWorkspaceWebView.swift | 1 + iOSClient/Select/NCSelect.swift | 2 +- iOSClient/Utility/NCUtilityFileSystem.swift | 16 + iOSClient/Viewer/NCViewer.swift | 35 +- .../NCViewerDirectEditing.swift | 7 +- .../Audio/NCAudioViewerContentView.swift | 510 ++++++++ .../Image/NCImageViewerContentView.swift | 354 ++++++ .../Image/NCLivePhotoViewerContentView.swift | 454 +++++++ .../AVPlayer/NCVideoAVPlayerPresenter.swift | 243 ++++ .../NCVideoAVPlayerViewController.swift | 1096 ++++++++++++++++ .../NCVideoAVPlayerViewControls.swift | 269 ++++ .../Content/Video/NCVideoControlsView.swift | 800 ++++++++++++ .../Video/NCVideoPlaybackController.swift | 524 ++++++++ .../Video/NCVideoViewerContentView.swift | 820 ++++++++++++ .../Video/VLC/NCVideoVLCPresenter.swift | 240 ++++ .../Video/VLC/NCVideoVLCViewController.swift | 1100 +++++++++++++++++ .../Video/VLC/NCVideoVLCViewControls.swift | 339 +++++ .../Helpers/NCViewerAppearance.swift | 100 ++ .../Helpers/NCViewerTransitionSource.swift | 34 + .../Helpers/Notification+Extension.swift | 9 + .../NCNextcloudMediaViewerLoader.swift | 363 ++++++ .../Model - View/NCMediaViewerModel.swift | 1086 ++++++++++++++++ .../Model - View/NCMediaViewerView.swift | 104 ++ .../NCMediaViewerHostingController.swift | 520 ++++++++ .../NCMediaViewerPresenter.swift | 574 +++++++++ .../NCViewerMedia/NCPlayer/NCPlayer.swift | 338 ----- .../NCPlayer/NCPlayerToolBar.swift | 448 ------- .../NCPlayer/NCPlayerToolBar.xib | 162 --- .../NCViewerMedia+VisionKit.swift | 29 - .../Viewer/NCViewerMedia/NCViewerMedia.swift | 652 ---------- .../NCViewerMediaDetailView.swift | 233 ---- .../NCViewerMediaPage.storyboard | 599 --------- .../NCViewerMedia/NCViewerMediaPage.swift | 658 ---------- .../NCViewerMedia/Views/NCImageZoomView.swift | 435 +++++++ .../Views/NCMediaViewerDetailView.swift | 330 +++++ .../Views/NCMediaViewerPageView.swift | 500 ++++++++ .../Views/NCMediaViewerPagingView.swift | 853 +++++++++++++ .../Views/NCViewerFloatingTitleView.swift | 225 ++++ .../Viewer/NCViewerPDF/NCViewerPDF.swift | 6 +- .../NCViewerRichDocument.swift | 7 +- 61 files changed, 12483 insertions(+), 3429 deletions(-) create mode 100644 iOSClient/Main/Collection Common/NCCollectionViewCommon+TransitionSourceBlink.swift delete mode 100644 iOSClient/Menu/NCContextMenuPlayerTracks.swift create mode 100644 iOSClient/Viewer/NCViewerMedia/Content/Audio/NCAudioViewerContentView.swift create mode 100644 iOSClient/Viewer/NCViewerMedia/Content/Image/NCImageViewerContentView.swift create mode 100644 iOSClient/Viewer/NCViewerMedia/Content/Image/NCLivePhotoViewerContentView.swift create mode 100644 iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoAVPlayerPresenter.swift create mode 100644 iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoAVPlayerViewController.swift create mode 100644 iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoAVPlayerViewControls.swift create mode 100644 iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoControlsView.swift create mode 100644 iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoPlaybackController.swift create mode 100644 iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoViewerContentView.swift create mode 100644 iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCPresenter.swift create mode 100644 iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCViewController.swift create mode 100644 iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCViewControls.swift create mode 100644 iOSClient/Viewer/NCViewerMedia/Helpers/NCViewerAppearance.swift create mode 100644 iOSClient/Viewer/NCViewerMedia/Helpers/NCViewerTransitionSource.swift create mode 100644 iOSClient/Viewer/NCViewerMedia/Helpers/Notification+Extension.swift create mode 100644 iOSClient/Viewer/NCViewerMedia/Loading/NCNextcloudMediaViewerLoader.swift create mode 100644 iOSClient/Viewer/NCViewerMedia/Model - View/NCMediaViewerModel.swift create mode 100644 iOSClient/Viewer/NCViewerMedia/Model - View/NCMediaViewerView.swift create mode 100644 iOSClient/Viewer/NCViewerMedia/NCMediaViewerHostingController.swift create mode 100644 iOSClient/Viewer/NCViewerMedia/NCMediaViewerPresenter.swift delete mode 100644 iOSClient/Viewer/NCViewerMedia/NCPlayer/NCPlayer.swift delete mode 100644 iOSClient/Viewer/NCViewerMedia/NCPlayer/NCPlayerToolBar.swift delete mode 100644 iOSClient/Viewer/NCViewerMedia/NCPlayer/NCPlayerToolBar.xib delete mode 100644 iOSClient/Viewer/NCViewerMedia/NCViewerMedia+VisionKit.swift delete mode 100644 iOSClient/Viewer/NCViewerMedia/NCViewerMedia.swift delete mode 100644 iOSClient/Viewer/NCViewerMedia/NCViewerMediaDetailView.swift delete mode 100644 iOSClient/Viewer/NCViewerMedia/NCViewerMediaPage.storyboard delete mode 100644 iOSClient/Viewer/NCViewerMedia/NCViewerMediaPage.swift create mode 100644 iOSClient/Viewer/NCViewerMedia/Views/NCImageZoomView.swift create mode 100644 iOSClient/Viewer/NCViewerMedia/Views/NCMediaViewerDetailView.swift create mode 100644 iOSClient/Viewer/NCViewerMedia/Views/NCMediaViewerPageView.swift create mode 100644 iOSClient/Viewer/NCViewerMedia/Views/NCMediaViewerPagingView.swift create mode 100644 iOSClient/Viewer/NCViewerMedia/Views/NCViewerFloatingTitleView.swift diff --git a/Nextcloud.xcodeproj/project.pbxproj b/Nextcloud.xcodeproj/project.pbxproj index 32974fe605..5ea21be103 100644 --- a/Nextcloud.xcodeproj/project.pbxproj +++ b/Nextcloud.xcodeproj/project.pbxproj @@ -11,7 +11,6 @@ 2C1D5D7923E2DE9100334ABB /* NCBrand.swift in Sources */ = {isa = PBXBuildFile; fileRef = F76B3CCD1EAE01BD00921AC9 /* NCBrand.swift */; }; 2C33C48223E2C475005F963B /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C33C48123E2C475005F963B /* NotificationService.swift */; }; 2C33C48623E2C475005F963B /* Notification Service Extension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 2C33C47F23E2C475005F963B /* Notification Service Extension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; - 2F96A1BAFB10ACFEAC68EF1C /* NCContextMenuPlayerTracks.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4C7A5B36D1ED178FB6B76CB /* NCContextMenuPlayerTracks.swift */; }; 370D26AF248A3D7A00121797 /* NCCellMain.swift in Sources */ = {isa = PBXBuildFile; fileRef = 370D26AE248A3D7A00121797 /* NCCellMain.swift */; }; A5A87F9E4B0E4441A6A4BC20 /* NCContextMenuProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB7697C94BA14450A0867940 /* NCContextMenuProfile.swift */; }; AA3C85E82D36B08C00F74F12 /* UITestBackend.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA3C85E72D36B08C00F74F12 /* UITestBackend.swift */; }; @@ -86,7 +85,6 @@ AFCE353927E5DE0500FEA6C2 /* Shareable.swift in Sources */ = {isa = PBXBuildFile; fileRef = AFCE353827E5DE0400FEA6C2 /* Shareable.swift */; }; CB3666201AF7550816B5CD6A /* NCContextMenuComment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8932E90EC4278026D86CCCC9 /* NCContextMenuComment.swift */; }; D5B6AA7827200C7200D49C24 /* NCActivityTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5B6AA7727200C7200D49C24 /* NCActivityTableViewCell.swift */; }; - F310B1EF2BA862F1001C42F5 /* NCViewerMedia+VisionKit.swift in Sources */ = {isa = PBXBuildFile; fileRef = F310B1EE2BA862F1001C42F5 /* NCViewerMedia+VisionKit.swift */; }; F31165022F9674A1009A1E37 /* AppIcon.icon in Resources */ = {isa = PBXBuildFile; fileRef = F31165012F9674A1009A1E37 /* AppIcon.icon */; }; F317C82E2E844C5300761AEA /* ClientIntegrationUIViewer.swift in Sources */ = {isa = PBXBuildFile; fileRef = F317C82D2E844C5300761AEA /* ClientIntegrationUIViewer.swift */; }; F321DA8A2B71205A00DDA0E6 /* NCTrashSelectTabBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = F321DA892B71205A00DDA0E6 /* NCTrashSelectTabBar.swift */; }; @@ -205,9 +203,6 @@ F70557BF2ED44F1800135623 /* UploadBannerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F70557BB2ED44F1800135623 /* UploadBannerView.swift */; }; F70716E62987F81500E72C1D /* DocumentActionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = F70716E52987F81500E72C1D /* DocumentActionViewController.swift */; }; F70716ED2987F81500E72C1D /* File Provider Extension UI.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = F70716E32987F81500E72C1D /* File Provider Extension UI.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; - F70753EB2542A99800972D44 /* NCViewerMediaPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = F70753EA2542A99800972D44 /* NCViewerMediaPage.swift */; }; - F70753F12542A9A200972D44 /* NCViewerMedia.swift in Sources */ = {isa = PBXBuildFile; fileRef = F70753F02542A9A200972D44 /* NCViewerMedia.swift */; }; - F70753F72542A9C000972D44 /* NCViewerMediaPage.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = F70753F62542A9C000972D44 /* NCViewerMediaPage.storyboard */; }; F707C26521A2DC5200F6181E /* NCStoreReview.swift in Sources */ = {isa = PBXBuildFile; fileRef = F707C26421A2DC5200F6181E /* NCStoreReview.swift */; }; F70821D829E59E6D001CA2D7 /* TagListView in Frameworks */ = {isa = PBXBuildFile; productRef = F70821D729E59E6D001CA2D7 /* TagListView */; }; F70898672EDDB39B00EF85BD /* NCNetworking+TransferDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = F70898662EDDB39300EF85BD /* NCNetworking+TransferDelegate.swift */; }; @@ -253,9 +248,10 @@ F7160A822BE933390034DCB3 /* RealmSwift in Frameworks */ = {isa = PBXBuildFile; productRef = F7160A812BE933390034DCB3 /* RealmSwift */; }; F71638922FA0C20C00A913B7 /* NCMoreView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F71638912FA0C1FC00A913B7 /* NCMoreView.swift */; }; F71638942FA0F65A00A913B7 /* NCMoreModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F71638932FA0F64B00A913B7 /* NCMoreModel.swift */; }; + F716DA652FA4E87B006A6703 /* NCImageZoomView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F716DA642FA4E878006A6703 /* NCImageZoomView.swift */; }; + F716DA672FA5F01A006A6703 /* NCMediaViewerPagingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F716DA662FA5F019006A6703 /* NCMediaViewerPagingView.swift */; }; F717402D24F699A5000C87D5 /* NCFavorite.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = F717402B24F699A5000C87D5 /* NCFavorite.storyboard */; }; F717402E24F699A5000C87D5 /* NCFavorite.swift in Sources */ = {isa = PBXBuildFile; fileRef = F717402C24F699A5000C87D5 /* NCFavorite.swift */; }; - F718C24E254D507B00C5C256 /* NCViewerMediaDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F718C24D254D507B00C5C256 /* NCViewerMediaDetailView.swift */; }; F718E25A2DF2D5D1004038AF /* NCBackgroundLocationUploadManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = F718E2572DF2D5C3004038AF /* NCBackgroundLocationUploadManager.swift */; }; F71916122E2901FB00E13E96 /* NCNetworking+Upload.swift in Sources */ = {isa = PBXBuildFile; fileRef = F71916102E2901E800E13E96 /* NCNetworking+Upload.swift */; }; F71916142E2901FB00E13E96 /* NCNetworking+Upload.swift in Sources */ = {isa = PBXBuildFile; fileRef = F71916102E2901E800E13E96 /* NCNetworking+Upload.swift */; }; @@ -272,6 +268,7 @@ F71F6D0C2B6A6A5E00F1EB15 /* ThreadSafeArray.swift in Sources */ = {isa = PBXBuildFile; fileRef = F71F6D062B6A6A5E00F1EB15 /* ThreadSafeArray.swift */; }; F71F6D0D2B6A6A5E00F1EB15 /* ThreadSafeArray.swift in Sources */ = {isa = PBXBuildFile; fileRef = F71F6D062B6A6A5E00F1EB15 /* ThreadSafeArray.swift */; }; F71FA7992F3508C600E86192 /* NCNetworking+WebDAV.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7327E2F2B73A86700A462C7 /* NCNetworking+WebDAV.swift */; }; + F721C50A2FB6F9AA00207DA9 /* NCCollectionViewCommon+TransitionSourceBlink.swift in Sources */ = {isa = PBXBuildFile; fileRef = F721C5092FB6F9AA00207DA9 /* NCCollectionViewCommon+TransitionSourceBlink.swift */; }; F722133B2D40EF9D002F7438 /* NCFilesNavigationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = F722133A2D40EF8C002F7438 /* NCFilesNavigationController.swift */; }; F7226EDC1EE4089300EBECB1 /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = F7226EDB1EE4089300EBECB1 /* Main.storyboard */; }; F722F0112CFF569500065FB5 /* MainInterface.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = F722F0102CFF569500065FB5 /* MainInterface.storyboard */; }; @@ -325,7 +322,6 @@ F7327E302B73A86700A462C7 /* NCNetworking+WebDAV.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7327E2F2B73A86700A462C7 /* NCNetworking+WebDAV.swift */; }; F7327E352B73AEDE00A462C7 /* NCNetworking+LivePhoto.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7327E342B73AEDE00A462C7 /* NCNetworking+LivePhoto.swift */; }; F7327E3B2B73B8D600A462C7 /* Array+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7AC1CAF28AB94490032D99F /* Array+Extension.swift */; }; - F732D23327CF8AED000B0F1B /* NCPlayerToolBar.xib in Resources */ = {isa = PBXBuildFile; fileRef = F732D23227CF8AED000B0F1B /* NCPlayerToolBar.xib */; }; F733598125C1C188002ABA72 /* NCAskAuthorization.swift in Sources */ = {isa = PBXBuildFile; fileRef = F733598025C1C188002ABA72 /* NCAskAuthorization.swift */; }; F7346E1628B0EF5C006CE2D2 /* Widget.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7346E1528B0EF5C006CE2D2 /* Widget.swift */; }; F7346E1C28B0EF5E006CE2D2 /* Widget.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = F7346E1028B0EF5B006CE2D2 /* Widget.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; @@ -395,6 +391,7 @@ F749B654297B0F2400087535 /* NCManageDatabase+Avatar.swift in Sources */ = {isa = PBXBuildFile; fileRef = F749B650297B0F2400087535 /* NCManageDatabase+Avatar.swift */; }; F749B656297B0F2400087535 /* NCManageDatabase+Avatar.swift in Sources */ = {isa = PBXBuildFile; fileRef = F749B650297B0F2400087535 /* NCManageDatabase+Avatar.swift */; }; F749E4E91DC1FB38009BA2FD /* Share.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = F7CE8AFB1DC1F8D8009CAE48 /* Share.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + F749ED312FADD62600CE8DFA /* NCMediaViewerDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F749ED302FADD62400CE8DFA /* NCMediaViewerDetailView.swift */; }; F74AF3A4247FB6AE00AC767B /* NCUtilityFileSystem.swift in Sources */ = {isa = PBXBuildFile; fileRef = F74AF3A3247FB6AE00AC767B /* NCUtilityFileSystem.swift */; }; F74AF3A5247FB6AE00AC767B /* NCUtilityFileSystem.swift in Sources */ = {isa = PBXBuildFile; fileRef = F74AF3A3247FB6AE00AC767B /* NCUtilityFileSystem.swift */; }; F74B6D952A7E239A00F03C5F /* NCManageDatabase+Chunk.swift in Sources */ = {isa = PBXBuildFile; fileRef = F74B6D942A7E239A00F03C5F /* NCManageDatabase+Chunk.swift */; }; @@ -412,12 +409,15 @@ F74C863D2AEFBFD9009A1D4A /* LRUCache in Frameworks */ = {isa = PBXBuildFile; productRef = F74C863C2AEFBFD9009A1D4A /* LRUCache */; }; F74D50352C9855A000BBBF4C /* NCCollectionViewCommon+CollectionViewDataSourcePrefetching.swift in Sources */ = {isa = PBXBuildFile; fileRef = F74D50342C9855A000BBBF4C /* NCCollectionViewCommon+CollectionViewDataSourcePrefetching.swift */; }; F74D50362C9856D300BBBF4C /* NCCollectionViewDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7C1EEA425053A9C00866ACC /* NCCollectionViewDataSource.swift */; }; + F74E3EEB2FB0AD8500252FA0 /* Notification+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = F74E3EEA2FB0AD7800252FA0 /* Notification+Extension.swift */; }; F7501C322212E57500FB1415 /* NCMedia.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = F7501C302212E57400FB1415 /* NCMedia.storyboard */; }; F7501C332212E57500FB1415 /* NCMedia.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7501C312212E57400FB1415 /* NCMedia.swift */; }; F751247C2C42919C00E63DB8 /* NCPhotoCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = F751247A2C42919C00E63DB8 /* NCPhotoCell.swift */; }; F751247E2C42919C00E63DB8 /* NCPhotoCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = F751247B2C42919C00E63DB8 /* NCPhotoCell.xib */; }; F752BA052E58C05200616A26 /* Maintenance.swift in Sources */ = {isa = PBXBuildFile; fileRef = F752BA042E58C05200616A26 /* Maintenance.swift */; }; F753BA93281FD8020015BFB6 /* EasyTipView in Frameworks */ = {isa = PBXBuildFile; productRef = F753BA92281FD8020015BFB6 /* EasyTipView */; }; + F7547FE32FB742A400E372C3 /* NCVideoVLCViewControls.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7547FE22FB7429200E372C3 /* NCVideoVLCViewControls.swift */; }; + F7547FE62FB76C1900E372C3 /* NCVideoControlsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7547FE52FB76C1800E372C3 /* NCVideoControlsView.swift */; }; F755BD9B20594AC7008C5FBB /* NCService.swift in Sources */ = {isa = PBXBuildFile; fileRef = F755BD9A20594AC7008C5FBB /* NCService.swift */; }; F755CB402B8CB13C00CE27E9 /* NCMediaLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = F755CB3F2B8CB13C00CE27E9 /* NCMediaLayout.swift */; }; F757CC8229E7F88B00F31428 /* NCManageDatabase+Groupfolders.swift in Sources */ = {isa = PBXBuildFile; fileRef = F757CC8129E7F88B00F31428 /* NCManageDatabase+Groupfolders.swift */; }; @@ -479,6 +479,7 @@ F763413D2EBE5DBB0056F538 /* FileProviderExtension+Thumbnail.swift in Sources */ = {isa = PBXBuildFile; fileRef = F771E3F520E239B400AFB62D /* FileProviderExtension+Thumbnail.swift */; }; F763413E2EBE5DC00056F538 /* FileProviderItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = F771E3D420E2392D00AFB62D /* FileProviderItem.swift */; }; F763413F2EBE5DC40056F538 /* FileProviderUtility.swift in Sources */ = {isa = PBXBuildFile; fileRef = F76673EF22C90433007ED366 /* FileProviderUtility.swift */; }; + F7635D8D2FB1F820007F658D /* NCVideoVLCPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7635D8C2FB1F81D007F658D /* NCVideoVLCPresenter.swift */; }; F763D29D2A249C4500A3C901 /* NCManageDatabase+Capabilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = F763D29C2A249C4500A3C901 /* NCManageDatabase+Capabilities.swift */; }; F763D29E2A249C4500A3C901 /* NCManageDatabase+Capabilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = F763D29C2A249C4500A3C901 /* NCManageDatabase+Capabilities.swift */; }; F763D29F2A249C4500A3C901 /* NCManageDatabase+Capabilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = F763D29C2A249C4500A3C901 /* NCManageDatabase+Capabilities.swift */; }; @@ -604,6 +605,9 @@ F783030328B4C4DD00B84583 /* ThreadSafeDictionary.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7245923289BB50B00474787 /* ThreadSafeDictionary.swift */; }; F783030728B4C52800B84583 /* UIColor+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = F70CEF5523E9C7E50007035B /* UIColor+Extension.swift */; }; F783034428B5142B00B84583 /* NextcloudKit in Frameworks */ = {isa = PBXBuildFile; productRef = F783034328B5142B00B84583 /* NextcloudKit */; }; + F78448B52FB1BE9000F2909A /* NCVideoViewerContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F78448A82FB1BE9000F2909A /* NCVideoViewerContentView.swift */; }; + F78448BA2FB1BE9000F2909A /* NCVideoPlaybackController.swift in Sources */ = {isa = PBXBuildFile; fileRef = F78448A32FB1BE9000F2909A /* NCVideoPlaybackController.swift */; }; + F78448BE2FB1C33B00F2909A /* NCVideoVLCViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = F78448BD2FB1C33B00F2909A /* NCVideoVLCViewController.swift */; }; F785129C2D7989B30087DDD0 /* NCNetworking+TermsOfService.swift in Sources */ = {isa = PBXBuildFile; fileRef = F785129A2D79899E0087DDD0 /* NCNetworking+TermsOfService.swift */; }; F785EE9D246196DF00B3F945 /* NCNetworkingE2EE.swift in Sources */ = {isa = PBXBuildFile; fileRef = F785EE9C246196DF00B3F945 /* NCNetworkingE2EE.swift */; }; F785EE9E2461A09900B3F945 /* NCNetworking.swift in Sources */ = {isa = PBXBuildFile; fileRef = F75A9EE523796C6F0044CFCE /* NCNetworking.swift */; }; @@ -644,7 +648,10 @@ F78F74342163757000C2ADAD /* NCTrash.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = F78F74332163757000C2ADAD /* NCTrash.storyboard */; }; F78F74362163781100C2ADAD /* NCTrash.swift in Sources */ = {isa = PBXBuildFile; fileRef = F78F74352163781100C2ADAD /* NCTrash.swift */; }; F790110E21415BF600D7B136 /* NCViewerRichDocument.swift in Sources */ = {isa = PBXBuildFile; fileRef = F790110D21415BF600D7B136 /* NCViewerRichDocument.swift */; }; + F79377052FBD86AF00DE56DE /* NCViewerFloatingTitleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F79377042FBD86AE00DE56DE /* NCViewerFloatingTitleView.swift */; }; F793E59D28B761E7005E4B02 /* NCNetworking.swift in Sources */ = {isa = PBXBuildFile; fileRef = F75A9EE523796C6F0044CFCE /* NCNetworking.swift */; }; + F7948DE72FBAE53000253D1C /* NCVideoAVPlayerPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7948DE62FBAE52F00253D1C /* NCVideoAVPlayerPresenter.swift */; }; + F7948DE92FBAEC5400253D1C /* NCVideoAVPlayerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7948DE82FBAEC5300253D1C /* NCVideoAVPlayerViewController.swift */; }; F794E13D2BBBFF2E003693D7 /* NCMainTabBarController.swift in Sources */ = {isa = PBXBuildFile; fileRef = F794E13C2BBBFF2E003693D7 /* NCMainTabBarController.swift */; }; F794E13F2BBC0F70003693D7 /* SceneDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = F794E13E2BBC0F70003693D7 /* SceneDelegate.swift */; }; F79699E72E689F68000EC82A /* NCMediaNavigationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = F79699E62E689F68000EC82A /* NCMediaNavigationController.swift */; }; @@ -665,8 +672,6 @@ F79EC78926316AC4004E59D6 /* NCPopupViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = F702F30725EE5D47008F8E80 /* NCPopupViewController.swift */; }; F79ED0F12D2FCA5B00A389D9 /* NCSectionFirstHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = F78ACD51219046DC0088454D /* NCSectionFirstHeader.swift */; }; F79ED0F22D2FCA6A00A389D9 /* NCRecommendationsCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = F75D901E2D2BE12E003E740B /* NCRecommendationsCell.xib */; }; - F79EDAA326B004980007D134 /* NCPlayerToolBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = F79EDA9F26B004980007D134 /* NCPlayerToolBar.swift */; }; - F79EDAA526B004980007D134 /* NCPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = F79EDAA126B004980007D134 /* NCPlayer.swift */; }; F79FFB262A97C24A0055EEA4 /* NCNetworkingE2EEMarkFolder.swift in Sources */ = {isa = PBXBuildFile; fileRef = F79FFB252A97C24A0055EEA4 /* NCNetworkingE2EEMarkFolder.swift */; }; F79FFB272A97C24A0055EEA4 /* NCNetworkingE2EEMarkFolder.swift in Sources */ = {isa = PBXBuildFile; fileRef = F79FFB252A97C24A0055EEA4 /* NCNetworkingE2EEMarkFolder.swift */; }; F7A03E2F2D425A14007AA677 /* NCFavoriteNavigationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7A03E2E2D425A14007AA677 /* NCFavoriteNavigationController.swift */; }; @@ -788,6 +793,12 @@ F7CBC1252BAC8B0000EC1D55 /* NCSectionFirstHeaderEmptyData.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7CBC1222BAC8B0000EC1D55 /* NCSectionFirstHeaderEmptyData.swift */; }; F7CBC1262BAC8B0000EC1D55 /* NCSectionFirstHeaderEmptyData.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7CBC1222BAC8B0000EC1D55 /* NCSectionFirstHeaderEmptyData.swift */; }; F7CCAB512ECF316700F8E68B /* NCCollectionViewCommon+SyncMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7CCAB502ECF315F00F8E68B /* NCCollectionViewCommon+SyncMetadata.swift */; }; + F7CDB5C32FA33CA300F72306 /* NCMediaViewerPageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7CDB5B92FA33CA300F72306 /* NCMediaViewerPageView.swift */; }; + F7CDB5C42FA33CA300F72306 /* NCImageViewerContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7CDB5B62FA33CA300F72306 /* NCImageViewerContentView.swift */; }; + F7CDB5C52FA33CA300F72306 /* NCMediaViewerModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7CDB5B82FA33CA300F72306 /* NCMediaViewerModel.swift */; }; + F7CDB5C62FA33CA300F72306 /* NCMediaViewerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7CDB5BB2FA33CA300F72306 /* NCMediaViewerView.swift */; }; + F7CDB5CC2FA33CA300F72306 /* NCNextcloudMediaViewerLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7CDB5BD2FA33CA300F72306 /* NCNextcloudMediaViewerLoader.swift */; }; + F7CDB5D32FA3448B00F72306 /* NCAudioViewerContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7CDB5D22FA3448A00F72306 /* NCAudioViewerContentView.swift */; }; F7CEE6002BA9A5C9003EFD89 /* NCTrashGridCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = F7CEE5FE2BA9A5C9003EFD89 /* NCTrashGridCell.xib */; }; F7CEE6012BA9A5C9003EFD89 /* NCTrashGridCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7CEE5FF2BA9A5C9003EFD89 /* NCTrashGridCell.swift */; }; F7CF06802E0FF3990063AD04 /* NCAppStateManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7CF067A2E0FF38F0063AD04 /* NCAppStateManager.swift */; }; @@ -897,6 +908,12 @@ F7E98C1727E0D0FC001F9F19 /* NCManageDatabase+Video.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7E98C1527E0D0FC001F9F19 /* NCManageDatabase+Video.swift */; }; F7E98C1927E0D0FC001F9F19 /* NCManageDatabase+Video.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7E98C1527E0D0FC001F9F19 /* NCManageDatabase+Video.swift */; }; F7ED547C25EEA65400956C55 /* QRCodeReader in Frameworks */ = {isa = PBXBuildFile; productRef = F7ED547B25EEA65400956C55 /* QRCodeReader */; }; + F7EDBB4B2FA89F6800098C42 /* NCLivePhotoViewerContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7EDBB4A2FA89F6500098C42 /* NCLivePhotoViewerContentView.swift */; }; + F7EDBB522FA8CACD00098C42 /* NCMediaViewerHostingController.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7EDBB512FA8CACA00098C42 /* NCMediaViewerHostingController.swift */; }; + F7EDBB552FA8CEBE00098C42 /* NCViewerTransitionSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7EDBB542FA8CEBE00098C42 /* NCViewerTransitionSource.swift */; }; + F7EDBB562FA8CEC900098C42 /* NCViewerTransitionSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7EDBB542FA8CEBE00098C42 /* NCViewerTransitionSource.swift */; }; + F7EDBB582FA8D00200098C42 /* NCViewerAppearance.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7EDBB572FA8CFFF00098C42 /* NCViewerAppearance.swift */; }; + F7EDBB5C2FA8DBE800098C42 /* NCMediaViewerPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7EDBB5B2FA8DBE600098C42 /* NCMediaViewerPresenter.swift */; }; F7EDE4D6262D7B9600414FE6 /* NCListCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = F78ACD4121903CE00088454D /* NCListCell.swift */; }; F7EDE4DB262D7BA200414FE6 /* NCCellMain.swift in Sources */ = {isa = PBXBuildFile; fileRef = 370D26AE248A3D7A00121797 /* NCCellMain.swift */; }; F7EDE509262DA9D600414FE6 /* NCSelectCommandViewSelect.xib in Resources */ = {isa = PBXBuildFile; fileRef = F7EDE508262DA9D600414FE6 /* NCSelectCommandViewSelect.xib */; }; @@ -928,6 +945,7 @@ F7FA7FFC2C0F4EE40072FC60 /* NCViewerQuickLookView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7FA7FFB2C0F4EE40072FC60 /* NCViewerQuickLookView.swift */; }; F7FA80002C0F4F3B0072FC60 /* NCUploadAssetsModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7FA7FFE2C0F4F3B0072FC60 /* NCUploadAssetsModel.swift */; }; F7FA80012C0F4F3B0072FC60 /* NCUploadAssetsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7FA7FFF2C0F4F3B0072FC60 /* NCUploadAssetsView.swift */; }; + F7FAAC222FB773CC00DCA45B /* NCVideoAVPlayerViewControls.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7FAAC212FB773CA00DCA45B /* NCVideoAVPlayerViewControls.swift */; }; F7FAFD3A28BFA948000777FE /* NCContextMenuNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7FAFD3928BFA947000777FE /* NCContextMenuNotification.swift */; }; F7FDFF692E437E55000D7688 /* NCAccountRequest.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = F7FDFF512E437E55000D7688 /* NCAccountRequest.storyboard */; }; F7FDFF6A2E437E55000D7688 /* NCShareAccounts.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = F7FDFF532E437E55000D7688 /* NCShareAccounts.storyboard */; }; @@ -1261,12 +1279,10 @@ AFCE353427E4ED5900FEA6C2 /* DateFormatter+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DateFormatter+Extension.swift"; sourceTree = ""; }; AFCE353627E4ED7B00FEA6C2 /* NCShareCells.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCShareCells.swift; sourceTree = ""; }; AFCE353827E5DE0400FEA6C2 /* Shareable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Shareable.swift; sourceTree = ""; }; - B4C7A5B36D1ED178FB6B76CB /* NCContextMenuPlayerTracks.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCContextMenuPlayerTracks.swift; sourceTree = ""; }; BB7697C94BA14450A0867940 /* NCContextMenuProfile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCContextMenuProfile.swift; sourceTree = ""; }; C0046CDA2A17B98400D87C9D /* NextcloudUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = NextcloudUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; C04E2F202A17BB4D001BAD85 /* NextcloudIntegrationTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = NextcloudIntegrationTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; D5B6AA7727200C7200D49C24 /* NCActivityTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCActivityTableViewCell.swift; sourceTree = ""; }; - F310B1EE2BA862F1001C42F5 /* NCViewerMedia+VisionKit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NCViewerMedia+VisionKit.swift"; sourceTree = ""; }; F31165012F9674A1009A1E37 /* AppIcon.icon */ = {isa = PBXFileReference; lastKnownFileType = folder.iconcomposer.icon; path = AppIcon.icon; sourceTree = ""; }; F317C82D2E844C5300761AEA /* ClientIntegrationUIViewer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClientIntegrationUIViewer.swift; sourceTree = ""; }; F321DA892B71205A00DDA0E6 /* NCTrashSelectTabBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCTrashSelectTabBar.swift; sourceTree = ""; }; @@ -1331,9 +1347,6 @@ F70557BB2ED44F1800135623 /* UploadBannerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UploadBannerView.swift; sourceTree = ""; }; F70716E32987F81500E72C1D /* File Provider Extension UI.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = "File Provider Extension UI.appex"; sourceTree = BUILT_PRODUCTS_DIR; }; F70716E52987F81500E72C1D /* DocumentActionViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DocumentActionViewController.swift; sourceTree = ""; }; - F70753EA2542A99800972D44 /* NCViewerMediaPage.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NCViewerMediaPage.swift; sourceTree = ""; }; - F70753F02542A9A200972D44 /* NCViewerMedia.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NCViewerMedia.swift; sourceTree = ""; }; - F70753F62542A9C000972D44 /* NCViewerMediaPage.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = NCViewerMediaPage.storyboard; sourceTree = ""; }; F707C26421A2DC5200F6181E /* NCStoreReview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCStoreReview.swift; sourceTree = ""; }; F70898662EDDB39300EF85BD /* NCNetworking+TransferDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NCNetworking+TransferDelegate.swift"; sourceTree = ""; }; F70898682EDDB51200EF85BD /* NCSelectOpen+SelectDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NCSelectOpen+SelectDelegate.swift"; sourceTree = ""; }; @@ -1366,9 +1379,10 @@ F71638932FA0F64B00A913B7 /* NCMoreModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCMoreModel.swift; sourceTree = ""; }; F7169A301EE59BB70086BD69 /* it */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = it; path = it.lproj/Localizable.strings; sourceTree = ""; }; F7169A4C1EE59C640086BD69 /* tr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = tr; path = tr.lproj/Localizable.strings; sourceTree = ""; }; + F716DA642FA4E878006A6703 /* NCImageZoomView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCImageZoomView.swift; sourceTree = ""; }; + F716DA662FA5F019006A6703 /* NCMediaViewerPagingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCMediaViewerPagingView.swift; sourceTree = ""; }; F717402B24F699A5000C87D5 /* NCFavorite.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; path = NCFavorite.storyboard; sourceTree = ""; }; F717402C24F699A5000C87D5 /* NCFavorite.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NCFavorite.swift; sourceTree = ""; }; - F718C24D254D507B00C5C256 /* NCViewerMediaDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCViewerMediaDetailView.swift; sourceTree = ""; }; F718E2572DF2D5C3004038AF /* NCBackgroundLocationUploadManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCBackgroundLocationUploadManager.swift; sourceTree = ""; }; F71916102E2901E800E13E96 /* NCNetworking+Upload.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NCNetworking+Upload.swift"; sourceTree = ""; }; F719D9DF288D37A300762E33 /* NCColorPicker.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = NCColorPicker.storyboard; sourceTree = ""; }; @@ -1376,6 +1390,7 @@ F71CFA662F2A07C6007A3AE9 /* NCMedia+Netwoking.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NCMedia+Netwoking.swift"; sourceTree = ""; }; F71D2FB62E09BBD700B751CC /* NCAutoUploadModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCAutoUploadModel.swift; sourceTree = ""; }; F71F6D062B6A6A5E00F1EB15 /* ThreadSafeArray.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadSafeArray.swift; sourceTree = ""; }; + F721C5092FB6F9AA00207DA9 /* NCCollectionViewCommon+TransitionSourceBlink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NCCollectionViewCommon+TransitionSourceBlink.swift"; sourceTree = ""; }; F722133A2D40EF8C002F7438 /* NCFilesNavigationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCFilesNavigationController.swift; sourceTree = ""; }; F7226EDB1EE4089300EBECB1 /* Main.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; path = Main.storyboard; sourceTree = ""; }; F722F0102CFF569500065FB5 /* MainInterface.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = MainInterface.storyboard; sourceTree = ""; }; @@ -1408,7 +1423,6 @@ F7327E1F2B73A42F00A462C7 /* NCNetworking+Download.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NCNetworking+Download.swift"; sourceTree = ""; }; F7327E2F2B73A86700A462C7 /* NCNetworking+WebDAV.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NCNetworking+WebDAV.swift"; sourceTree = ""; }; F7327E342B73AEDE00A462C7 /* NCNetworking+LivePhoto.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NCNetworking+LivePhoto.swift"; sourceTree = ""; }; - F732D23227CF8AED000B0F1B /* NCPlayerToolBar.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; path = NCPlayerToolBar.xib; sourceTree = ""; }; F733598025C1C188002ABA72 /* NCAskAuthorization.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCAskAuthorization.swift; sourceTree = ""; }; F7346E1028B0EF5B006CE2D2 /* Widget.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = Widget.appex; sourceTree = BUILT_PRODUCTS_DIR; }; F7346E1528B0EF5C006CE2D2 /* Widget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Widget.swift; sourceTree = ""; }; @@ -1436,6 +1450,7 @@ F747EB0C2C4AC1FF00F959A8 /* NCCollectionViewCommon+CollectionViewDelegateFlowLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NCCollectionViewCommon+CollectionViewDelegateFlowLayout.swift"; sourceTree = ""; }; F749B649297B0CBB00087535 /* NCManageDatabase+Share.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NCManageDatabase+Share.swift"; sourceTree = ""; }; F749B650297B0F2400087535 /* NCManageDatabase+Avatar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NCManageDatabase+Avatar.swift"; sourceTree = ""; }; + F749ED302FADD62400CE8DFA /* NCMediaViewerDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCMediaViewerDetailView.swift; sourceTree = ""; }; F74AF3A3247FB6AE00AC767B /* NCUtilityFileSystem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCUtilityFileSystem.swift; sourceTree = ""; }; F74B6D942A7E239A00F03C5F /* NCManageDatabase+Chunk.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NCManageDatabase+Chunk.swift"; sourceTree = ""; }; F74B91E42F51D4100050813D /* InfoBannerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InfoBannerView.swift; sourceTree = ""; }; @@ -1443,6 +1458,7 @@ F74C0434253F1CDC009762AB /* NCShares.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NCShares.swift; sourceTree = ""; }; F74C0435253F1CDC009762AB /* NCShares.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; path = NCShares.storyboard; sourceTree = ""; }; F74D50342C9855A000BBBF4C /* NCCollectionViewCommon+CollectionViewDataSourcePrefetching.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NCCollectionViewCommon+CollectionViewDataSourcePrefetching.swift"; sourceTree = ""; }; + F74E3EEA2FB0AD7800252FA0 /* Notification+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Notification+Extension.swift"; sourceTree = ""; }; F7501C302212E57400FB1415 /* NCMedia.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; path = NCMedia.storyboard; sourceTree = ""; }; F7501C312212E57400FB1415 /* NCMedia.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NCMedia.swift; sourceTree = ""; }; F751247A2C42919C00E63DB8 /* NCPhotoCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NCPhotoCell.swift; sourceTree = ""; }; @@ -1451,6 +1467,8 @@ F753701822723D620041C76C /* gl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = gl; path = gl.lproj/Localizable.strings; sourceTree = ""; }; F753701922723E0D0041C76C /* ca */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ca; path = ca.lproj/Localizable.strings; sourceTree = ""; }; F753701A22723EC80041C76C /* da */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = da; path = da.lproj/Localizable.strings; sourceTree = ""; }; + F7547FE22FB7429200E372C3 /* NCVideoVLCViewControls.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCVideoVLCViewControls.swift; sourceTree = ""; }; + F7547FE52FB76C1800E372C3 /* NCVideoControlsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCVideoControlsView.swift; sourceTree = ""; }; F755BD9A20594AC7008C5FBB /* NCService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCService.swift; sourceTree = ""; }; F755CB3F2B8CB13C00CE27E9 /* NCMediaLayout.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NCMediaLayout.swift; sourceTree = ""; }; F757CC8129E7F88B00F31428 /* NCManageDatabase+Groupfolders.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "NCManageDatabase+Groupfolders.swift"; sourceTree = ""; }; @@ -1478,6 +1496,7 @@ F76340F32EBDE9740056F538 /* NCManageDatabaseCore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCManageDatabaseCore.swift; sourceTree = ""; }; F76340FB2EBDF64A0056F538 /* NCManageDatabase+Tag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NCManageDatabase+Tag.swift"; sourceTree = ""; }; F76341172EBE0BB80056F538 /* NCNetworking+NextcloudKitDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NCNetworking+NextcloudKitDelegate.swift"; sourceTree = ""; }; + F7635D8C2FB1F81D007F658D /* NCVideoVLCPresenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCVideoVLCPresenter.swift; sourceTree = ""; }; F763D29C2A249C4500A3C901 /* NCManageDatabase+Capabilities.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "NCManageDatabase+Capabilities.swift"; sourceTree = ""; }; F765E9CC295C585800A09ED8 /* NCUploadScanDocument.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCUploadScanDocument.swift; sourceTree = ""; }; F765F72F25237E3F00391DBE /* NCRecent.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NCRecent.swift; sourceTree = ""; }; @@ -1572,6 +1591,9 @@ F7814E952F3B5F170074DA3A /* NCSVGRenderer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCSVGRenderer.swift; sourceTree = ""; }; F7816EF12C2C3E1F00A52517 /* NCPushNotification.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCPushNotification.swift; sourceTree = ""; }; F7817CF729801A3500FFBC65 /* Data+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Data+Extension.swift"; sourceTree = ""; }; + F78448A32FB1BE9000F2909A /* NCVideoPlaybackController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCVideoPlaybackController.swift; sourceTree = ""; }; + F78448A82FB1BE9000F2909A /* NCVideoViewerContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCVideoViewerContentView.swift; sourceTree = ""; }; + F78448BD2FB1C33B00F2909A /* NCVideoVLCViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCVideoVLCViewController.swift; sourceTree = ""; }; F785129A2D79899E0087DDD0 /* NCNetworking+TermsOfService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NCNetworking+TermsOfService.swift"; sourceTree = ""; }; F785EE9C246196DF00B3F945 /* NCNetworkingE2EE.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCNetworkingE2EE.swift; sourceTree = ""; }; F7864ACB2A78FE73004870E0 /* NCManageDatabase+LocalFile.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "NCManageDatabase+LocalFile.swift"; sourceTree = ""; }; @@ -1600,6 +1622,9 @@ F790110D21415BF600D7B136 /* NCViewerRichDocument.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCViewerRichDocument.swift; sourceTree = ""; }; F79131C628AFB86E00577277 /* eu */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = eu; path = eu.lproj/Localizable.strings; sourceTree = ""; }; F79131C728AFB86E00577277 /* eu */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = eu; path = eu.lproj/InfoPlist.strings; sourceTree = ""; }; + F79377042FBD86AE00DE56DE /* NCViewerFloatingTitleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCViewerFloatingTitleView.swift; sourceTree = ""; }; + F7948DE62FBAE52F00253D1C /* NCVideoAVPlayerPresenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCVideoAVPlayerPresenter.swift; sourceTree = ""; }; + F7948DE82FBAEC5300253D1C /* NCVideoAVPlayerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCVideoAVPlayerViewController.swift; sourceTree = ""; }; F794E13C2BBBFF2E003693D7 /* NCMainTabBarController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCMainTabBarController.swift; sourceTree = ""; }; F794E13E2BBC0F70003693D7 /* SceneDelegate.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = SceneDelegate.swift; sourceTree = ""; }; F79699E62E689F68000EC82A /* NCMediaNavigationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCMediaNavigationController.swift; sourceTree = ""; }; @@ -1611,8 +1636,6 @@ F79A65C52191D95E00FF6DCC /* NCSelect.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCSelect.swift; sourceTree = ""; }; F79B645F26CA661600838ACA /* UIControl+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "UIControl+Extension.swift"; sourceTree = ""; }; F79B869A265E19D40085C0E0 /* NSMutableAttributedString+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NSMutableAttributedString+Extension.swift"; sourceTree = ""; }; - F79EDA9F26B004980007D134 /* NCPlayerToolBar.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NCPlayerToolBar.swift; sourceTree = ""; }; - F79EDAA126B004980007D134 /* NCPlayer.swift */ = {isa = PBXFileReference; fileEncoding = 4; indentWidth = 4; lastKnownFileType = sourcecode.swift; path = NCPlayer.swift; sourceTree = ""; }; F79FFB252A97C24A0055EEA4 /* NCNetworkingE2EEMarkFolder.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCNetworkingE2EEMarkFolder.swift; sourceTree = ""; }; F7A03E2E2D425A14007AA677 /* NCFavoriteNavigationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCFavoriteNavigationController.swift; sourceTree = ""; }; F7A03E322D426115007AA677 /* NCMoreNavigationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCMoreNavigationController.swift; sourceTree = ""; }; @@ -1758,6 +1781,12 @@ F7CBC1222BAC8B0000EC1D55 /* NCSectionFirstHeaderEmptyData.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NCSectionFirstHeaderEmptyData.swift; sourceTree = ""; }; F7CC04E61F5AD50D00378CEF /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/Localizable.strings; sourceTree = ""; }; F7CCAB502ECF315F00F8E68B /* NCCollectionViewCommon+SyncMetadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NCCollectionViewCommon+SyncMetadata.swift"; sourceTree = ""; }; + F7CDB5B62FA33CA300F72306 /* NCImageViewerContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCImageViewerContentView.swift; sourceTree = ""; }; + F7CDB5B82FA33CA300F72306 /* NCMediaViewerModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCMediaViewerModel.swift; sourceTree = ""; }; + F7CDB5B92FA33CA300F72306 /* NCMediaViewerPageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCMediaViewerPageView.swift; sourceTree = ""; }; + F7CDB5BB2FA33CA300F72306 /* NCMediaViewerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCMediaViewerView.swift; sourceTree = ""; }; + F7CDB5BD2FA33CA300F72306 /* NCNextcloudMediaViewerLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCNextcloudMediaViewerLoader.swift; sourceTree = ""; }; + F7CDB5D22FA3448A00F72306 /* NCAudioViewerContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCAudioViewerContentView.swift; sourceTree = ""; }; F7CE8AFA1DC1F8D8009CAE48 /* Nextcloud.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Nextcloud.app; sourceTree = BUILT_PRODUCTS_DIR; }; F7CE8AFB1DC1F8D8009CAE48 /* Share.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = Share.appex; sourceTree = BUILT_PRODUCTS_DIR; }; F7CEE5FE2BA9A5C9003EFD89 /* NCTrashGridCell.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; path = NCTrashGridCell.xib; sourceTree = ""; }; @@ -1828,6 +1857,11 @@ F7E7AEA42BA32C6500512E52 /* NCCollectionViewDownloadThumbnail.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCCollectionViewDownloadThumbnail.swift; sourceTree = ""; }; F7E8A390295DC5E0006CB2D0 /* View+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "View+Extension.swift"; sourceTree = ""; }; F7E98C1527E0D0FC001F9F19 /* NCManageDatabase+Video.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NCManageDatabase+Video.swift"; sourceTree = ""; }; + F7EDBB4A2FA89F6500098C42 /* NCLivePhotoViewerContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCLivePhotoViewerContentView.swift; sourceTree = ""; }; + F7EDBB512FA8CACA00098C42 /* NCMediaViewerHostingController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCMediaViewerHostingController.swift; sourceTree = ""; }; + F7EDBB542FA8CEBE00098C42 /* NCViewerTransitionSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCViewerTransitionSource.swift; sourceTree = ""; }; + F7EDBB572FA8CFFF00098C42 /* NCViewerAppearance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCViewerAppearance.swift; sourceTree = ""; }; + F7EDBB5B2FA8DBE600098C42 /* NCMediaViewerPresenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCMediaViewerPresenter.swift; sourceTree = ""; }; F7EDE508262DA9D600414FE6 /* NCSelectCommandViewSelect.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = NCSelectCommandViewSelect.xib; sourceTree = ""; }; F7EDE513262DC2CD00414FE6 /* NCSelectCommandViewSelect+CreateFolder.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; path = "NCSelectCommandViewSelect+CreateFolder.xib"; sourceTree = ""; }; F7EDE51A262DD0C400414FE6 /* NCSelectCommandViewCopyMove.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; path = NCSelectCommandViewCopyMove.xib; sourceTree = ""; }; @@ -1851,6 +1885,7 @@ F7FA7FFB2C0F4EE40072FC60 /* NCViewerQuickLookView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NCViewerQuickLookView.swift; sourceTree = ""; }; F7FA7FFE2C0F4F3B0072FC60 /* NCUploadAssetsModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NCUploadAssetsModel.swift; sourceTree = ""; }; F7FA7FFF2C0F4F3B0072FC60 /* NCUploadAssetsView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NCUploadAssetsView.swift; sourceTree = ""; }; + F7FAAC212FB773CA00DCA45B /* NCVideoAVPlayerViewControls.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCVideoAVPlayerViewControls.swift; sourceTree = ""; }; F7FAFD3928BFA947000777FE /* NCContextMenuNotification.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NCContextMenuNotification.swift; sourceTree = ""; }; F7FDFF512E437E55000D7688 /* NCAccountRequest.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = NCAccountRequest.storyboard; sourceTree = ""; }; F7FDFF522E437E55000D7688 /* NCAccountRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCAccountRequest.swift; sourceTree = ""; }; @@ -2051,7 +2086,6 @@ F78C6FDD296D677300C952C3 /* NCContextMenuMain.swift */, F72EC7252F45C90600A2135C /* NCContextMenuNavigation.swift */, F7FAFD3928BFA947000777FE /* NCContextMenuNotification.swift */, - B4C7A5B36D1ED178FB6B76CB /* NCContextMenuPlayerTracks.swift */, F72EC7272F45FF0600A2135C /* NCContextMenuPlus.swift */, BB7697C94BA14450A0867940 /* NCContextMenuProfile.swift */, AF93471127E2341B002537EE /* NCContextMenuShare.swift */, @@ -2352,6 +2386,16 @@ path = Shares; sourceTree = ""; }; + F716DA682FA5F137006A6703 /* Content */ = { + isa = PBXGroup; + children = ( + F78448AE2FB1BE9000F2909A /* Video */, + F74E3EE52FB07F3000252FA0 /* Audio */, + F74E3EE42FB07F2500252FA0 /* Image */, + ); + path = Content; + sourceTree = ""; + }; F720B5B72507B9A5008C94E5 /* Cell */ = { isa = PBXGroup; children = ( @@ -2475,6 +2519,15 @@ path = NCViewerDirectEditing; sourceTree = ""; }; + F749ED342FAF0EE200CE8DFA /* Model - View */ = { + isa = PBXGroup; + children = ( + F7CDB5BB2FA33CA300F72306 /* NCMediaViewerView.swift */, + F7CDB5B82FA33CA300F72306 /* NCMediaViewerModel.swift */, + ); + path = "Model - View"; + sourceTree = ""; + }; F74D3DB81BAC1941000BAE4B /* Networking */ = { isa = PBXGroup; children = ( @@ -2499,6 +2552,23 @@ path = Networking; sourceTree = ""; }; + F74E3EE42FB07F2500252FA0 /* Image */ = { + isa = PBXGroup; + children = ( + F7CDB5B62FA33CA300F72306 /* NCImageViewerContentView.swift */, + F7EDBB4A2FA89F6500098C42 /* NCLivePhotoViewerContentView.swift */, + ); + path = Image; + sourceTree = ""; + }; + F74E3EE52FB07F3000252FA0 /* Audio */ = { + isa = PBXGroup; + children = ( + F7CDB5D22FA3448A00F72306 /* NCAudioViewerContentView.swift */, + ); + path = Audio; + sourceTree = ""; + }; F757CC8929E82D0500F31428 /* Groupfolders */ = { isa = PBXGroup; children = ( @@ -2551,7 +2621,6 @@ children = ( F75FE06B2BB01D0D00A0EFEF /* Cell */, F70D7C3525FFBF81002B9E34 /* NCCollectionViewCommon.swift */, - F76995F32F9A4AC000291FA7 /* NCCollectionViewCommon+UIEditMenuInteractionDelegate.swift */, F7CAFE172F164B9200DB35A5 /* NCCollectionViewCommon+CellDelegate.swift */, F7743A132C33F13A0034F670 /* NCCollectionViewCommon+CollectionViewDataSource.swift */, F74D50342C9855A000BBBF4C /* NCCollectionViewCommon+CollectionViewDataSourcePrefetching.swift */, @@ -2563,6 +2632,8 @@ F7865FF02F39D32500D09AE4 /* NCCollectionViewCommon+Search.swift */, F36E64F62B9245210085ABB5 /* NCCollectionViewCommon+SelectTabBarDelegate.swift */, F7CCAB502ECF315F00F8E68B /* NCCollectionViewCommon+SyncMetadata.swift */, + F721C5092FB6F9AA00207DA9 /* NCCollectionViewCommon+TransitionSourceBlink.swift */, + F76995F32F9A4AC000291FA7 /* NCCollectionViewCommon+UIEditMenuInteractionDelegate.swift */, F7D4BF002CA1831600A5E746 /* NCCollectionViewCommonPinchGesture.swift */, F38F71242B6BBDC300473CDC /* NCCollectionViewCommonSelectTabBar.swift */, F7C1EEA425053A9C00866ACC /* NCCollectionViewDataSource.swift */, @@ -2765,6 +2836,38 @@ path = Toolbar; sourceTree = ""; }; + F78448AE2FB1BE9000F2909A /* Video */ = { + isa = PBXGroup; + children = ( + F78448A32FB1BE9000F2909A /* NCVideoPlaybackController.swift */, + F78448A82FB1BE9000F2909A /* NCVideoViewerContentView.swift */, + F7547FE52FB76C1800E372C3 /* NCVideoControlsView.swift */, + F78448C02FB1C79A00F2909A /* VLC */, + F78448BF2FB1C78900F2909A /* AVPlayer */, + ); + path = Video; + sourceTree = ""; + }; + F78448BF2FB1C78900F2909A /* AVPlayer */ = { + isa = PBXGroup; + children = ( + F7948DE62FBAE52F00253D1C /* NCVideoAVPlayerPresenter.swift */, + F7948DE82FBAEC5300253D1C /* NCVideoAVPlayerViewController.swift */, + F7FAAC212FB773CA00DCA45B /* NCVideoAVPlayerViewControls.swift */, + ); + path = AVPlayer; + sourceTree = ""; + }; + F78448C02FB1C79A00F2909A /* VLC */ = { + isa = PBXGroup; + children = ( + F7635D8C2FB1F81D007F658D /* NCVideoVLCPresenter.swift */, + F78448BD2FB1C33B00F2909A /* NCVideoVLCViewController.swift */, + F7547FE22FB7429200E372C3 /* NCVideoVLCViewControls.swift */, + ); + path = VLC; + sourceTree = ""; + }; F78ACD4721903F850088454D /* Cell */ = { isa = PBXGroup; children = ( @@ -2806,25 +2909,12 @@ path = Trash; sourceTree = ""; }; - F79018B1240962C7007C9B6D /* NCViewerMedia */ = { - isa = PBXGroup; - children = ( - F70753EA2542A99800972D44 /* NCViewerMediaPage.swift */, - F718C24D254D507B00C5C256 /* NCViewerMediaDetailView.swift */, - F70753F02542A9A200972D44 /* NCViewerMedia.swift */, - F310B1EE2BA862F1001C42F5 /* NCViewerMedia+VisionKit.swift */, - F70753F62542A9C000972D44 /* NCViewerMediaPage.storyboard */, - F79EDA9E26B004980007D134 /* NCPlayer */, - ); - path = NCViewerMedia; - sourceTree = ""; - }; F79630EC215526B60015EEA5 /* Viewer */ = { isa = PBXGroup; children = ( F7F9D1BA25397CE000D9BFF5 /* NCViewer.swift */, F7EFA47725ADBA500083159A /* NCViewerProviderContextMenu.swift */, - F79018B1240962C7007C9B6D /* NCViewerMedia */, + F7CDB5C12FA33CA300F72306 /* NCViewerMedia */, F723986A253C9C0E00257F49 /* NCViewerQuickLook */, F76D3CEF2428B3DD005DFA87 /* NCViewerPDF */, F73D11FF253C5F5400DF9BEC /* NCViewerDirectEditing */, @@ -2846,16 +2936,6 @@ path = Select; sourceTree = ""; }; - F79EDA9E26B004980007D134 /* NCPlayer */ = { - isa = PBXGroup; - children = ( - F79EDAA126B004980007D134 /* NCPlayer.swift */, - F732D23227CF8AED000B0F1B /* NCPlayerToolBar.xib */, - F79EDA9F26B004980007D134 /* NCPlayerToolBar.swift */, - ); - path = NCPlayer; - sourceTree = ""; - }; F7A0D14E259229FA008F8A13 /* Extensions */ = { isa = PBXGroup; children = ( @@ -3101,6 +3181,40 @@ path = More; sourceTree = ""; }; + F7CDB5C12FA33CA300F72306 /* NCViewerMedia */ = { + isa = PBXGroup; + children = ( + F7EDBB5B2FA8DBE600098C42 /* NCMediaViewerPresenter.swift */, + F7EDBB512FA8CACA00098C42 /* NCMediaViewerHostingController.swift */, + F749ED342FAF0EE200CE8DFA /* Model - View */, + F7CDB5CE2FA33DED00F72306 /* Loading */, + F7CDB5D02FA33E3500F72306 /* Views */, + F716DA682FA5F137006A6703 /* Content */, + F7EDBB592FA8D09E00098C42 /* Helpers */, + ); + path = NCViewerMedia; + sourceTree = ""; + }; + F7CDB5CE2FA33DED00F72306 /* Loading */ = { + isa = PBXGroup; + children = ( + F7CDB5BD2FA33CA300F72306 /* NCNextcloudMediaViewerLoader.swift */, + ); + path = Loading; + sourceTree = ""; + }; + F7CDB5D02FA33E3500F72306 /* Views */ = { + isa = PBXGroup; + children = ( + F716DA662FA5F019006A6703 /* NCMediaViewerPagingView.swift */, + F7CDB5B92FA33CA300F72306 /* NCMediaViewerPageView.swift */, + F716DA642FA4E878006A6703 /* NCImageZoomView.swift */, + F79377042FBD86AE00DE56DE /* NCViewerFloatingTitleView.swift */, + F749ED302FADD62400CE8DFA /* NCMediaViewerDetailView.swift */, + ); + path = Views; + sourceTree = ""; + }; F7D4BF0A2CA2E8D800A5E746 /* Models */ = { isa = PBXGroup; children = ( @@ -3237,6 +3351,16 @@ path = Media; sourceTree = ""; }; + F7EDBB592FA8D09E00098C42 /* Helpers */ = { + isa = PBXGroup; + children = ( + F7EDBB572FA8CFFF00098C42 /* NCViewerAppearance.swift */, + F7EDBB542FA8CEBE00098C42 /* NCViewerTransitionSource.swift */, + F74E3EEA2FB0AD7800252FA0 /* Notification+Extension.swift */, + ); + path = Helpers; + sourceTree = ""; + }; F7EF2AEA2E43157B0081B2C9 /* Notification */ = { isa = PBXGroup; children = ( @@ -4060,7 +4184,6 @@ F7CBC1232BAC8B0000EC1D55 /* NCSectionFirstHeaderEmptyData.xib in Resources */, F700510122DF63AC003A3356 /* NCShare.storyboard in Resources */, F787704F22E7019900F287A9 /* NCShareLinkCell.xib in Resources */, - F70753F72542A9C000972D44 /* NCViewerMediaPage.storyboard in Resources */, F7F4F10627ECDBDB008676F9 /* Inconsolata-Medium.ttf in Resources */, F7AC934A296193050002BC0F /* Reasons to use Nextcloud.pdf in Resources */, F761856A29E98543006EB3B0 /* NCIntro.storyboard in Resources */, @@ -4075,7 +4198,6 @@ F751247E2C42919C00E63DB8 /* NCPhotoCell.xib in Resources */, F704B5E32430AA6F00632F5F /* NCCreateFormUploadConflict.storyboard in Resources */, F7EDE509262DA9D600414FE6 /* NCSelectCommandViewSelect.xib in Resources */, - F732D23327CF8AED000B0F1B /* NCPlayerToolBar.xib in Resources */, F73D11FA253C5F4800DF9BEC /* NCViewerDirectEditing.storyboard in Resources */, F7EDE51B262DD0C400414FE6 /* NCSelectCommandViewCopyMove.xib in Resources */, F7FF2CB12842159500EBB7A1 /* NCSectionHeader.xib in Resources */, @@ -4324,6 +4446,7 @@ F76340F82EBDE9760056F538 /* NCManageDatabaseCore.swift in Sources */, F79ED0F12D2FCA5B00A389D9 /* NCSectionFirstHeader.swift in Sources */, F79B646126CA661600838ACA /* UIControl+Extension.swift in Sources */, + F7EDBB562FA8CEC900098C42 /* NCViewerTransitionSource.swift in Sources */, F77C973A2953143A00FDDD09 /* NCCameraRoll.swift in Sources */, F740BEF02A35C2AD00E9B6D5 /* UILabel+Extension.swift in Sources */, F7C30E01291BD2610017149B /* NCNetworkingE2EERename.swift in Sources */, @@ -4541,7 +4664,6 @@ F78C6FDE296D677300C952C3 /* NCContextMenuMain.swift in Sources */, A5A87F9E4B0E4441A6A4BC20 /* NCContextMenuProfile.swift in Sources */, CB3666201AF7550816B5CD6A /* NCContextMenuComment.swift in Sources */, - 2F96A1BAFB10ACFEAC68EF1C /* NCContextMenuPlayerTracks.swift in Sources */, F7E402332BA89551007E5609 /* NCTrash+Networking.swift in Sources */, F73EF7A72B0223900087E6E9 /* NCManageDatabase+Comments.swift in Sources */, F33918C42C7CD8F2002D9AA1 /* FileNameValidator+Extensions.swift in Sources */, @@ -4558,6 +4680,7 @@ F7DF7B3F2F1A2EF900514020 /* WarningBannerView.swift in Sources */, F768822C2C0DD1E7001CF441 /* NCPreferences.swift in Sources */, F7CAFE1D2F17A35F00DB35A5 /* NCNetworking+Actor.swift in Sources */, + F7EDBB4B2FA89F6800098C42 /* NCLivePhotoViewerContentView.swift in Sources */, F3754A7D2CF87D600009312E /* SetupPasscodeView.swift in Sources */, F73EF7D72B0226080087E6E9 /* NCManageDatabase+Tip.swift in Sources */, F3374A842D64AC31002A38F9 /* AssistantLabelStyle.swift in Sources */, @@ -4567,6 +4690,7 @@ F78ACD4021903CC20088454D /* NCGridCell.swift in Sources */, F7D890752BD25C570050B8A6 /* NCCollectionViewCommon+DragDrop.swift in Sources */, F7BD0A042C4689E9003A4A6D /* NCMedia+MediaLayout.swift in Sources */, + F7CDB5D32FA3448B00F72306 /* NCAudioViewerContentView.swift in Sources */, F718E25A2DF2D5D1004038AF /* NCBackgroundLocationUploadManager.swift in Sources */, F761856B29E98543006EB3B0 /* NCIntroViewController.swift in Sources */, F7743A142C33F13A0034F670 /* NCCollectionViewCommon+CollectionViewDataSource.swift in Sources */, @@ -4586,24 +4710,31 @@ F714A1472ED84AF90050A43B /* HudBannerView.swift in Sources */, F7A3DB932DDE23B5008F7EC8 /* NCDebouncer.swift in Sources */, F72CD63A25C19EBF00F46F9A /* NCAutoUpload.swift in Sources */, + F7FAAC222FB773CC00DCA45B /* NCVideoAVPlayerViewControls.swift in Sources */, AF93471D27E2361E002537EE /* NCShareAdvancePermissionFooter.swift in Sources */, AF1A9B6427D0CA1E00F17A9E /* UIAlertController+Extension.swift in Sources */, F7FA80012C0F4F3B0072FC60 /* NCUploadAssetsView.swift in Sources */, F74230F32C79B57200CA1ACA /* NCNetworking+Task.swift in Sources */, F757CC8229E7F88B00F31428 /* NCManageDatabase+Groupfolders.swift in Sources */, F7B769A82B7A0B2000C1AAEB /* NCManageDatabase+Metadata+Session.swift in Sources */, - F79EDAA326B004980007D134 /* NCPlayerToolBar.swift in Sources */, F7B934FE2BDCFE1E002B2FC9 /* NCDragDrop.swift in Sources */, F77444F8222816D5000D5EB0 /* NCPickerViewController.swift in Sources */, F77BB74A2899857B0090FC19 /* UINavigationController+Extension.swift in Sources */, + F7948DE92FBAEC5400253D1C /* NCVideoAVPlayerViewController.swift in Sources */, F70898672EDDB39B00EF85BD /* NCNetworking+TransferDelegate.swift in Sources */, F769454622E9F1B0000A798A /* NCShareCommon.swift in Sources */, - F70753F12542A9A200972D44 /* NCViewerMedia.swift in Sources */, F799DF822C4B7DCC003410B5 /* NCSectionFooter.swift in Sources */, F76B649C2ADFFAED00014640 /* NCImageCache.swift in Sources */, F7110AE42F9774140095AA5C /* AppDelegate+AppProcessing.swift in Sources */, + F7CDB5C32FA33CA300F72306 /* NCMediaViewerPageView.swift in Sources */, + F7CDB5C42FA33CA300F72306 /* NCImageViewerContentView.swift in Sources */, + F7CDB5C52FA33CA300F72306 /* NCMediaViewerModel.swift in Sources */, + F7CDB5C62FA33CA300F72306 /* NCMediaViewerView.swift in Sources */, + F7CDB5CC2FA33CA300F72306 /* NCNextcloudMediaViewerLoader.swift in Sources */, F76341182EBE0BC60056F538 /* NCNetworking+NextcloudKitDelegate.swift in Sources */, + F79377052FBD86AF00DE56DE /* NCViewerFloatingTitleView.swift in Sources */, F78A18B823CDE2B300F681F3 /* NCViewerRichWorkspace.swift in Sources */, + F7948DE72FBAE53000253D1C /* NCVideoAVPlayerPresenter.swift in Sources */, F34E1AD92ECC839100FA10C3 /* EmojiTextField.swift in Sources */, F768822E2C0DD1E7001CF441 /* NCSettingsBundleHelper.swift in Sources */, F72408332B8A27C900F128E2 /* NCMedia+Command.swift in Sources */, @@ -4615,6 +4746,7 @@ AFCE353727E4ED7B00FEA6C2 /* NCShareCells.swift in Sources */, F75A9EE623796C6F0044CFCE /* NCNetworking.swift in Sources */, F72EC7282F45FF1400A2135C /* NCContextMenuPlus.swift in Sources */, + F7EDBB522FA8CACD00098C42 /* NCMediaViewerHostingController.swift in Sources */, AA8D31552D41052300FE2775 /* NCManageDatabase+DownloadLimit.swift in Sources */, F758B460212C56A400515F55 /* NCScan.swift in Sources */, F76882262C0DD1E7001CF441 /* NCSettingsView.swift in Sources */, @@ -4622,6 +4754,8 @@ F743C89E2E5B25A1000173A9 /* UIScene+Extension.swift in Sources */, F72944F52A8424F800246839 /* NCEndToEndMetadataV1.swift in Sources */, F710D2022405826100A6033D /* NCContextMenuViewer.swift in Sources */, + F7EDBB582FA8D00200098C42 /* NCViewerAppearance.swift in Sources */, + F74E3EEB2FB0AD8500252FA0 /* Notification+Extension.swift in Sources */, F765E9CD295C585800A09ED8 /* NCUploadScanDocument.swift in Sources */, F741C2242B6B9FD600E849BB /* NCMediaSelectTabBar.swift in Sources */, F7BF9D822934CA21009EE9A6 /* NCManageDatabase+LayoutForView.swift in Sources */, @@ -4644,6 +4778,7 @@ F77BB746289984CA0090FC19 /* UIViewController+Extension.swift in Sources */, F700510522DF6A89003A3356 /* NCShare.swift in Sources */, F72D1007210B6882009C96B7 /* NCPushNotificationEncryption.m in Sources */, + F7EDBB552FA8CEBE00098C42 /* NCViewerTransitionSource.swift in Sources */, F71638942FA0F65A00A913B7 /* NCMoreModel.swift in Sources */, F76882362C0DD1E7001CF441 /* NCAcknowledgementsView.swift in Sources */, F785EE9D246196DF00B3F945 /* NCNetworkingE2EE.swift in Sources */, @@ -4683,6 +4818,7 @@ F76882352C0DD1E7001CF441 /* NCWebBrowserView.swift in Sources */, F72CA0572F5048C3002E2F06 /* UIApplication+Extension.swift in Sources */, F3A047972BD2668800658E7B /* NCAssistantEmptyView.swift in Sources */, + F7547FE32FB742A400E372C3 /* NCVideoVLCViewControls.swift in Sources */, F757CC8D29E82D0500F31428 /* NCGroupfolders.swift in Sources */, F34BDB3A2F5744EC007A222C /* UINavigationItem+Extension.swift in Sources */, F7F3E58E2D3BB65600A32B14 /* NCNetworking+Recommendations.swift in Sources */, @@ -4708,7 +4844,6 @@ F75DD765290ABB25002EB562 /* Intent.intentdefinition in Sources */, F7D4BF012CA1831900A5E746 /* NCCollectionViewCommonPinchGesture.swift in Sources */, F74B6D952A7E239A00F03C5F /* NCManageDatabase+Chunk.swift in Sources */, - F310B1EF2BA862F1001C42F5 /* NCViewerMedia+VisionKit.swift in Sources */, F702F2F725EE5CED008F8E80 /* NCLogin.swift in Sources */, F75D90212D2BE26F003E740B /* NCRecommendationsCell.swift in Sources */, F7E98C1627E0D0FC001F9F19 /* NCManageDatabase+Video.swift in Sources */, @@ -4732,15 +4867,14 @@ F78026122E9CFA6300B63436 /* NCTransfersModel.swift in Sources */, F7EF2AEB2E43157B0081B2C9 /* NCNotification.swift in Sources */, F70BFC7420E0FA7D00C67599 /* NCUtility.swift in Sources */, - F79EDAA526B004980007D134 /* NCPlayer.swift in Sources */, F7C1EEA525053A9C00866ACC /* NCCollectionViewDataSource.swift in Sources */, F713FF002472764100214AF6 /* UIImage+animatedGIF.m in Sources */, AFCE353527E4ED5900FEA6C2 /* DateFormatter+Extension.swift in Sources */, - F718C24E254D507B00C5C256 /* NCViewerMediaDetailView.swift in Sources */, F33EE6F22BF4C9B200CA1A51 /* PKCS12.swift in Sources */, F7145610296433C80038D028 /* NCDocumentCamera.swift in Sources */, F34E1AD72ECB937D00FA10C3 /* NCStatusMessageView.swift in Sources */, F76882312C0DD1E7001CF441 /* NCFileNameView.swift in Sources */, + F716DA652FA4E87B006A6703 /* NCImageZoomView.swift in Sources */, F7381EE1218218C9000B1560 /* NCOffline.swift in Sources */, F751247C2C42919C00E63DB8 /* NCPhotoCell.swift in Sources */, F7A509252C26BD5D00326106 /* NCCreate.swift in Sources */, @@ -4793,6 +4927,7 @@ AA8D316E2D4123B200FE2775 /* NCShareDownloadLimitTableViewControllerDelegate.swift in Sources */, F36C514F2E89393C0097E5F7 /* UIView+BlurVibrancy.swift in Sources */, AA8D316F2D4123B200FE2775 /* NCShareDownloadLimitTableViewController.swift in Sources */, + F78448BE2FB1C33B00F2909A /* NCVideoVLCViewController.swift in Sources */, AA8D31702D4123B200FE2775 /* DownloadLimitViewModel.swift in Sources */, AA8D31712D4123B200FE2775 /* NCShareDownloadLimitViewController.swift in Sources */, AB6000012F60000100FE2775 /* NCTagEditorModel.swift in Sources */, @@ -4804,7 +4939,6 @@ F7D368DF2DAFE19E0037E7C6 /* NCActivityNavigationController.swift in Sources */, F7A03E332D426115007AA677 /* NCMoreNavigationController.swift in Sources */, F7E402312BA891EB007E5609 /* NCTrash+SelectTabBarDelegate.swift in Sources */, - F70753EB2542A99800972D44 /* NCViewerMediaPage.swift in Sources */, F7817CF829801A3500FFBC65 /* Data+Extension.swift in Sources */, F749B651297B0F2400087535 /* NCManageDatabase+Avatar.swift in Sources */, F7FAFD3A28BFA948000777FE /* NCContextMenuNotification.swift in Sources */, @@ -4817,15 +4951,19 @@ F7CF06802E0FF3990063AD04 /* NCAppStateManager.swift in Sources */, F7BAADCB1ED5A87C00B7EAD4 /* NCManageDatabase.swift in Sources */, F79792472F5EECE100FE9544 /* Font+Extension.swift in Sources */, + F7635D8D2FB1F820007F658D /* NCVideoVLCPresenter.swift in Sources */, + F749ED312FADD62600CE8DFA /* NCMediaViewerDetailView.swift in Sources */, F768822A2C0DD1E7001CF441 /* NCSettingsModel.swift in Sources */, F737DA9D2B7B893C0063BAFC /* NCPasscode.swift in Sources */, F77C97392953131000FDDD09 /* NCCameraRoll.swift in Sources */, + F7EDBB5C2FA8DBE800098C42 /* NCMediaViewerPresenter.swift in Sources */, F7CADEFD2EA159210057849E /* NCMetadataTranfersSuccess.swift in Sources */, F343A4B32A1E01FF00DDA874 /* PHAsset+Extension.swift in Sources */, F70968A424212C4E00ED60E5 /* NCLivePhoto.swift in Sources */, F7C30DFA291BCF790017149B /* NCNetworkingE2EECreateFolder.swift in Sources */, F72CA05C2F5051DB002E2F06 /* AlertActionBannerView.swift in Sources */, F76995F42F9A4AC400291FA7 /* NCCollectionViewCommon+UIEditMenuInteractionDelegate.swift in Sources */, + F7547FE62FB76C1900E372C3 /* NCVideoControlsView.swift in Sources */, F722133B2D40EF9D002F7438 /* NCFilesNavigationController.swift in Sources */, F7BC288026663F85004D46C5 /* NCViewCertificateDetails.swift in Sources */, F78B87E92B62550800C65ADC /* NCMediaDownloadThumbnail.swift in Sources */, @@ -4840,6 +4978,7 @@ F749B64A297B0CBB00087535 /* NCManageDatabase+Share.swift in Sources */, F7C9555521F0C5470024296E /* NCActivity.swift in Sources */, F7725A60251F33BB00D125E0 /* NCFiles.swift in Sources */, + F721C50A2FB6F9AA00207DA9 /* NCCollectionViewCommon+TransitionSourceBlink.swift in Sources */, F704B5E52430AA8000632F5F /* NCCreateFormUploadConflict.swift in Sources */, F7865FF12F39D32F00D09AE4 /* NCCollectionViewCommon+Search.swift in Sources */, F7327E352B73AEDE00A462C7 /* NCNetworking+LivePhoto.swift in Sources */, @@ -4855,6 +4994,7 @@ F3DDFE0F2F15453900A784C8 /* NCAssistantChat.swift in Sources */, F7D68FCC28CB9051009139F3 /* NCManageDatabase+DashboardWidget.swift in Sources */, F76882292C0DD1E7001CF441 /* NCManageE2EEModel.swift in Sources */, + F716DA672FA5F01A006A6703 /* NCMediaViewerPagingView.swift in Sources */, F7CCAB512ECF316700F8E68B /* NCCollectionViewCommon+SyncMetadata.swift in Sources */, AA8E041D2D300FDE00E7E89C /* NCShareNetworkingDelegate.swift in Sources */, F78E2D6529AF02DB0024D4F3 /* Database.swift in Sources */, @@ -4892,6 +5032,8 @@ AA62DF602D5DF1F1009E8894 /* PHAssetCollection+Extension.swift in Sources */, F717402E24F699A5000C87D5 /* NCFavorite.swift in Sources */, AF2D7C7E2742559100ADF566 /* NCShareUserCell.swift in Sources */, + F78448B52FB1BE9000F2909A /* NCVideoViewerContentView.swift in Sources */, + F78448BA2FB1BE9000F2909A /* NCVideoPlaybackController.swift in Sources */, AF4BF614275629E20081CEEF /* NCManageDatabase+Account.swift in Sources */, F76340FA2EBDE9760056F538 /* NCManageDatabaseCore.swift in Sources */, F3E173C02C9B1067006D177A /* AwakeMode.swift in Sources */, diff --git a/iOSClient/Data/NCManageDatabase+Metadata.swift b/iOSClient/Data/NCManageDatabase+Metadata.swift index dc32ea891c..96c13a1883 100644 --- a/iOSClient/Data/NCManageDatabase+Metadata.swift +++ b/iOSClient/Data/NCManageDatabase+Metadata.swift @@ -1356,6 +1356,26 @@ extension NCManageDatabase { } ?? 0 } + /// Returns only the ocIds that still have a matching metadata row in Realm. + /// + /// - Parameter ocIds: Candidate media ocIds used by the media viewer. + /// - Returns: Valid ocIds preserving the original input order. + func getValidMetadataOcIdsAsync(_ ocIds: [String]) async -> [String] { + guard !ocIds.isEmpty else { + return [] + } + + return await core.performRealmReadAsync { realm in + let existingOcIds = Set( + realm.objects(tableMetadata.self) + .filter("ocId IN %@", ocIds) + .map(\.ocId) + ) + + return ocIds.filter { existingOcIds.contains($0) } + } ?? [] + } + func metadataExistsAsync(predicate: NSPredicate) async -> Bool { await core.performRealmReadAsync { realm in realm.objects(tableMetadata.self) diff --git a/iOSClient/Files/NCFiles.swift b/iOSClient/Files/NCFiles.swift index 68c13890b2..44675a705e 100644 --- a/iOSClient/Files/NCFiles.swift +++ b/iOSClient/Files/NCFiles.swift @@ -8,7 +8,6 @@ import RealmSwift import SwiftUI class NCFiles: NCCollectionViewCommon { - internal var fileNameBlink: String? internal var lastOffsetY: CGFloat = 0 internal var lastScrollTime: TimeInterval = 0 internal var accumulatedScrollDown: CGFloat = 0 @@ -107,11 +106,6 @@ class NCFiles: NCCollectionViewCommon { override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) - if !self.dataSource.isEmpty() { - blinkCell(fileName: self.fileNameBlink) - fileNameBlink = nil - } - Task { // Plus Menu reload await self.mainNavigationController?.menuPlus?.create(session: session) @@ -134,8 +128,6 @@ class NCFiles: NCCollectionViewCommon { override func viewDidDisappear(_ animated: Bool) { super.viewDidDisappear(animated) - - fileNameBlink = nil } // MARK: - DataSource @@ -355,31 +347,11 @@ class NCFiles: NCCollectionViewCommon { return (metadatas, error, reloadRequired) } - func blinkCell(fileName: String?) { - if let fileName = fileName, let metadata = database.getMetadata(predicate: NSPredicate(format: "account == %@ AND serverUrl == %@ AND fileName == %@", session.account, self.serverUrl, fileName)) { - let indexPath = self.dataSource.getIndexPathMetadata(ocId: metadata.ocId) - if let indexPath = indexPath { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - UIView.animate(withDuration: 0.3) { - self.collectionView.scrollToItem(at: indexPath, at: .centeredVertically, animated: false) - } completion: { _ in - if let cell = self.collectionView.cellForItem(at: indexPath) { - cell.backgroundColor = .darkGray - UIView.animate(withDuration: 2) { - cell.backgroundColor = .clear - } - } - } - } - } - } - } - func open(metadata: tableMetadata?) async { guard let metadata else { return } - await didSelectMetadata(metadata, withOcIds: false) + await didSelectMetadata(metadata, withOcIds: false, viewerTransitionSource: nil) } // MARK: - NCAccountSettingsModelDelegate diff --git a/iOSClient/Main/Collection Common/Cell/NCCellMain.swift b/iOSClient/Main/Collection Common/Cell/NCCellMain.swift index 71b9ec76c6..20826a546f 100644 --- a/iOSClient/Main/Collection Common/Cell/NCCellMain.swift +++ b/iOSClient/Main/Collection Common/Cell/NCCellMain.swift @@ -15,6 +15,7 @@ protocol NCCellMainProtocol { var infoLbl: UILabel? { get set } func selected(_ status: Bool, isEditMode: Bool, color: UIColor) + func viewerTransitionSource() -> NCViewerTransitionSource? } extension NCCellMainProtocol { @@ -38,6 +39,17 @@ extension NCCellMainProtocol { get { return nil } set {} } + + func viewerTransitionSource() -> NCViewerTransitionSource? { + guard let imageView = previewImg, + let image = imageView.image, + let window = imageView.window else { + return nil + } + let sourceFrame = imageView.convert(imageView.bounds, to: window) + + return NCViewerTransitionSource(image: image, sourceFrame: sourceFrame, cornerRadius: imageView.layer.cornerRadius) + } } #if !EXTENSION diff --git a/iOSClient/Main/Collection Common/Cell/NCRecommendationsCell.swift b/iOSClient/Main/Collection Common/Cell/NCRecommendationsCell.swift index 21d27676f7..dc15fba8aa 100644 --- a/iOSClient/Main/Collection Common/Cell/NCRecommendationsCell.swift +++ b/iOSClient/Main/Collection Common/Cell/NCRecommendationsCell.swift @@ -25,6 +25,17 @@ class NCRecommendationsCell: UICollectionViewCell, UIGestureRecognizerDelegate { } } + func viewerTransitionSource() -> NCViewerTransitionSource? { + guard let imageView = image, + let image = imageView.image, + let window = imageView.window else { + return nil + } + let sourceFrame = imageView.convert(imageView.bounds, to: window) + + return NCViewerTransitionSource(image: image, sourceFrame: sourceFrame, cornerRadius: imageView.layer.cornerRadius) + } + override func awakeFromNib() { super.awakeFromNib() diff --git a/iOSClient/Main/Collection Common/NCCollectionViewCommon+CellDelegate.swift b/iOSClient/Main/Collection Common/NCCollectionViewCommon+CellDelegate.swift index dda9b5cede..1b5390de87 100644 --- a/iOSClient/Main/Collection Common/NCCollectionViewCommon+CellDelegate.swift +++ b/iOSClient/Main/Collection Common/NCCollectionViewCommon+CellDelegate.swift @@ -22,7 +22,7 @@ extension NCCollectionViewCommon: NCListCellDelegate, NCGridCellDelegate { func tapShareListItem(with metadata: tableMetadata?, button: UIButton, sender: Any) { Task { guard let metadata else { return } - NCCreate().createShare(controller: self.controller, metadata: metadata, page: .sharing) + NCCreate().createShare(controller: self.controller, viewController: self.controller, metadata: metadata, page: .sharing) } } } diff --git a/iOSClient/Main/Collection Common/NCCollectionViewCommon+CollectionViewDelegate.swift b/iOSClient/Main/Collection Common/NCCollectionViewCommon+CollectionViewDelegate.swift index 9b9a422625..9febece15c 100644 --- a/iOSClient/Main/Collection Common/NCCollectionViewCommon+CollectionViewDelegate.swift +++ b/iOSClient/Main/Collection Common/NCCollectionViewCommon+CollectionViewDelegate.swift @@ -10,7 +10,7 @@ import LucidBanner extension NCCollectionViewCommon: UICollectionViewDelegate { @MainActor - func didSelectMetadata(_ metadata: tableMetadata, withOcIds: Bool) async { + func didSelectMetadata(_ metadata: tableMetadata, withOcIds: Bool, viewerTransitionSource: NCViewerTransitionSource?) async { let capabilities = await NKCapabilities.shared.getCapabilities(for: session.account) if metadata.e2eEncrypted { @@ -94,7 +94,7 @@ extension NCCollectionViewCommon: UICollectionViewDelegate { // --- E2EE ------- if metadata.isDirectoryE2EE { if fileExists { - if let vc = await NCViewer().getViewerController(metadata: metadata, delegate: self) { + if let vc = await NCViewer().getViewerController(metadata: metadata, delegate: self, viewerTransitionSource: viewerTransitionSource) { self.navigationController?.pushViewController(vc, animated: true) } } else { @@ -110,11 +110,11 @@ extension NCCollectionViewCommon: UICollectionViewDelegate { $0.classFile == NKTypeClassFile.video.rawValue || $0.classFile == NKTypeClassFile.audio.rawValue }.map(\.ocId) - if let vc = await NCViewer().getViewerController(metadata: metadata, ocIds: withOcIds ? ocIds : nil, image: image, delegate: self) { + if let vc = await NCViewer().getViewerController(metadata: metadata, ocIds: withOcIds ? ocIds : nil, image: image, delegate: self, viewerTransitionSource: viewerTransitionSource) { self.navigationController?.pushViewController(vc, animated: true) } } else if !metadata.isDirectoryE2EE, metadata.isAvailableEditorView || utilityFileSystem.fileProviderStorageExists(metadata) || metadata.name == self.global.talkName { - if let vc = await NCViewer().getViewerController(metadata: metadata, image: image, delegate: self) { + if let vc = await NCViewer().getViewerController(metadata: metadata, image: image, delegate: self, viewerTransitionSource: viewerTransitionSource) { self.navigationController?.pushViewController(vc, animated: true) } } else if NextcloudKit.shared.isNetworkReachable() { @@ -128,7 +128,7 @@ extension NCCollectionViewCommon: UICollectionViewDelegate { if metadata.name == "files" { await downloadFile() } else if !metadata.url.isEmpty, - let vc = await NCViewer().getViewerController(metadata: metadata, delegate: self) { + let vc = await NCViewer().getViewerController(metadata: metadata, delegate: self, viewerTransitionSource: viewerTransitionSource) { self.navigationController?.pushViewController(vc, animated: true) } } else { @@ -141,6 +141,7 @@ extension NCCollectionViewCommon: UICollectionViewDelegate { guard let metadata = self.dataSource.getMetadata(indexPath: indexPath) else { return } + var viewerTransitionSource: NCViewerTransitionSource? if self.isEditMode { if let index = self.fileSelect.firstIndex(of: metadata.ocId) { @@ -154,8 +155,12 @@ extension NCCollectionViewCommon: UICollectionViewDelegate { return } + if let cell = collectionView.cellForItem(at: indexPath) as? NCCellMainProtocol { + viewerTransitionSource = cell.viewerTransitionSource() + } + Task { - await didSelectMetadata(metadata, withOcIds: true) + await didSelectMetadata(metadata, withOcIds: true, viewerTransitionSource: viewerTransitionSource) } } diff --git a/iOSClient/Main/Collection Common/NCCollectionViewCommon+TransitionSourceBlink.swift b/iOSClient/Main/Collection Common/NCCollectionViewCommon+TransitionSourceBlink.swift new file mode 100644 index 0000000000..eba1594c0c --- /dev/null +++ b/iOSClient/Main/Collection Common/NCCollectionViewCommon+TransitionSourceBlink.swift @@ -0,0 +1,123 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Marino Faggiana +// SPDX-License-Identifier: GPL-3.0-or-later + +import Foundation +import UIKit + +extension NCCollectionViewCommon { + /// Returns the transition source for a media item in the collection view. + /// + /// If the target cell is visible, the transition uses the real preview image view frame. + /// If the target cell is not materialized yet, the transition falls back to the + /// collection view layout attributes so the closing animation can still target + /// the correct item position. + /// + /// - Parameter ocId: Nextcloud file identifier of the media item. + /// - Returns: Transition source if the item can be resolved. + func viewerTransitionSource(for ocId: String) -> NCViewerTransitionSource? { + guard let indexPath = dataSource.getIndexPathMetadata(ocId: ocId), + let window = collectionView.window else { + return nil + } + + collectionView.layoutIfNeeded() + + if collectionView.cellForItem(at: indexPath) == nil { + collectionView.scrollToItem( + at: indexPath, + at: .centeredVertically, + animated: false + ) + + collectionView.layoutIfNeeded() + } + + if let cell = collectionView.cellForItem(at: indexPath) as? NCCellMainProtocol, + let imageView = cell.previewImg, + let image = imageView.image { + let sourceFrame = imageView.convert( + imageView.bounds, + to: window + ) + + return NCViewerTransitionSource( + image: image, + sourceFrame: sourceFrame, + cornerRadius: imageView.layer.cornerRadius + ) + } + + guard let attributes = collectionView.layoutAttributesForItem(at: indexPath) else { + return nil + } + + let sourceFrame = collectionView.convert( + attributes.frame, + to: window + ) + + return NCViewerTransitionSource( + image: UIImage(), + sourceFrame: sourceFrame, + cornerRadius: 6 + ) + } + + /// Briefly highlights the collection view cell associated with the given ocId. + /// + /// If the target item is not currently visible, the collection view scrolls to it first. + /// The highlight is intentionally lightweight and temporary. + @MainActor + func blinkItem(ocId: String) { + guard let indexPath = dataSource.getIndexPathMetadata(ocId: ocId) else { + return + } + + collectionView.layoutIfNeeded() + + if collectionView.cellForItem(at: indexPath) == nil { + collectionView.scrollToItem( + at: indexPath, + at: .centeredVertically, + animated: false + ) + + view.layoutIfNeeded() + collectionView.layoutIfNeeded() + } + + guard let cell = collectionView.cellForItem(at: indexPath) else { + return + } + + blink(view: cell.contentView) + } + + /// Applies a short blink animation to the provided view. + /// + /// - Parameter view: View that should be visually highlighted. + private func blink(view: UIView) { + let overlay = UIView(frame: view.bounds) + overlay.backgroundColor = UIColor.systemBlue.withAlphaComponent(0.22) + overlay.layer.cornerRadius = view.layer.cornerRadius + overlay.isUserInteractionEnabled = false + overlay.autoresizingMask = [ + .flexibleWidth, + .flexibleHeight + ] + + view.addSubview(overlay) + + UIView.animate( + withDuration: 0.4, + delay: 0, + options: [.curveEaseInOut] + ) { + overlay.alpha = 0.0 + } completion: { _ in + overlay.removeFromSuperview() + } + } + +} diff --git a/iOSClient/Main/Collection Common/NCCollectionViewCommon.swift b/iOSClient/Main/Collection Common/NCCollectionViewCommon.swift index 7db4928ad4..5368d0ca3b 100644 --- a/iOSClient/Main/Collection Common/NCCollectionViewCommon.swift +++ b/iOSClient/Main/Collection Common/NCCollectionViewCommon.swift @@ -787,9 +787,9 @@ extension NCCollectionViewCommon: NCSectionFirstHeaderDelegate { } } - func tapRecommendations(with metadata: tableMetadata) { + func tapRecommendations(with metadata: tableMetadata, viewerTransitionSource: NCViewerTransitionSource?) { Task { - await didSelectMetadata(metadata, withOcIds: false) + await didSelectMetadata(metadata, withOcIds: false, viewerTransitionSource: viewerTransitionSource) } } } diff --git a/iOSClient/Main/Collection Common/Section Header Footer/NCSectionFirstHeader.swift b/iOSClient/Main/Collection Common/Section Header Footer/NCSectionFirstHeader.swift index 7272cffaea..2c34123719 100644 --- a/iOSClient/Main/Collection Common/Section Header Footer/NCSectionFirstHeader.swift +++ b/iOSClient/Main/Collection Common/Section Header Footer/NCSectionFirstHeader.swift @@ -8,7 +8,7 @@ import NextcloudKit protocol NCSectionFirstHeaderDelegate: AnyObject { func tapRichWorkspace(_ sender: Any) - func tapRecommendations(with metadata: tableMetadata) + func tapRecommendations(with metadata: tableMetadata, viewerTransitionSource: NCViewerTransitionSource?) } class NCSectionFirstHeader: UICollectionReusableView, UIGestureRecognizerDelegate { @@ -232,11 +232,13 @@ extension NCSectionFirstHeader: UICollectionViewDataSource { extension NCSectionFirstHeader: UICollectionViewDelegate { func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { let recommendedFiles = self.recommendations[indexPath.row] - guard let metadata = NCManageDatabase.shared.getMetadataFromFileId(recommendedFiles.id) else { + guard let metadata = NCManageDatabase.shared.getMetadataFromFileId(recommendedFiles.id), + let cell = collectionView.cellForItem(at: indexPath) as? NCRecommendationsCell else { return } + let viewerTransitionSource = cell.viewerTransitionSource() - self.delegate?.tapRecommendations(with: metadata) + self.delegate?.tapRecommendations(with: metadata, viewerTransitionSource: viewerTransitionSource) } func collectionView(_ collectionView: UICollectionView, contextMenuConfigurationForItemAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? { diff --git a/iOSClient/Main/Create/NCCreate.swift b/iOSClient/Main/Create/NCCreate.swift index 5db2c8818e..d91319d156 100644 --- a/iOSClient/Main/Create/NCCreate.swift +++ b/iOSClient/Main/Create/NCCreate.swift @@ -49,7 +49,7 @@ class NCCreate: NSObject { url: url, session: session, sceneIdentifier: controller.sceneIdentifier) - if let vc = await NCViewer().getViewerController(metadata: metadata, delegate: viewController) { + if let vc = await NCViewer().getViewerController(metadata: metadata, delegate: viewController, viewerTransitionSource: nil) { viewController.navigationController?.pushViewController(vc, animated: true) } @@ -79,7 +79,7 @@ class NCCreate: NSObject { session: session, sceneIdentifier: controller.sceneIdentifier) - if let vc = await NCViewer().getViewerController(metadata: metadata, delegate: viewController) { + if let vc = await NCViewer().getViewerController(metadata: metadata, delegate: viewController, viewerTransitionSource: nil) { viewController.navigationController?.pushViewController(vc, animated: true) } } @@ -157,7 +157,7 @@ class NCCreate: NSObject { return (templates, selectedTemplate, ext) } - func createShare(controller: NCMainTabBarController?, metadata: tableMetadata, page: NCBrandOptions.NCInfoPagingTab) { + func createShare(controller: NCMainTabBarController?, viewController: UIViewController?, metadata: tableMetadata, page: NCBrandOptions.NCInfoPagingTab) { guard let controller else { return } @@ -211,7 +211,7 @@ class NCCreate: NSObject { shareNavigationController?.modalPresentationStyle = .formSheet if let shareNavigationController = shareNavigationController { - controller.present(shareNavigationController, animated: true, completion: nil) + viewController?.present(shareNavigationController, animated: true, completion: nil) } } } diff --git a/iOSClient/Main/NCMainNavigationController.swift b/iOSClient/Main/NCMainNavigationController.swift index ccce146116..86d9da34bc 100644 --- a/iOSClient/Main/NCMainNavigationController.swift +++ b/iOSClient/Main/NCMainNavigationController.swift @@ -292,7 +292,6 @@ class NCMainNavigationController: UINavigationController, UINavigationController guard !(collectionViewCommon?.isEditMode ?? false), !(trashViewController?.isEditMode ?? false), !(mediaViewController?.isEditMode ?? false), - !(topViewController is NCViewerMediaPage), !(topViewController is NCViewerPDF), !(topViewController is NCViewerRichDocument), !(topViewController is NCViewerDirectEditing) diff --git a/iOSClient/Main/NCPickerViewController.swift b/iOSClient/Main/NCPickerViewController.swift index eb05a89255..8245cdb69c 100644 --- a/iOSClient/Main/NCPickerViewController.swift +++ b/iOSClient/Main/NCPickerViewController.swift @@ -166,7 +166,7 @@ class NCDocumentPickerViewController: NSObject, UIDocumentPickerDelegate { await UIAlertController.warningAsync( message: message, presenter: self.controller) } else { if let metadata = await database.addAndReturnMetadataAsync(metadata), - let vc = await NCViewer().getViewerController(metadata: metadata, delegate: viewController) { + let vc = await NCViewer().getViewerController(metadata: metadata, delegate: viewController, viewerTransitionSource: nil) { viewController.navigationController?.pushViewController(vc, animated: true) } } diff --git a/iOSClient/Media/NCMedia+CollectionViewDelegate.swift b/iOSClient/Media/NCMedia+CollectionViewDelegate.swift index fde3168ff7..f9be4e9ffe 100644 --- a/iOSClient/Media/NCMedia+CollectionViewDelegate.swift +++ b/iOSClient/Media/NCMedia+CollectionViewDelegate.swift @@ -23,15 +23,82 @@ extension NCMedia: UICollectionViewDelegate { tabBarSelect.selectCount = fileSelect.count } else if let metadata = await self.database.getMetadataFromOcIdAsync(metadata.ocId) { let image = utility.getImage(ocId: metadata.ocId, etag: metadata.etag, ext: global.previewExt1024, userId: metadata.userId, urlBase: metadata.urlBase) + var viewerTransitionSource: NCViewerTransitionSource? let ocIds = dataSource.metadatas.map { $0.ocId } - if let vc = await NCViewer().getViewerController(metadata: metadata, ocIds: ocIds, image: image, delegate: self) { - self.navigationController?.pushViewController(vc, animated: true) + if let imageView = cell.imageItem, + let image = imageView.image, + let window = imageView.window { + let sourceFrame = imageView.convert(imageView.bounds, to: window) + viewerTransitionSource = NCViewerTransitionSource(image: image, sourceFrame: sourceFrame, cornerRadius: imageView.layer.cornerRadius) + } + + if let vc = await NCViewer().getViewerController(metadata: metadata, ocIds: ocIds, image: image, delegate: self, viewerTransitionSource: viewerTransitionSource) { + vc.view.backgroundColor = .clear + self.navigationController?.pushViewController(vc, animated: false) } } } } + /// Returns the transition source for a media item in the collection view. + /// + /// If the target cell is visible, the transition uses the real preview image view frame. + /// If the target cell is not materialized yet, the transition falls back to the + /// collection view layout attributes so the closing animation can still target + /// the correct item position. + /// + /// - Parameter ocId: Nextcloud file identifier of the media item. + /// - Returns: Transition source if the item can be resolved. + func viewerTransitionSource(for ocId: String) -> NCViewerTransitionSource? { + guard let indexPath = self.dataSource.indexPath(forOcId: ocId), + let window = collectionView.window else { + return nil + } + + collectionView.layoutIfNeeded() + + if collectionView.cellForItem(at: indexPath) == nil { + collectionView.scrollToItem( + at: indexPath, + at: .centeredVertically, + animated: false + ) + + collectionView.layoutIfNeeded() + } + + if let cell = collectionView.cellForItem(at: indexPath) as? NCMediaCell, + let imageView = cell.imageItem, + let image = imageView.image { + let sourceFrame = imageView.convert( + imageView.bounds, + to: window + ) + + return NCViewerTransitionSource( + image: image, + sourceFrame: sourceFrame, + cornerRadius: imageView.layer.cornerRadius + ) + } + + guard let attributes = collectionView.layoutAttributesForItem(at: indexPath) else { + return nil + } + + let sourceFrame = collectionView.convert( + attributes.frame, + to: window + ) + + return NCViewerTransitionSource( + image: UIImage(), + sourceFrame: sourceFrame, + cornerRadius: 6 + ) + } + func collectionView(_ collectionView: UICollectionView, contextMenuConfigurationForItemAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? { guard let ocId = dataSource.getMetadata(indexPath: indexPath)?.ocId, let metadata = database.getMetadataFromOcId(ocId) diff --git a/iOSClient/Media/NCMediaNavigationController.swift b/iOSClient/Media/NCMediaNavigationController.swift index c517a91508..ee7ac04c20 100644 --- a/iOSClient/Media/NCMediaNavigationController.swift +++ b/iOSClient/Media/NCMediaNavigationController.swift @@ -159,7 +159,7 @@ class NCMediaNavigationController: NCMainNavigationController { sceneIdentifier: self.controller?.sceneIdentifier) await self.database.addMetadataAsync(metadata) - if let vc = await NCViewer().getViewerController(metadata: metadata, delegate: self) { + if let vc = await NCViewer().getViewerController(metadata: metadata, delegate: self, viewerTransitionSource: nil) { self.navigationController?.pushViewController(vc, animated: true) } } diff --git a/iOSClient/Menu/NCContextMenuMain.swift b/iOSClient/Menu/NCContextMenuMain.swift index 2926e69636..09d24d27a9 100644 --- a/iOSClient/Menu/NCContextMenuMain.swift +++ b/iOSClient/Menu/NCContextMenuMain.swift @@ -9,6 +9,7 @@ import Alamofire import NextcloudKit import LucidBanner +/// A context menu used in ``NCCollectionViewCommon`` and ``NCMedia`` /// A context menu used in ``NCCollectionViewCommon`` and ``NCMedia`` /// See ``NCCollectionViewCommon/collectionView(_:contextMenuConfigurationForItemAt:point:)``, /// ``NCCollectionViewCommon/openContextMenu(with:button:sender:)``, ``NCMedia/collectionView(_:contextMenuConfigurationForItemAt:point:)`` for usage details. @@ -95,6 +96,7 @@ class NCContextMenuMain: NSObject { image: utility.loadImage(named: "info.circle.fill") ) { _ in NCCreate().createShare(controller: self.controller, + viewController: self.controller, metadata: metadata, page: .activity) } diff --git a/iOSClient/Menu/NCContextMenuPlayerTracks.swift b/iOSClient/Menu/NCContextMenuPlayerTracks.swift deleted file mode 100644 index 43ecdb2598..0000000000 --- a/iOSClient/Menu/NCContextMenuPlayerTracks.swift +++ /dev/null @@ -1,148 +0,0 @@ -// SPDX-FileCopyrightText: Nextcloud GmbH -// SPDX-FileCopyrightText: 2026 Milen Pivchev -// SPDX-License-Identifier: GPL-3.0-or-later - -import UIKit -import NextcloudKit -import MobileVLCKit - -/// A context menu for video player track selection (subtitles, audio tracks). -/// See ``NCPlayerToolBar`` for usage details. -class NCContextMenuPlayerTracks: NSObject { - enum TrackType { - case subtitle - case audio - } - - let trackType: TrackType - let currentIndex: Int? - let ncplayer: NCPlayer? - let metadata: tableMetadata? - let viewerMediaPage: NCViewerMediaPage? - private let database = NCManageDatabase.shared - - init(trackType: TrackType, - tracks: [Any], - trackIndexes: [Any], - currentIndex: Int?, - ncplayer: NCPlayer?, - metadata: tableMetadata?, - viewerMediaPage: NCViewerMediaPage?) { - self.trackType = trackType - self.currentIndex = currentIndex - self.ncplayer = ncplayer - self.metadata = metadata - self.viewerMediaPage = viewerMediaPage - } - - func viewMenu() -> UIMenu { - var children: [UIMenuElement] = [] - - // Add track action - switch self.trackType { - case .subtitle: - let deferredElement = UIDeferredMenuElement.uncached { [self] completion in - guard let player = ncplayer?.player else { return completion([]) } - let spuTracks = player.videoSubTitlesNames - let spuTrackIndexes = player.videoSubTitlesIndexes - - var actions = [UIAction]() - var subTitleIndex: Int? - - if let data = self.database.getVideo(metadata: metadata), let idx = data.currentVideoSubTitleIndex { - subTitleIndex = idx - } else if let idx = ncplayer?.player.currentVideoSubTitleIndex { - subTitleIndex = Int(idx) - } - - if !spuTracks.isEmpty { - for index in 0...spuTracks.count - 1 { - guard let title = spuTracks[index] as? String, let idx = spuTrackIndexes[index] as? Int32 else { return } - - let action = makeTrackAction(title: title, index: idx, isSelected: (subTitleIndex ?? -9999) == idx) - actions.append(action) - } - } - - completion(actions) - } - - children.append(deferredElement) - case .audio: - let deferredElement = UIDeferredMenuElement.uncached { [self] completion in - guard let player = ncplayer?.player else { return completion([]) } - let audioTracks = player.audioTrackNames - let audioTrackIndexes = player.audioTrackIndexes - - var actions = [UIAction]() - var audioIndex: Int? - - if let data = self.database.getVideo(metadata: metadata), let idx = data.currentAudioTrackIndex { - audioIndex = idx - } else if let idx = ncplayer?.player.currentAudioTrackIndex { - audioIndex = Int(idx) - } - - if !audioTracks.isEmpty { - for index in 0...audioTracks.count - 1 { - guard let title = audioTracks[index] as? String, let idx = audioTrackIndexes[index] as? Int32 else { return } - - let action = makeTrackAction(title: title, index: idx, isSelected: (audioIndex ?? -9999) == idx) - actions.append(action) - } - } - - completion(actions) - } - - children.append(deferredElement) - } - - children.append(makeAddTrackAction()) - - return UIMenu(title: "", children: children) - } - - private func makeTrackAction(title: String, index: Int32, isSelected: Bool) -> UIAction { - UIAction( - title: title, - state: isSelected ? .on : .off - ) { _ in - guard let metadata = self.metadata else { return } - - switch self.trackType { - case .subtitle: - self.ncplayer?.player.currentVideoSubTitleIndex = index - self.database.addVideo(metadata: metadata, currentVideoSubTitleIndex: Int(index)) - case .audio: - self.ncplayer?.player.currentAudioTrackIndex = index - self.database.addVideo(metadata: metadata, currentAudioTrackIndex: Int(index)) - } - } - } - - private func makeAddTrackAction() -> UIAction { - let title = trackType == .subtitle - ? NSLocalizedString("_add_subtitle_", comment: "") - : NSLocalizedString("_add_audio_", comment: "") - - return UIAction(title: title) { _ in - guard let metadata = self.metadata else { return } - let storyboard = UIStoryboard(name: "NCSelect", bundle: nil) - if let navigationController = storyboard.instantiateInitialViewController() as? UINavigationController, - let viewController = navigationController.topViewController as? NCSelect { - - viewController.delegate = self.viewerMediaPage?.currentViewController.playerToolBar - viewController.typeOfCommandView = .nothing - viewController.includeDirectoryE2EEncryption = false - viewController.enableSelectFile = true - viewController.type = self.trackType == .subtitle ? "subtitle" : "audio" - viewController.serverUrl = metadata.serverUrl - viewController.session = NCSession.shared.getSession(account: metadata.account) - viewController.controller = nil - - self.viewerMediaPage?.present(navigationController, animated: true, completion: nil) - } - } - } -} diff --git a/iOSClient/Menu/NCContextMenuViewer.swift b/iOSClient/Menu/NCContextMenuViewer.swift index 460574ee8c..2d96bc0589 100644 --- a/iOSClient/Menu/NCContextMenuViewer.swift +++ b/iOSClient/Menu/NCContextMenuViewer.swift @@ -11,6 +11,7 @@ import NextcloudKit class NCContextMenuViewer: NSObject { let metadata: tableMetadata let controller: NCMainTabBarController? + let viewController: UIViewController? let webView: Bool let sender: Any? private let database = NCManageDatabase.shared @@ -20,9 +21,14 @@ class NCContextMenuViewer: NSObject { SceneManager.shared.getWindowScene(controller: controller) } - init(metadata: tableMetadata, controller: NCMainTabBarController?, webView: Bool, sender: Any?) { + init(metadata: tableMetadata, + controller: NCMainTabBarController?, + viewController: UIViewController?, + webView: Bool, + sender: Any?) { self.metadata = metadata self.controller = controller + self.viewController = viewController self.webView = webView self.sender = sender } @@ -40,12 +46,12 @@ class NCContextMenuViewer: NSObject { // DETAIL if !(!capabilities.fileSharingApiEnabled && !capabilities.filesComments && capabilities.activity.isEmpty) { - menuElements.append(makeDetailAction(metadata: metadata, controller: controller)) + menuElements.append(makeDetailAction(metadata: metadata, controller: controller, viewController: viewController)) } // VIEW IN FOLDER if !webView { - menuElements.append(makeViewInFolderAction(metadata: metadata, controller: controller)) + menuElements.append(makeViewInFolderAction(metadata: metadata, controller: controller, viewController: viewController)) } // FAVORITE @@ -85,26 +91,42 @@ class NCContextMenuViewer: NSObject { // MARK: - Private Action Makers - private func makeDetailAction(metadata: tableMetadata, controller: NCMainTabBarController) -> UIAction { + private func makeDetailAction(metadata: tableMetadata, controller: NCMainTabBarController, viewController: UIViewController?) -> UIAction { UIAction( title: NSLocalizedString("_details_", comment: ""), image: UIImage(systemName: "info") ) { _ in NCCreate().createShare(controller: controller, + viewController: viewController, metadata: metadata, page: .activity) } } - private func makeViewInFolderAction(metadata: tableMetadata, controller: NCMainTabBarController) -> UIAction { + private func makeViewInFolderAction(metadata: tableMetadata, controller: NCMainTabBarController, viewController: UIViewController?) -> UIAction { UIAction( title: NSLocalizedString("_view_in_folder_", comment: ""), image: UIImage(systemName: "questionmark.folder") ) { _ in Task { - await NCNetworking.shared.blinkInFolder(serverUrl: metadata.serverUrl, - fileName: metadata.fileName, - sceneIdentifier: controller.sceneIdentifier) + if let files = await NCNetworking.shared.moveInFolder(serverUrl: metadata.serverUrl, + sceneIdentifier: controller.sceneIdentifier) { + + files.loadViewIfNeeded() + files.view.layoutIfNeeded() + files.collectionView.layoutIfNeeded() + + if let mediaViewer = viewController as? NCMediaViewerHostingController { + mediaViewer.close() + } else if let mediaViewer = viewController as? NCVideoVLCViewController { + mediaViewer.closeImmediately() + } else if let mediaViewer = viewController as? NCVideoAVPlayerViewController { + mediaViewer.closeImmediately() + } + + try? await Task.sleep(for: .seconds(0.6)) + files.blinkItem(ocId: metadata.ocId) + } } } } diff --git a/iOSClient/NCGlobal.swift b/iOSClient/NCGlobal.swift index 2e6ad38aee..1bb3c80bcc 100644 --- a/iOSClient/NCGlobal.swift +++ b/iOSClient/NCGlobal.swift @@ -403,6 +403,7 @@ final class NCGlobal: Sendable { let logTagSpeedUpSyncMetadata = "SYNC METADATA" let logTagNetworkingTasks = "NETWORKING TASKS" let logTagMetadataTransfers = "METADATA TRANSFERS" + let logTagViewer = "VIEWERS" // USER DEFAULTS // diff --git a/iOSClient/Networking/NCNetworking+Recommendations.swift b/iOSClient/Networking/NCNetworking+Recommendations.swift index 9750e6aec4..fae0d04b05 100644 --- a/iOSClient/Networking/NCNetworking+Recommendations.swift +++ b/iOSClient/Networking/NCNetworking+Recommendations.swift @@ -39,7 +39,9 @@ extension NCNetworking { if results.error == .success, let file = results.files?.first { let metadata = await NCManageDatabaseCreateMetadata().convertFileToMetadataAsync(file) - await NCManageDatabase.shared.addMetadataAsync(metadata) + if await NCManageDatabase.shared.getMetadataFromOcIdAsync(metadata.ocId) == nil { + await NCManageDatabase.shared.addMetadataAsync(metadata) + } if metadata.isLivePhoto, metadata.isVideo { continue diff --git a/iOSClient/Networking/NCNetworking+TransferDelegate.swift b/iOSClient/Networking/NCNetworking+TransferDelegate.swift index 82362b7324..d07d8bca52 100644 --- a/iOSClient/Networking/NCNetworking+TransferDelegate.swift +++ b/iOSClient/Networking/NCNetworking+TransferDelegate.swift @@ -95,7 +95,7 @@ extension NCNetworking: NCTransferDelegate { if let viewController = controller.currentViewController() { let image = NCUtility().getImage(ocId: metadata.ocId, etag: metadata.etag, ext: NCGlobal.shared.previewExt1024, userId: metadata.userId, urlBase: metadata.urlBase) Task { - if let vc = await NCViewer().getViewerController(metadata: metadata, image: image, delegate: viewController) { + if let vc = await NCViewer().getViewerController(metadata: metadata, image: image, delegate: viewController, viewerTransitionSource: nil) { viewController.navigationController?.pushViewController(vc, animated: true) } } @@ -215,7 +215,7 @@ extension NCNetworking: NCTransferDelegate { ) let fileSize = attr[FileAttributeKey.size] as? UInt64 ?? 0 if fileSize > 0 { - if let vc = await NCViewer().getViewerController(metadata: metadata, delegate: viewController) { + if let vc = await NCViewer().getViewerController(metadata: metadata, delegate: viewController, viewerTransitionSource: nil) { viewController.navigationController?.pushViewController(vc, animated: true) } return @@ -255,7 +255,7 @@ extension NCNetworking: NCTransferDelegate { ) if metadata.isAudioOrVideo { - if let vc = await NCViewer().getViewerController(metadata: metadata, delegate: viewController) { + if let vc = await NCViewer().getViewerController(metadata: metadata, delegate: viewController, viewerTransitionSource: nil) { viewController.navigationController?.pushViewController(vc, animated: true) } return @@ -290,7 +290,7 @@ extension NCNetworking: NCTransferDelegate { if download.nkError == .success { await NCManageDatabase.shared.addLocalFilesAsync(metadatas: [metadata]) - if let vc = await NCViewer().getViewerController(metadata: metadata, delegate: viewController) { + if let vc = await NCViewer().getViewerController(metadata: metadata, delegate: viewController, viewerTransitionSource: nil) { viewController.navigationController?.pushViewController(vc, animated: true) } } @@ -313,52 +313,71 @@ extension NCNetworking: NCTransferDelegate { } @MainActor - func blinkInFolder(serverUrl: String, - fileName: String, - sceneIdentifier: String) async { + func moveInFolder(serverUrl: String, sceneIdentifier: String) async -> NCFiles? { guard let controller = SceneManager.shared.getController(sceneIdentifier: sceneIdentifier), let navigationController = controller.viewControllers?.first as? UINavigationController - else { return } + else { + return nil + } + let session = NCSession.shared.getSession(controller: controller) - var serverUrlPush = self.utilityFileSystem.getHomeServer(session: session) + var serverUrlPush = utilityFileSystem.getHomeServer(session: session) navigationController.popToRootViewController(animated: false) controller.selectedIndex = 0 + if serverUrlPush == serverUrl, - let viewController = navigationController.topViewController as? NCFiles { - Task { - viewController.blinkCell(fileName: fileName) - } - return + let files = navigationController.topViewController as? NCFiles { + return files + } + + guard serverUrl.hasPrefix(serverUrlPush) else { + return nil } - let diffDirectory = serverUrl.replacingOccurrences(of: serverUrlPush, with: "") + let diffDirectory = String(serverUrl.dropFirst(serverUrlPush.count)) var subDirs = diffDirectory.split(separator: "/") + var lastFilesViewController: NCFiles? + while serverUrlPush != serverUrl, !subDirs.isEmpty { - guard let dir = subDirs.first else { - return - } - serverUrlPush = self.utilityFileSystem.createServerUrl(serverUrl: serverUrlPush, fileName: String(dir)) + let dir = String(subDirs.removeFirst()) + + serverUrlPush = utilityFileSystem.createServerUrl( + serverUrl: serverUrlPush, + fileName: dir + ) + + if let viewController = controller.navigationCollectionViewCommon.first(where: { + $0.navigationController == navigationController && + $0.serverUrl == serverUrlPush + })?.viewController as? NCFiles { - if let viewController = controller.navigationCollectionViewCommon.first(where: { $0.navigationController == navigationController && $0.serverUrl == serverUrlPush})?.viewController as? NCFiles, viewController.isViewLoaded { - viewController.fileNameBlink = fileName navigationController.pushViewController(viewController, animated: false) - } else { - if let viewController: NCFiles = UIStoryboard(name: "NCFiles", bundle: nil).instantiateInitialViewController() as? NCFiles { - viewController.serverUrl = serverUrlPush - viewController.titleCurrentFolder = String(dir) - viewController.navigationItem.backButtonTitle = viewController.titleCurrentFolder + lastFilesViewController = viewController - controller.navigationCollectionViewCommon.append(NavigationCollectionViewCommon(serverUrl: serverUrlPush, navigationController: navigationController, viewController: viewController)) + } else if let viewController = UIStoryboard(name: "NCFiles", bundle: nil).instantiateInitialViewController() as? NCFiles { - if serverUrlPush == serverUrl { - viewController.fileNameBlink = fileName - } - navigationController.pushViewController(viewController, animated: false) - } + viewController.serverUrl = serverUrlPush + viewController.titleCurrentFolder = dir + viewController.navigationItem.backButtonTitle = dir + + controller.navigationCollectionViewCommon.append( + NavigationCollectionViewCommon( + serverUrl: serverUrlPush, + navigationController: navigationController, + viewController: viewController + ) + ) + + navigationController.pushViewController(viewController, animated: false) + lastFilesViewController = viewController + + } else { + return nil } - subDirs.remove(at: 0) } + + return serverUrlPush == serverUrl ? lastFilesViewController : nil } } diff --git a/iOSClient/RichWorkspace/NCViewerRichWorkspaceWebView.swift b/iOSClient/RichWorkspace/NCViewerRichWorkspaceWebView.swift index 70d5358c0a..df0114b962 100644 --- a/iOSClient/RichWorkspace/NCViewerRichWorkspaceWebView.swift +++ b/iOSClient/RichWorkspace/NCViewerRichWorkspaceWebView.swift @@ -72,6 +72,7 @@ class NCViewerRichWorkspaceWebView: UIViewController, WKNavigationDelegate, WKSc if message.body as? String == "share", metadata != nil { NCCreate().createShare(controller: self.controller, + viewController: self.controller, metadata: metadata!, page: .sharing) } diff --git a/iOSClient/Select/NCSelect.swift b/iOSClient/Select/NCSelect.swift index 775de939ab..209ba3d4a5 100644 --- a/iOSClient/Select/NCSelect.swift +++ b/iOSClient/Select/NCSelect.swift @@ -292,7 +292,7 @@ class NCSelect: UIViewController, UIGestureRecognizerDelegate, UIAdaptivePresent } func tapRichWorkspace(_ sender: Any) { } - func tapRecommendations(with metadata: tableMetadata) { } + func tapRecommendations(with metadata: tableMetadata, viewerTransitionSource: NCViewerTransitionSource?) { } // MARK: - Push metadata diff --git a/iOSClient/Utility/NCUtilityFileSystem.swift b/iOSClient/Utility/NCUtilityFileSystem.swift index 7073605d7e..be71855854 100644 --- a/iOSClient/Utility/NCUtilityFileSystem.swift +++ b/iOSClient/Utility/NCUtilityFileSystem.swift @@ -849,4 +849,20 @@ final class NCUtilityFileSystem: NSObject, @unchecked Sendable { let parent = url.deletingLastPathComponent().lastPathComponent return parent == "f" ? id : nil } + + /// Extracts the numeric fileId prefix from a Nextcloud ocId. + /// + /// - Parameter ocId: Nextcloud ocId, usually composed by a numeric fileId prefix and an instance suffix. + /// - Returns: Numeric fileId string if available. + func extractFileId(from ocId: String) -> String? { + let prefix = ocId.prefix { character in + character.isNumber + } + + guard !prefix.isEmpty else { + return nil + } + + return String(Int(prefix) ?? 0) + } } diff --git a/iOSClient/Viewer/NCViewer.swift b/iOSClient/Viewer/NCViewer.swift index 353787937d..c503d50c55 100644 --- a/iOSClient/Viewer/NCViewer.swift +++ b/iOSClient/Viewer/NCViewer.swift @@ -5,6 +5,7 @@ import UIKit import NextcloudKit import QuickLook +import SwiftUI class NCViewer: NSObject { let utilityFileSystem = NCUtilityFileSystem() @@ -13,7 +14,7 @@ class NCViewer: NSObject { private var viewerQuickLook: NCViewerQuickLook? @MainActor - func getViewerController(metadata: tableMetadata, ocIds: [String]? = nil, image: UIImage? = nil, delegate: UIViewController? = nil) async -> UIViewController? { + func getViewerController(metadata: tableMetadata, ocIds: [String]? = nil, image: UIImage? = nil, delegate: UIViewController? = nil, viewerTransitionSource: NCViewerTransitionSource?) async -> UIViewController? { let session = NCSession.shared.getSession(account: metadata.account) // Set Last Opening Date await self.database.setLocalFileLastOpeningDateAsync(metadata: metadata) @@ -41,18 +42,26 @@ class NCViewer: NSObject { // IMAGE AUDIO VIDEO else if metadata.isImage || metadata.isAudioOrVideo { - let viewerMediaPageContainer = UIStoryboard(name: "NCViewerMediaPage", bundle: nil).instantiateInitialViewController() as? NCViewerMediaPage - - viewerMediaPageContainer?.delegateViewController = delegate - if let ocIds { - viewerMediaPageContainer?.currentIndex = ocIds.firstIndex(where: { $0 == metadata.ocId }) ?? 0 - viewerMediaPageContainer?.ocIds = ocIds - } else { - viewerMediaPageContainer?.currentIndex = 0 - viewerMediaPageContainer?.ocIds = [metadata.ocId] - } - - return viewerMediaPageContainer + let mediaOcIds = ocIds ?? [metadata.ocId] + let mediaSearch = delegate is NCMedia + let model = NCMediaViewerModel(currentMetadata: metadata, ocIds: mediaOcIds, session: session, mediaSearch: mediaSearch, loader: NCMediaViewerLoader()) + + NCMediaViewerPresenter.shared.show( + model: model, + viewerTransitionSource: viewerTransitionSource, + from: delegate?.view, + contextMenuController: delegate?.tabBarController as? NCMainTabBarController, + closingTransitionSourceProvider: { ocId in + if let provider = delegate as? NCCollectionViewCommon { + return provider.viewerTransitionSource(for: ocId) + } else if let provider = delegate as? NCMedia { + return provider.viewerTransitionSource(for: ocId) + } else { + return nil + } + } + ) + return nil } // DOCUMENTS diff --git a/iOSClient/Viewer/NCViewerDirectEditing/NCViewerDirectEditing.swift b/iOSClient/Viewer/NCViewerDirectEditing/NCViewerDirectEditing.swift index 90fef2de6a..6332794545 100644 --- a/iOSClient/Viewer/NCViewerDirectEditing/NCViewerDirectEditing.swift +++ b/iOSClient/Viewer/NCViewerDirectEditing/NCViewerDirectEditing.swift @@ -40,7 +40,11 @@ class NCViewerDirectEditing: UIViewController, WKNavigationDelegate, WKScriptMes primaryAction: nil, menu: UIMenu(title: "", children: [ UIDeferredMenuElement.uncached { [self] completion in - if let menu = NCContextMenuViewer(metadata: self.metadata, controller: self.tabBarController as? NCMainTabBarController, webView: true, sender: self).viewMenu() { + if let menu = NCContextMenuViewer(metadata: self.metadata, + controller: self.tabBarController as? NCMainTabBarController, + viewController: self.tabBarController, + webView: true, + sender: self).viewMenu() { completion(menu.children) } } @@ -172,6 +176,7 @@ class NCViewerDirectEditing: UIViewController, WKNavigationDelegate, WKScriptMes if message.body as? String == "share" { NCCreate().createShare(controller: self.controller, + viewController: self.controller, metadata: metadata, page: .sharing) } diff --git a/iOSClient/Viewer/NCViewerMedia/Content/Audio/NCAudioViewerContentView.swift b/iOSClient/Viewer/NCViewerMedia/Content/Audio/NCAudioViewerContentView.swift new file mode 100644 index 0000000000..73fb816f8c --- /dev/null +++ b/iOSClient/Viewer/NCViewerMedia/Content/Audio/NCAudioViewerContentView.swift @@ -0,0 +1,510 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Marino Faggiana +// SPDX-License-Identifier: GPL-3.0-or-later + +import SwiftUI +import AVFoundation +import NextcloudKit + +// MARK: - Audio Viewer View + +/// Displays and plays a local audio file. +/// +/// The playback model is retrieved from `NCAudioViewerPlaybackRegistry` so the +/// underlying `AVPlayer` survives SwiftUI view rebuilds caused by rotation, +/// layout invalidation, or cell refreshes. +struct NCAudioViewerContentView: View { + let metadata: tableMetadata + let localURL: URL + let canGoPrevious: Bool + let canGoNext: Bool + let shouldAutoPlay: Bool + let onPrevious: (_ shouldAutoPlay: Bool) -> Void + let onNext: (_ shouldAutoPlay: Bool) -> Void + let onAutoPlayConsumed: () -> Void + + @StateObject private var model: NCAudioViewerModel + + init( + metadata: tableMetadata, + localURL: URL, + canGoPrevious: Bool = false, + canGoNext: Bool = false, + shouldAutoPlay: Bool = false, + onPrevious: @escaping (_ shouldAutoPlay: Bool) -> Void = { _ in }, + onNext: @escaping (_ shouldAutoPlay: Bool) -> Void = { _ in }, + onAutoPlayConsumed: @escaping () -> Void = {} + ) { + self.metadata = metadata + self.localURL = localURL + self.canGoPrevious = canGoPrevious + self.canGoNext = canGoNext + self.shouldAutoPlay = shouldAutoPlay + self.onPrevious = onPrevious + self.onNext = onNext + self.onAutoPlayConsumed = onAutoPlayConsumed + + _model = StateObject( + wrappedValue: NCAudioViewerPlaybackRegistry.shared.model( + for: metadata.ocId + ) + ) + } + + var body: some View { + VStack(spacing: 28) { + artworkView + + VStack(spacing: 8) { + Text(displayFileName) + .font(.headline) + .foregroundStyle(.white) + .lineLimit(2) + .multilineTextAlignment(.center) + + Text(metadata.contentType.isEmpty ? "Audio" : metadata.contentType) + .font(.footnote) + .foregroundStyle(.white.opacity(0.55)) + .lineLimit(1) + } + .padding(.horizontal, 24) + + VStack(spacing: 10) { + Slider( + value: Binding( + get: { model.currentTime }, + set: { model.seek(to: $0) } + ), + in: 0...max(model.duration, 1) + ) + .disabled(model.duration <= 0) + + HStack { + Text(formatTime(model.currentTime)) + + Spacer() + + Text(formatTime(model.duration)) + } + .font(.caption.monospacedDigit()) + .foregroundStyle(.white.opacity(0.6)) + } + .padding(.horizontal, 32) + + HStack(spacing: 28) { + Button { + model.toggleLoop() + } label: { + Image(systemName: model.isLoopEnabled ? "repeat.circle.fill" : "repeat.circle") + .font(.system(size: 34, weight: .regular)) + .foregroundStyle(model.isLoopEnabled ? .white : .white.opacity(0.45)) + } + .buttonStyle(.plain) + + Button { + model.togglePlayback() + } label: { + Image(systemName: model.isPlaying ? "pause.circle.fill" : "play.circle.fill") + .font(.system(size: 72, weight: .regular)) + .foregroundStyle(.white) + } + .buttonStyle(.plain) + + Button { + model.restart() + } label: { + Image(systemName: "gobackward") + .font(.system(size: 34, weight: .regular)) + .foregroundStyle(.white.opacity(0.45)) + } + .buttonStyle(.plain) + .disabled(model.duration <= 0) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color.black) + .task(id: localURL) { + await model.load(url: localURL) + consumeAutoPlayIfNeeded() + } + .onChange(of: shouldAutoPlay) { _, newValue in + guard newValue else { + return + } + + consumeAutoPlayIfNeeded() + } + .onReceive(NotificationCenter.default.publisher(for: .ncMediaViewerStopPlayback)) { _ in + NCAudioViewerPlaybackRegistry.shared.stopAll() + } + } + + // MARK: - Views + + private var artworkView: some View { + ZStack { + Circle() + .fill(.white.opacity(0.08)) + .frame(width: 180, height: 180) + + Image(systemName: "waveform") + .font(.system(size: 76, weight: .regular)) + .foregroundStyle(.white.opacity(0.9)) + } + } + + // MARK: - Private + + private var displayFileName: String { + if !metadata.fileNameView.isEmpty { + return metadata.fileNameView + } + + return metadata.fileName + } + + /// Starts playback when this page receives an auto-play request. + @MainActor + private func consumeAutoPlayIfNeeded() { + guard shouldAutoPlay else { + return + } + + model.play() + onAutoPlayConsumed() + } + + private func formatTime(_ seconds: Double) -> String { + guard seconds.isFinite, + seconds >= 0 else { + return "00:00" + } + + let totalSeconds = Int(seconds.rounded()) + let minutes = totalSeconds / 60 + let remainingSeconds = totalSeconds % 60 + + return String( + format: "%02d:%02d", + minutes, + remainingSeconds + ) + } +} + +// MARK: - Audio Viewer Playback Registry + +/// Keeps audio playback models alive across SwiftUI view rebuilds. +/// +/// The media viewer can rebuild cells during rotation or layout changes. +/// This registry prevents the audio player from being destroyed just because +/// the SwiftUI page view was recreated. +@MainActor +final class NCAudioViewerPlaybackRegistry { + static let shared = NCAudioViewerPlaybackRegistry() + + private var modelsByOcId: [String: NCAudioViewerModel] = [:] + + private init() { } + + /// Returns a stable audio model for the given media item. + /// + /// - Parameter ocId: Stable Nextcloud media identifier. + /// - Returns: Existing or newly created audio playback model. + func model(for ocId: String) -> NCAudioViewerModel { + if let model = modelsByOcId[ocId] { + return model + } + + let model = NCAudioViewerModel() + modelsByOcId[ocId] = model + return model + } + + /// Stops all cached audio models without removing them. + /// + /// SwiftUI pages may still hold `@StateObject` references to these models. + /// Removing them while views are alive can create duplicate playback models for + /// the same `ocId` after a later cell refresh or rebuild. + func stopAll() { + modelsByOcId.values.forEach { $0.stop() } + } +} + +// MARK: - Audio Viewer Model + +/// Lightweight audio playback model backed by `AVPlayer`. +/// +/// The model observes playback time and item completion, exposes SwiftUI-friendly +/// state, and performs cleanup when playback is explicitly stopped. +@MainActor +final class NCAudioViewerModel: ObservableObject { + + // MARK: - Published State + + @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 + + private var player: AVPlayer? + private var timeObserver: Any? + private var endObserver: NSObjectProtocol? + private var currentURL: URL? + private var loadedURL: URL? + + // MARK: - Public API + + /// Loads a local audio file. + /// + /// If the same URL is already loaded, the existing player is reused. + /// + /// - Parameter url: Local audio file URL. + func load(url: URL) async { + guard currentURL != url else { + return + } + + stop() + + currentURL = url + loadedURL = url + + configureAudioSession() + + let asset = AVURLAsset(url: url) + let item = AVPlayerItem(asset: asset) + let player = AVPlayer(playerItem: item) + + player.actionAtItemEnd = .pause + + self.player = player + + let loadedDuration: Double + + if let duration = try? await asset.load(.duration), + duration.seconds.isFinite { + loadedDuration = duration.seconds + } else { + loadedDuration = 0 + } + + guard !Task.isCancelled, + currentURL == url, + self.player === player else { + player.pause() + return + } + + self.duration = loadedDuration + + addTimeObserver(to: player) + addEndObserver(for: item, player: player) + } + + /// Starts audio playback. + func play() { + guard let player else { + guard let loadedURL else { + return + } + + Task { @MainActor in + await load(url: loadedURL) + play() + } + return + } + + if duration > 0, + currentTime >= duration - 0.2 { + seek(to: 0) + } + + configureAudioSession() + + player.play() + isPlaying = true + } + + /// Toggles audio playback. + func togglePlayback() { + if isPlaying { + pause() + } else { + play() + } + } + + /// Toggles loop playback. + func toggleLoop() { + isLoopEnabled.toggle() + } + + /// Restarts playback from the beginning. + func restart() { + seek(to: 0) + + if isPlaying { + player?.play() + } + } + + /// Seeks to a specific playback time. + /// + /// - Parameter seconds: Target playback position in seconds. + func seek(to seconds: Double) { + guard let player else { + return + } + + let clampedSeconds = min( + max(seconds, 0), + max(duration, 0) + ) + + currentTime = clampedSeconds + + let time = CMTime( + seconds: clampedSeconds, + preferredTimescale: 600 + ) + + player.seek( + to: time, + toleranceBefore: .zero, + toleranceAfter: .zero + ) + } + + /// Pauses playback without releasing the player. + func pause() { + player?.pause() + isPlaying = false + } + + /// Stops playback and releases the player. + func stop() { + if let player { + player.pause() + } + + if let timeObserver, + let player { + player.removeTimeObserver(timeObserver) + } + + if let endObserver { + NotificationCenter.default.removeObserver(endObserver) + } + + timeObserver = nil + endObserver = nil + player = nil + currentURL = nil + + isPlaying = false + currentTime = 0 + duration = 0 + } + + // MARK: - Private + + /// Configures the audio session for media playback. + private func configureAudioSession() { + do { + try AVAudioSession.sharedInstance().setCategory( + .playback, + mode: .default, + options: [] + ) + + try AVAudioSession.sharedInstance().setActive(true) + } catch { + nkLog( + tag: NCGlobal.shared.logTagViewer, + emoji: .error, + message: "AUDIO session error: \(error.localizedDescription)", + consoleOnly: true + ) + } + } + + /// Adds a periodic time observer to update SwiftUI playback state. + /// + /// - Parameter player: Player to observe. + private func addTimeObserver(to player: AVPlayer) { + let interval = CMTime( + seconds: 0.25, + preferredTimescale: 600 + ) + + timeObserver = player.addPeriodicTimeObserver( + forInterval: interval, + queue: .main + ) { [weak self] time in + guard let self else { + return + } + + Task { @MainActor in + guard self.player === player else { + return + } + + self.currentTime = time.seconds.isFinite ? time.seconds : 0 + } + } + } + + /// Observes the end of playback and restarts the item when loop is enabled. + /// + /// - Parameters: + /// - item: Player item to observe. + /// - player: Player that owns the item. + private func addEndObserver( + for item: AVPlayerItem, + player: AVPlayer + ) { + endObserver = NotificationCenter.default.addObserver( + forName: AVPlayerItem.didPlayToEndTimeNotification, + object: item, + queue: .main + ) { [weak self, weak player] _ in + guard let self, + let player else { + return + } + + Task { @MainActor in + guard self.player === player else { + return + } + + if self.isLoopEnabled { + self.currentTime = 0 + + player.seek( + to: .zero, + toleranceBefore: .zero, + toleranceAfter: .zero + ) { _ in + Task { @MainActor in + guard self.player === player else { + return + } + + player.play() + self.isPlaying = true + } + } + } else { + self.currentTime = self.duration + self.isPlaying = false + } + } + } + } +} diff --git a/iOSClient/Viewer/NCViewerMedia/Content/Image/NCImageViewerContentView.swift b/iOSClient/Viewer/NCViewerMedia/Content/Image/NCImageViewerContentView.swift new file mode 100644 index 0000000000..c1213adda7 --- /dev/null +++ b/iOSClient/Viewer/NCViewerMedia/Content/Image/NCImageViewerContentView.swift @@ -0,0 +1,354 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Marino Faggiana +// SPDX-License-Identifier: GPL-3.0-or-later + +import SwiftUI +import UIKit + +// MARK: - Image Viewer Content View + +/// Displays an image page using an optional preview and an optional full-size image. +/// +/// The preview is decoded first when available. +/// The full image replaces the preview only after it has been decoded. +/// Animated GIF files are decoded as animated `UIImage` instances. +/// SVG files are rasterized into `UIImage` instances before rendering. +/// All decoded images are rendered through the same zoom pipeline. +struct NCImageViewerContentView: View { + let identifier: String + let previewURL: URL? + let fullURL: URL? + let backgroundStyle: NCViewerBackgroundStyle + + @State private var currentImage: UIImage? + @State private var loadedPreviewURL: URL? + @State private var loadedFullURL: URL? + @State private var loadedIdentifier: String? + @State private var failedMessage: String? + + private var taskIdentifier: String { + "\(identifier)|\(previewURL?.absoluteString ?? "")|\(fullURL?.absoluteString ?? "")" + } + + init(identifier: String, previewURL: URL?, fullURL: URL?, backgroundStyle: NCViewerBackgroundStyle = .system) { + self.identifier = identifier + self.previewURL = previewURL + self.fullURL = fullURL + self.backgroundStyle = backgroundStyle + } + + var body: some View { + ZStack { + Color.ncViewerBackground(backgroundStyle) + .ignoresSafeArea() + + if let currentImage { + NCImageZoomView( + image: currentImage, + backgroundStyle: backgroundStyle, + allowsImageAnalysis: allowsImageAnalysis + ) + .ignoresSafeArea() + } else if let failedMessage { + failedView(failedMessage) + } else { + Color.ncViewerBackground(backgroundStyle) + .ignoresSafeArea() + } + } + .background(Color.ncViewerBackground(backgroundStyle)) + .task(id: taskIdentifier) { + await loadBestAvailableImage() + } + } + + // MARK: - Views + + private func failedView(_ message: String) -> some View { + VStack(spacing: 12) { + Image(systemName: "photo.badge.exclamationmark") + .font(.system(size: 44, weight: .regular)) + + Text("Image load failed") + .font(.headline) + + Text(message) + .font(.caption) + .foregroundStyle(secondaryForegroundStyle) + .multilineTextAlignment(.center) + } + .foregroundStyle(primaryForegroundStyle) + .padding(24) + } + + // MARK: - Appearance + + private var primaryForegroundStyle: Color { + switch backgroundStyle { + case .black: + return .white + + case .system, + .white, + .custom: + return .primary + } + } + + private var secondaryForegroundStyle: Color { + switch backgroundStyle { + case .black: + return .white.opacity(0.65) + + case .system, + .white, + .custom: + return .secondary + } + } + + // MARK: - Loading + + /// Loads the best available image for the current URLs. + @MainActor + private func loadBestAvailableImage() async { + let expectedIdentifier = identifier + let expectedPreviewURL = previewURL + let expectedFullURL = fullURL + + if loadedIdentifier != expectedIdentifier { + currentImage = nil + loadedPreviewURL = nil + loadedFullURL = nil + failedMessage = nil + loadedIdentifier = expectedIdentifier + } + + failedMessage = nil + + if let expectedPreviewURL, + currentImage == nil, + loadedPreviewURL != expectedPreviewURL { + if let previewImage = await decodePreviewImageIfPossible(url: expectedPreviewURL) { + guard !Task.isCancelled, + identifier == expectedIdentifier, + previewURL == expectedPreviewURL else { + return + } + + loadedPreviewURL = expectedPreviewURL + failedMessage = nil + currentImage = previewImage + + await Task.yield() + } + } + + guard let expectedFullURL else { + return + } + + guard loadedFullURL != expectedFullURL else { + return + } + + if loadedPreviewURL == expectedFullURL, + currentImage != nil { + loadedFullURL = expectedFullURL + return + } + + let fullImage: UIImage? + + if isGIF(expectedFullURL) { + fullImage = await decodeGIFImageIfPossible(url: expectedFullURL) + } else if isSVG(expectedFullURL) { + fullImage = await decodeSVGImageIfPossible(url: expectedFullURL) + } else { + fullImage = await decodeImageIfPossible(url: expectedFullURL) + } + + guard !Task.isCancelled, + identifier == expectedIdentifier, + fullURL == expectedFullURL else { + return + } + + if let fullImage { + loadedFullURL = expectedFullURL + failedMessage = nil + currentImage = fullImage + return + } + + if currentImage == nil { + failedMessage = imageDecodeFailedMessage(for: expectedFullURL) + } + } + + /// Decodes and prepares a local standard image file for display. + /// + /// `UIImage(contentsOfFile:)` can return a lazy image whose bitmap is decoded only + /// when UIKit first draws it. Complex or large images can therefore produce a short + /// blank frame before becoming visible. + /// + /// This method synchronously prepares the image for display in a detached task + /// before publishing it to SwiftUI, so the viewer replaces the preview only when + /// the image is really ready. + /// + /// - Parameter url: Local file URL. + /// - Returns: Display-prepared image if possible. + private func decodeImageIfPossible(url: URL) async -> UIImage? { + guard isValidLocalFile(url: url) else { + return nil + } + + let path = url.path + + return await Task.detached(priority: .userInitiated) { + autoreleasepool { + guard let image = UIImage(contentsOfFile: path) else { + return nil + } + + return image.preparingForDisplay() ?? image + } + }.value + } + + /// Decodes a local preview image file as quickly as possible. + /// + /// Preview images are intentionally not display-prepared here. + /// They are small temporary placeholders and should become visible before the + /// full image starts its heavier display preparation. + /// + /// - Parameter url: Local preview file URL. + /// - Returns: Preview image if possible. + private func decodePreviewImageIfPossible(url: URL) async -> UIImage? { + guard isValidLocalFile(url: url) else { + return nil + } + + let path = url.path + + return await Task.detached(priority: .userInitiated) { + autoreleasepool { + UIImage(contentsOfFile: path) + } + }.value + } + + /// Decodes a local GIF file as an animated `UIImage`. + /// + /// - Parameter url: Local GIF file URL. + /// - Returns: Animated image if the GIF can be decoded. + private func decodeGIFImageIfPossible(url: URL) async -> UIImage? { + guard isValidLocalFile(url: url) else { + return nil + } + + return await Task.detached(priority: .userInitiated) { + autoreleasepool { + UIImage.animatedImage(withAnimatedGIFURL: url) + } + }.value + } + + /// Decodes a local SVG file by rasterizing it into a `UIImage`. + /// + /// `NCSVGRenderer` is WKWebView-backed, so this method must run on the main actor. + /// + /// - Parameter url: Local SVG file URL. + /// - Returns: Rasterized SVG image if possible. + @MainActor + private func decodeSVGImageIfPossible(url: URL) async -> UIImage? { + guard isValidLocalFile(url: url) else { + return nil + } + + guard let svgData = try? Data(contentsOf: url) else { + return nil + } + + return try? await NCSVGRenderer().renderSVGToUIImage( + svgData: svgData, + size: CGSize(width: 1024, height: 1024) + ) + } + + /// Returns whether the URL points to a GIF file. + /// + /// - Parameter url: Optional file URL. + /// - Returns: True when the path extension is `gif`. + private func isGIF(_ url: URL?) -> Bool { + url?.pathExtension.lowercased() == "gif" + } + + /// Returns whether the URL points to an SVG file. + /// + /// - Parameter url: Optional file URL. + /// - Returns: True when the path extension is `svg`. + private func isSVG(_ url: URL?) -> Bool { + url?.pathExtension.lowercased() == "svg" + } + + /// Returns the proper decode failure message for a local image URL. + /// + /// - Parameter url: Local file URL. + /// - Returns: User-facing decode failure message. + private func imageDecodeFailedMessage(for url: URL) -> String { + if isGIF(url) { + return "GIF file could not be decoded." + } + + if isSVG(url) { + return "SVG file could not be rendered." + } + + return "UIImage could not decode this file." + } + + /// Checks whether a local file exists and has a non-zero size. + /// + /// - Parameter url: Local file URL. + /// - Returns: True when the file exists and is not empty. + private func isValidLocalFile(url: URL) -> Bool { + let path = url.path + + guard FileManager.default.fileExists(atPath: path) else { + return false + } + + guard let attributes = try? FileManager.default.attributesOfItem(atPath: path), + let fileSize = attributes[.size] as? Int64, + fileSize > 0 else { + return false + } + + return true + } + + /// Returns whether VisionKit image analysis should be enabled for the current image. + /// + /// Image analysis is enabled only for normal static images. + /// GIF and SVG are excluded because they are rendered through special decoding paths. + private var allowsImageAnalysis: Bool { + let url = fullURL ?? previewURL + + guard let url else { + return false + } + + if isGIF(url) { + return false + } + + /* for now disable (marino) + if isSVG(url) { + return false + } + */ + + return true + } +} diff --git a/iOSClient/Viewer/NCViewerMedia/Content/Image/NCLivePhotoViewerContentView.swift b/iOSClient/Viewer/NCViewerMedia/Content/Image/NCLivePhotoViewerContentView.swift new file mode 100644 index 0000000000..aa89c449e8 --- /dev/null +++ b/iOSClient/Viewer/NCViewerMedia/Content/Image/NCLivePhotoViewerContentView.swift @@ -0,0 +1,454 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Marino Faggiana +// SPDX-License-Identifier: GPL-3.0-or-later + +import SwiftUI +import UIKit +import Photos +import PhotosUI +import NextcloudKit + +// MARK: - Live Photo Viewer Content View + +/// Displays a Live Photo using a paired full image file and video file. +/// +/// The still image is rendered through `NCImageViewerContentView`, so preview, +/// full image replacement, zoom, and pan keep the same behavior as normal images. +/// The `PHLivePhotoView` is mounted only during playback and is dismantled as soon +/// as playback ends, the page changes, or the view disappears. +struct NCLivePhotoViewerContentView: View { + let identifier: String + let previewURL: URL? + let fullURL: URL? + let videoURL: URL? + let backgroundStyle: NCViewerBackgroundStyle + let topOverlayInset: CGFloat + + @State private var livePhoto: PHLivePhoto? + @State private var failedMessage: String? + @State private var isPlayingLivePhoto = false + @State private var loadedTaskIdentifier: String? + + init( + identifier: String, + previewURL: URL?, + fullURL: URL?, + videoURL: URL?, + backgroundStyle: NCViewerBackgroundStyle = .system, + topOverlayInset: CGFloat = 0 + ) { + self.identifier = identifier + self.previewURL = previewURL + self.fullURL = fullURL + self.videoURL = videoURL + self.backgroundStyle = backgroundStyle + self.topOverlayInset = topOverlayInset + } + + var body: some View { + ZStack { + Color.ncViewerBackground(backgroundStyle) + .ignoresSafeArea() + + stillImageView + + if isPlayingLivePhoto, let livePhoto { + NCLivePhotoViewRepresentable( + livePhoto: livePhoto, + backgroundStyle: backgroundStyle, + isPlaying: $isPlayingLivePhoto + ) + .id(playbackViewIdentifier) + .ignoresSafeArea() + } + + livePhotoBadge + + if let failedMessage { + failedOverlay(failedMessage) + } + } + .background(Color.ncViewerBackground(backgroundStyle)) + .task(id: taskIdentifier) { + await loadLivePhotoIfNeeded() + } + .highPriorityGesture( + LongPressGesture(minimumDuration: 0.25) + .onEnded { _ in + guard livePhoto != nil else { + return + } + + isPlayingLivePhoto = true + } + ) + .onReceive(NotificationCenter.default.publisher(for: .ncMediaViewerStopPlayback)) { _ in + stopLivePhotoPlayback() + } + .onChange(of: identifier) { _, _ in + stopLivePhotoPlayback() + } + .onChange(of: taskIdentifier) { _, _ in + stopLivePhotoPlayback() + } + .onDisappear { + stopLivePhotoPlayback() + } + } + + // MARK: - Views + + @ViewBuilder + private var stillImageView: some View { + NCImageViewerContentView( + identifier: identifier, + previewURL: previewURL, + fullURL: fullURL, + backgroundStyle: backgroundStyle + ) + } + + /// Badge shown below the navigation bar on the leading side. (color) + private var livePhotoBadgeBackground: Color { + switch backgroundStyle { + case .black: + return .gray.opacity(0.32) + + case .system, + .white, + .custom: + return .white.opacity(0.72) + } + } + + private var livePhotoBadgeForeground: Color { + switch backgroundStyle { + case .black: + return .white.opacity(0.88) + + case .system, + .white, + .custom: + return .gray + } + } + + private var livePhotoBadgeStroke: Color { + switch backgroundStyle { + case .black: + return .white.opacity(0.16) + + case .system, + .white, + .custom: + return .gray.opacity(0.22) + } + } + + /// Badge shown below the navigation bar on the leading side. + private var livePhotoBadge: some View { + GeometryReader { proxy in + let isLandscape = proxy.size.width > proxy.size.height + let isPad = UIDevice.current.userInterfaceIdiom == .pad + let topInset = isLandscape && !isPad ? max(topOverlayInset, 76) : topOverlayInset + + VStack { + HStack { + HStack(spacing: 5) { + Image(systemName: "livephoto") + .font(.system(size: 12, weight: .medium)) + .foregroundStyle(livePhotoBadgeForeground) + + Text("LIVE") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(livePhotoBadgeForeground) + } + .padding(.horizontal, 9) + .padding(.vertical, 5) + .background(livePhotoBadgeBackground) + .overlay( + Capsule() + .stroke(livePhotoBadgeStroke, lineWidth: 1) + ) + .clipShape(Capsule()) + .shadow(color: .black.opacity(0.08), radius: 2, x: 0, y: 1) + .padding(.leading, 12) + .padding(.top, topInset) + + Spacer() + } + + Spacer() + } + } + .allowsHitTesting(false) + } + + private func failedOverlay(_ message: String) -> some View { + VStack(spacing: 8) { + Image(systemName: "livephoto.slash") + .font(.system(size: 24, weight: .regular)) + + Text(message) + .font(.caption) + .multilineTextAlignment(.center) + } + .foregroundStyle(primaryForegroundStyle) + .padding(12) + .background(.black.opacity(0.35)) + .clipShape(RoundedRectangle(cornerRadius: 12)) + .padding() + } + + // MARK: - Appearance + + private var primaryForegroundStyle: Color { + switch backgroundStyle { + case .black: + return .white + + case .system, + .white, + .custom: + return .primary + } + } + + // MARK: - Identifiers + + private var taskIdentifier: String { + "\(identifier)|\(fullURL?.absoluteString ?? "")|\(videoURL?.absoluteString ?? "")" + } + + private var playbackViewIdentifier: String { + "\(taskIdentifier)|playback" + } + + // MARK: - Loading + + /// Loads the Live Photo only when both full image and paired video resources are available. + /// + /// Missing resources are not treated as a visual failure because the viewer can + /// still render the still image through the normal image pipeline. + @MainActor + private func loadLivePhotoIfNeeded() async { + if loadedTaskIdentifier != taskIdentifier { + livePhoto = nil + failedMessage = nil + isPlayingLivePhoto = false + loadedTaskIdentifier = taskIdentifier + } + + guard livePhoto == nil else { + return + } + + failedMessage = nil + + guard let fullURL, + let videoURL else { + return + } + + guard FileManager.default.fileExists(atPath: fullURL.path), + FileManager.default.fileExists(atPath: videoURL.path) else { + return + } + + let resourceURLs = [ + fullURL, + videoURL + ] + + let loadedLivePhoto = await requestLivePhoto(resourceURLs: resourceURLs) + + guard !Task.isCancelled else { + return + } + + guard loadedTaskIdentifier == taskIdentifier else { + return + } + + guard let loadedLivePhoto else { + failedMessage = "PHLivePhoto could not load these resources." + return + } + + failedMessage = nil + livePhoto = loadedLivePhoto + } + + /// Stops the current Live Photo playback and removes the temporary playback view. + @MainActor + private func stopLivePhotoPlayback() { + isPlayingLivePhoto = false + } + + /// Requests a `PHLivePhoto` from the provided photo and video resource URLs. + /// + /// The Photos framework can invoke the result handler more than once. + /// This wrapper waits for the non-degraded Live Photo and resumes the continuation only once. + /// + /// - Parameter resourceURLs: Local resource URLs required to build the Live Photo. + /// - Returns: A playable `PHLivePhoto` when the request succeeds, otherwise `nil`. + @MainActor + private func requestLivePhoto(resourceURLs: [URL]) async -> PHLivePhoto? { + guard resourceURLs.count >= 2 else { + return nil + } + + return await withCheckedContinuation { continuation in + final class ResumeBox { + private var didResume = false + private let lock = NSLock() + + func resumeOnce( + _ continuation: CheckedContinuation, + returning livePhoto: PHLivePhoto? + ) { + lock.lock() + defer { lock.unlock() } + + guard !didResume else { + return + } + + didResume = true + continuation.resume(returning: livePhoto) + } + } + + let resumeBox = ResumeBox() + + PHLivePhoto.request( + withResourceFileURLs: resourceURLs, + placeholderImage: nil, + targetSize: .zero, + contentMode: .aspectFit + ) { livePhoto, info in + if let cancelled = info[PHLivePhotoInfoCancelledKey] as? Bool, + cancelled { + resumeBox.resumeOnce( + continuation, + returning: nil + ) + return + } + + if info[PHLivePhotoInfoErrorKey] != nil { + resumeBox.resumeOnce( + continuation, + returning: nil + ) + return + } + + let isDegraded = (info[PHLivePhotoInfoIsDegradedKey] as? Bool) == true + + if isDegraded { + return + } + + guard let livePhoto else { + return + } + + resumeBox.resumeOnce( + continuation, + returning: livePhoto + ) + } + } + } +} + +// MARK: - Live Photo View Representable + +/// UIKit wrapper for `PHLivePhotoView`. +/// +/// The wrapper starts Live Photo playback when it is mounted. +/// Playback is stopped explicitly when SwiftUI dismantles the UIKit view. +private struct NCLivePhotoViewRepresentable: UIViewRepresentable { + let livePhoto: PHLivePhoto + let backgroundStyle: NCViewerBackgroundStyle + @Binding var isPlaying: Bool + + func makeUIView(context: Context) -> PHLivePhotoView { + let view = PHLivePhotoView() + + view.backgroundColor = .ncViewerBackground(backgroundStyle) + view.contentMode = .scaleAspectFit + view.clipsToBounds = true + view.livePhoto = livePhoto + view.isMuted = false + view.delegate = context.coordinator + + context.coordinator.livePhotoView = view + context.coordinator.isPlaying = $isPlaying + + DispatchQueue.main.async { + guard context.coordinator.livePhotoView === view else { + return + } + + guard isPlaying else { + return + } + + view.startPlayback(with: .full) + } + + return view + } + + func updateUIView(_ view: PHLivePhotoView, context: Context) { + view.backgroundColor = .ncViewerBackground(backgroundStyle) + + context.coordinator.livePhotoView = view + context.coordinator.isPlaying = $isPlaying + view.delegate = context.coordinator + + if view.livePhoto !== livePhoto { + view.stopPlayback() + view.livePhoto = livePhoto + } + + if isPlaying { + view.startPlayback(with: .full) + } else { + view.stopPlayback() + } + } + + static func dismantleUIView( + _ view: PHLivePhotoView, + coordinator: Coordinator + ) { + view.stopPlayback() + view.delegate = nil + view.livePhoto = nil + + coordinator.livePhotoView = nil + } + + func makeCoordinator() -> Coordinator { + Coordinator(isPlaying: $isPlaying) + } + + final class Coordinator: NSObject, PHLivePhotoViewDelegate { + weak var livePhotoView: PHLivePhotoView? + var isPlaying: Binding + + init(isPlaying: Binding) { + self.isPlaying = isPlaying + } + + func livePhotoView( + _ livePhotoView: PHLivePhotoView, + didEndPlaybackWith playbackStyle: PHLivePhotoViewPlaybackStyle + ) { + isPlaying.wrappedValue = false + } + } +} diff --git a/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoAVPlayerPresenter.swift b/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoAVPlayerPresenter.swift new file mode 100644 index 0000000000..148128e8c0 --- /dev/null +++ b/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoAVPlayerPresenter.swift @@ -0,0 +1,243 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Marino Faggiana +// SPDX-License-Identifier: GPL-3.0-or-later + +import UIKit +import NextcloudKit + +// MARK: - AVPlayer Presenter + +/// Presents one UIKit-only AVPlayer viewer outside the SwiftUI paging hierarchy. +/// +/// This presenter guarantees that only one AVPlayer viewer is presented at a time. +@MainActor +enum NCVideoAVPlayerPresenter { + + // MARK: - State + + private static weak var currentViewController: NCVideoAVPlayerViewController? + private static var currentURL: URL? + private static var isPresenting = false + + // MARK: - Public API + + /// Presents the AVPlayer viewer from the current top view controller. + /// + /// Repeated calls with the same URL are ignored to avoid multiple AVPlayer instances + /// during SwiftUI recomposition or device rotation. + /// + /// - Parameters: + /// - metadata: Video metadata used for logging and player title. + /// - url: Local or remote playable URL. + /// - previewURL: Optional local preview image URL shown until the first video frame is ready. + /// - userAgent: Optional HTTP User-Agent for remote playback. + /// - contextMenuController: Main tab bar controller used by context menu actions. + /// - canGoPrevious: Whether the previous-page gesture/action is currently available. + /// - canGoNext: Whether the next-page gesture/action is currently available. + /// - onPrevious: Callback invoked when AVPlayer receives a previous-page action. + /// - onNext: Callback invoked when AVPlayer receives a next-page action. + /// - onClose: Callback invoked with the current media ocId when AVPlayer closes the fullscreen media viewer. + static func present( + metadata: tableMetadata, + url: URL, + previewURL: URL?, + userAgent: String?, + contextMenuController: NCMainTabBarController?, + canGoPrevious: Bool = false, + canGoNext: Bool = false, + onPrevious: (() -> Void)? = nil, + onNext: (() -> Void)? = nil, + onClose: ((_ ocId: String?) -> Void)? = nil + ) { + if currentURL == url, + let currentViewController { + currentViewController.update( + metadata: metadata, + url: url, + previewURL: previewURL, + userAgent: userAgent, + contextMenuController: contextMenuController + ) + currentViewController.canGoPrevious = canGoPrevious + currentViewController.canGoNext = canGoNext + currentViewController.onPrevious = onPrevious + currentViewController.onNext = onNext + currentViewController.onClose = onClose + + nkLog( + tag: NCGlobal.shared.logTagViewer, + emoji: .debug, + message: "VIDEO AVPlayer presenter ignored duplicate URL \(url.absoluteString)", + consoleOnly: true + ) + return + } + + if isPresenting { + nkLog( + tag: NCGlobal.shared.logTagViewer, + emoji: .debug, + message: "VIDEO AVPlayer presenter ignored while presentation is in progress", + consoleOnly: true + ) + return + } + + if let currentViewController { + currentViewController.update( + metadata: metadata, + url: url, + previewURL: previewURL, + userAgent: userAgent, + contextMenuController: contextMenuController + ) + currentViewController.canGoPrevious = canGoPrevious + currentViewController.canGoNext = canGoNext + currentViewController.onPrevious = onPrevious + currentViewController.onNext = onNext + currentViewController.onClose = onClose + + currentURL = url + return + } + + guard let presenter = topViewController() else { + nkLog( + tag: NCGlobal.shared.logTagViewer, + emoji: .error, + message: "VIDEO AVPlayer presenter failed: no top view controller", + consoleOnly: true + ) + return + } + + if presenter is NCVideoAVPlayerViewController { + return + } + + if let navigationController = presenter as? UINavigationController, + navigationController.topViewController is NCVideoAVPlayerViewController { + return + } + + isPresenting = true + + let viewController = NCVideoAVPlayerViewController( + metadata: metadata, + url: url, + previewURL: previewURL, + userAgent: userAgent, + contextMenuController: contextMenuController + ) + viewController.canGoPrevious = canGoPrevious + viewController.canGoNext = canGoNext + viewController.onPrevious = onPrevious + viewController.onNext = onNext + viewController.onClose = onClose + + currentViewController = viewController + currentURL = url + + let navigationController = UINavigationController( + rootViewController: viewController + ) + + navigationController.modalPresentationStyle = .fullScreen + navigationController.modalTransitionStyle = .crossDissolve + navigationController.navigationBar.prefersLargeTitles = false + navigationController.navigationBar.barStyle = .black + navigationController.navigationBar.tintColor = .white + navigationController.navigationBar.titleTextAttributes = [ + .foregroundColor: UIColor.white + ] + + presenter.present( + navigationController, + animated: false + ) { + isPresenting = false + } + } + + /// Clears the current AVPlayer presentation state. + /// + /// Call this from `NCVideoAVPlayerViewController` when it closes. + /// + /// - Parameter viewController: AVPlayer view controller being closed. + static func clearCurrent( + _ viewController: NCVideoAVPlayerViewController + ) { + guard currentViewController === viewController else { + return + } + + currentViewController = nil + currentURL = nil + isPresenting = false + } + + /// Dismisses the current AVPlayer viewer if one is currently presented. + static func dismissCurrent() { + guard let currentViewController else { + return + } + + currentViewController.dismiss(animated: false) { + clearCurrent(currentViewController) + } + } + + /// Dismisses the current AVPlayer viewer if one is currently presented. + /// + /// This short alias is used by video-page navigation callbacks before moving + /// the SwiftUI media viewer to the previous or next page. + static func dismiss() { + dismissCurrent() + } + + // MARK: - Private + + /// Resolves the top-most visible view controller. + /// + /// - Returns: Top-most visible view controller, if available. + private static func topViewController() -> UIViewController? { + let windowScene = UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + .first { $0.activationState == .foregroundActive } + + let rootViewController = windowScene? + .windows + .first { $0.isKeyWindow }? + .rootViewController + + return visibleViewController(from: rootViewController) + } + + /// Recursively resolves the visible view controller. + /// + /// - Parameter viewController: Root or intermediate view controller. + /// - Returns: Top-most visible view controller. + private static func visibleViewController( + from viewController: UIViewController? + ) -> UIViewController? { + if let navigationController = viewController as? UINavigationController { + return visibleViewController( + from: navigationController.visibleViewController + ) + } + + if let tabBarController = viewController as? UITabBarController { + return visibleViewController( + from: tabBarController.selectedViewController + ) + } + + if let presentedViewController = viewController?.presentedViewController { + return visibleViewController( + from: presentedViewController + ) + } + + return viewController + } +} diff --git a/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoAVPlayerViewController.swift b/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoAVPlayerViewController.swift new file mode 100644 index 0000000000..1206ca2463 --- /dev/null +++ b/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoAVPlayerViewController.swift @@ -0,0 +1,1096 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Marino Faggiana +// SPDX-License-Identifier: GPL-3.0-or-later + +import AVFoundation +import AVKit +import UIKit +import SwiftUI +import NextcloudKit + +// MARK: - AVPlayer Layer View + +/// UIView backed directly by an AVPlayerLayer. +/// +/// This is the AVPlayer equivalent of VLC's drawable view: +/// the fullscreen controller owns one stable video surface and attaches the player to it. +final class NCVideoAVPlayerLayerView: UIView { + override static var layerClass: AnyClass { + AVPlayerLayer.self + } + + var playerLayer: AVPlayerLayer { + guard let playerLayer = layer as? AVPlayerLayer else { + fatalError("NCVideoAVPlayerLayerView must be backed by AVPlayerLayer") + } + + return playerLayer + } + + var player: AVPlayer? { + get { playerLayer.player } + set { playerLayer.player = newValue } + } +} + +// MARK: - AVPlayer View Controller + +/// UIKit-only AVPlayer video controller. +/// +/// This controller is intentionally outside the SwiftUI paging hierarchy. +/// It owns one stable AVPlayerLayer-backed view, one AVPlayer, one optional PiP controller, +/// and one shared controls view. +final class NCVideoAVPlayerViewController: UIViewController { + + // MARK: - Input + + private var metadata: tableMetadata + private var url: URL + private var previewURL: URL? + private var userAgent: String? + private weak var contextMenuController: NCMainTabBarController? + + // MARK: - Paging Callbacks + + var onPrevious: (() -> Void)? + var onNext: (() -> Void)? + var onClose: ((_ ocId: String?) -> Void)? + var canGoPrevious = false + var canGoNext = false + + // MARK: - Views + + internal let playerContainerView = NCVideoAVPlayerLayerView() + private let previewImageView = UIImageView() + internal let controlsView = NCVideoControlsView() + + private let floatingTitleView = NCViewerFloatingTitleView() + + private lazy var floatingTitleDateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.locale = .current + formatter.dateStyle = .medium + formatter.timeStyle = .short + return formatter + }() + + // MARK: - AVPlayer + + internal let player = AVPlayer() + + internal var controlsHideTimer: Timer? + internal var controlsVisible = false + internal var isScrubbing = false + + private var pictureInPictureController: AVPictureInPictureController? + private var itemStatusObservation: NSKeyValueObservation? + private var timeControlStatusObservation: NSKeyValueObservation? + private var playbackEndObserver: NSObjectProtocol? + private var timeObserverToken: Any? + private var preparedURL: URL? + + var isPictureInPictureActive: Bool { + pictureInPictureController?.isPictureInPictureActive == true + } + + internal var shouldKeepControlsVisible: Bool { + player.timeControlStatus != .playing + } + + internal func setNavigationBarVisible( + _ isVisible: Bool, + animated: Bool + ) { + navigationController?.setNavigationBarHidden( + !isVisible, + animated: animated + ) + } + + // MARK: - Navigation Items + + private lazy var moreNavigationItem = UIBarButtonItem( + image: NCImageCache.shared.getImageButtonMore(), + primaryAction: nil, + menu: makeMoreMenu() + ) + + private lazy var mediaDetailNavigationItem = UIBarButtonItem( + image: NCUtility().loadImage( + named: "info.circle", + colors: [NCBrandColor.shared.iconImageColor] + ), + style: .plain, + target: self, + action: #selector(mediaDetailButtonTapped) + ) + + // MARK: - Init + + init( + metadata: tableMetadata, + url: URL, + previewURL: URL?, + userAgent: String?, + contextMenuController: NCMainTabBarController? + ) { + self.metadata = metadata + self.url = url + self.previewURL = previewURL + self.userAgent = userAgent + self.contextMenuController = contextMenuController + + super.init( + nibName: nil, + bundle: nil + ) + + modalPresentationStyle = .fullScreen + modalTransitionStyle = .crossDissolve + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + stopControlsHideTimer() + stop() + pictureInPictureController?.delegate = nil + pictureInPictureController = nil + } + + // MARK: - Lifecycle + + override func loadView() { + let rootView = UIView() + rootView.backgroundColor = .black + rootView.isOpaque = true + rootView.clipsToBounds = true + + playerContainerView.backgroundColor = .black + playerContainerView.isOpaque = true + playerContainerView.clipsToBounds = true + playerContainerView.translatesAutoresizingMaskIntoConstraints = false + playerContainerView.playerLayer.videoGravity = .resizeAspect + + previewImageView.backgroundColor = .black + previewImageView.contentMode = .scaleAspectFit + previewImageView.clipsToBounds = true + previewImageView.translatesAutoresizingMaskIntoConstraints = false + updatePreviewImage() + + controlsView.delegate = self + controlsView.alpha = 0 + controlsView.isHidden = true + controlsView.translatesAutoresizingMaskIntoConstraints = false + + rootView.addSubview(playerContainerView) + rootView.addSubview(previewImageView) + rootView.addSubview(controlsView) + + NSLayoutConstraint.activate([ + playerContainerView.leadingAnchor.constraint(equalTo: rootView.leadingAnchor), + playerContainerView.trailingAnchor.constraint(equalTo: rootView.trailingAnchor), + playerContainerView.topAnchor.constraint(equalTo: rootView.topAnchor), + playerContainerView.bottomAnchor.constraint(equalTo: rootView.bottomAnchor), + + previewImageView.leadingAnchor.constraint(equalTo: rootView.leadingAnchor), + previewImageView.trailingAnchor.constraint(equalTo: rootView.trailingAnchor), + previewImageView.topAnchor.constraint(equalTo: rootView.topAnchor), + previewImageView.bottomAnchor.constraint(equalTo: rootView.bottomAnchor), + + controlsView.leadingAnchor.constraint(equalTo: rootView.leadingAnchor), + controlsView.trailingAnchor.constraint(equalTo: rootView.trailingAnchor), + controlsView.topAnchor.constraint(equalTo: rootView.topAnchor), + controlsView.bottomAnchor.constraint(equalTo: rootView.bottomAnchor) + ]) + + updateControlsNavigationBar() + view = rootView + } + + override func viewDidLoad() { + super.viewDidLoad() + + view.backgroundColor = .black + + configureNavigationItem() + updateTitleLabel(metadata: metadata) + configureAudioSession() + configurePlayerLayer() + configureSwipeGestures() + configureTapGesture() + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + start() + showControls(animated: false) + stopControlsHideTimer() + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + + updatePictureInPictureLayout() + updateControlsNavigationBar() + configureFloatingTitleViewIfNeeded() + } + + override func viewWillTransition( + to size: CGSize, + with coordinator: UIViewControllerTransitionCoordinator + ) { + super.viewWillTransition( + to: size, + with: coordinator + ) + + coordinator.animate(alongsideTransition: { [weak self] _ in + self?.view.layoutIfNeeded() + }, completion: { [weak self] _ in + self?.updatePictureInPictureLayout() + self?.updateControlsNavigationBar() + self?.configureFloatingTitleViewIfNeeded() + }) + } + + // MARK: - Public API + + /// Updates the current AVPlayer input. + /// + /// If the URL changes, the current item is stopped and the new item is prepared. + /// The context menu is refreshed for the new metadata. + /// + /// - Parameters: + /// - metadata: Updated video metadata. + /// - url: Updated playable URL. + /// - userAgent: Optional HTTP User-Agent. + /// - contextMenuController: Updated context menu controller. + func update( + metadata: tableMetadata, + url: URL, + previewURL: URL?, + userAgent: String?, + contextMenuController: NCMainTabBarController? + ) { + let urlChanged = self.url != url + + if urlChanged { + stop() + } + + self.metadata = metadata + self.url = url + self.previewURL = previewURL + self.userAgent = userAgent + self.contextMenuController = contextMenuController + updatePreviewImage() + updateTitleLabel(metadata: metadata) + + refreshMoreMenu() + + if urlChanged { + start() + } + + updatePlayPauseButton() + updateProgressControls() + } + + // MARK: - Navigation + + /// Configures the navigation bar items. + private func configureNavigationItem() { + title = nil + navigationItem.title = nil + navigationItem.titleView = nil + + navigationItem.leftBarButtonItem = UIBarButtonItem( + image: UIImage(systemName: "chevron.backward"), + style: .plain, + target: self, + action: #selector(closeTapped) + ) + + navigationItem.rightBarButtonItems = [ + moreNavigationItem, + mediaDetailNavigationItem + ] + } + + /// Configures the floating title view inside the navigation bar chrome. + private func configureFloatingTitleViewIfNeeded() { + guard let navigationBar = navigationController?.navigationBar else { + return + } + + floatingTitleView.attach(to: navigationBar) + } + + /// Updates the floating title view using the provided video metadata. + /// + /// - Parameter metadata: Video metadata used to build the visible title content. + private func updateTitleLabel(metadata: tableMetadata) { + let primaryTitle = metadata.fileNameView.isEmpty + ? metadata.fileName + : metadata.fileNameView + + floatingTitleView.update( + primaryText: primaryTitle, + secondaryText: floatingTitleSecondaryText(for: metadata), + textColor: .white + ) + } + + /// Builds the secondary floating title text for the provided metadata. + /// + /// - Parameter metadata: Video metadata used to derive the secondary title line. + /// - Returns: Secondary title text shown below the main title. + private func floatingTitleSecondaryText(for metadata: tableMetadata) -> String? { + floatingTitleDateFormatter.string(from: metadata.date as Date) + } + + /// Rebuilds the More menu using the current metadata. + private func refreshMoreMenu() { + moreNavigationItem.menu = makeMoreMenu() + } + + /// Builds the AVPlayer-specific More menu. + /// + /// The menu uses `sender: self`, so menu actions present from the visible + /// AVPlayer controller instead of the SwiftUI viewer underneath. + private func makeMoreMenu() -> UIMenu { + UIMenu(title: "", children: [ + UIDeferredMenuElement.uncached { [weak self] completion in + guard let self else { + completion([]) + return + } + + if let menu = NCContextMenuViewer( + metadata: self.metadata, + controller: self.contextMenuController, + viewController: self, + webView: false, + sender: self + ).viewMenu() { + completion(menu.children) + } else { + completion([]) + } + } + ]) + } + + @objc + private func closeTapped() { + close() + } + + @objc + private func mediaDetailButtonTapped() { + presentDetailView(animated: true) + } + + /// Presents the media metadata detail panel for the current video. + /// + /// Video metadata usually has no EXIF payload, so the detail view receives an empty EXIF model. + /// + /// - Parameter animated: Whether presentation should be animated. + private func presentDetailView(animated: Bool) { + let detailView = NCMediaViewerDetailView( + metadata: metadata, + exif: ExifData() + ) + + let hostingController = UIHostingController(rootView: detailView) + hostingController.modalPresentationStyle = .pageSheet + + if let sheetPresentationController = hostingController.sheetPresentationController { + sheetPresentationController.detents = [.medium(), .large()] + sheetPresentationController.prefersGrabberVisible = true + sheetPresentationController.preferredCornerRadius = 24 + sheetPresentationController.prefersEdgeAttachedInCompactHeight = true + sheetPresentationController.widthFollowsPreferredContentSizeWhenEdgeAttached = false + } + + present( + hostingController, + animated: animated + ) + } + + func close() { + stopControlsHideTimer() + stop() + + NCVideoAVPlayerPresenter.clearCurrent(self) + + dismiss(animated: false) { [onClose, metadata] in + DispatchQueue.main.async { + onClose?(metadata.ocId) + } + } + } + + func closeImmediately() { + stopControlsHideTimer() + stop() + + NCVideoAVPlayerPresenter.clearCurrent(self) + + dismiss(animated: false) { [onClose] in + onClose?(nil) + } + } + + // MARK: - Swipe Navigation + + /// Configures swipe gestures for page navigation and close behavior. + private func configureSwipeGestures() { + let previousGesture = UISwipeGestureRecognizer( + target: self, + action: #selector(handleSwipe(_:)) + ) + previousGesture.direction = .right + previousGesture.delegate = self + view.addGestureRecognizer(previousGesture) + + let nextGesture = UISwipeGestureRecognizer( + target: self, + action: #selector(handleSwipe(_:)) + ) + nextGesture.direction = .left + nextGesture.delegate = self + view.addGestureRecognizer(nextGesture) + + let closePanGesture = UIPanGestureRecognizer( + target: self, + action: #selector(handleClosePan(_:)) + ) + closePanGesture.delegate = self + view.addGestureRecognizer(closePanGesture) + } + + /// Handles page navigation and close swipe gestures. + /// + /// - Parameter gesture: Source swipe gesture recognizer. + @objc + private func handleSwipe(_ gesture: UISwipeGestureRecognizer) { + guard gesture.state == .ended else { + return + } + + guard !isPictureInPictureActive else { + return + } + + guard !isScrubbing else { + return + } + + switch gesture.direction { + case .left: + guard canGoNext else { + return + } + onNext?() + + case .right: + guard canGoPrevious else { + return + } + onPrevious?() + + default: + break + } + } + + /// Handles downward pan gestures by closing the AVPlayer viewer. + /// + /// This mirrors the common media viewer drag-to-close behavior: a short downward + /// drag or a quick downward flick is enough, while horizontal paging still wins + /// when the gesture is mostly horizontal. + /// + /// - Parameter gesture: Source pan gesture recognizer. + @objc + private func handleClosePan(_ gesture: UIPanGestureRecognizer) { + guard !isPictureInPictureActive else { + return + } + + let translation = gesture.translation(in: view) + let velocity = gesture.velocity(in: view) + + guard translation.y > 0 else { + return + } + + switch gesture.state { + case .ended, + .cancelled: + let verticalDistance = translation.y + let horizontalDistance = abs(translation.x) + let downwardVelocity = velocity.y + let isMostlyVertical = verticalDistance > horizontalDistance * 1.10 + let shouldClose = verticalDistance > 70 || downwardVelocity > 550 + + guard isMostlyVertical, + shouldClose else { + return + } + + close() + + default: + break + } + } + + // MARK: - Gesture Handling + + /// Configures a single tap gesture to toggle AVPlayer playback controls. + private func configureTapGesture() { + let tapGesture = UITapGestureRecognizer( + target: self, + action: #selector(handleSingleTap(_:)) + ) + tapGesture.numberOfTapsRequired = 1 + tapGesture.cancelsTouchesInView = false + tapGesture.delegate = self + view.addGestureRecognizer(tapGesture) + } + + /// Handles single taps by toggling AVPlayer playback controls. + /// + /// Taps are ignored while playback is not running because controls and the + /// navigation bar must remain visible in prepared, paused, and stopped states. + /// + /// - Parameter gesture: Source tap gesture recognizer. + @objc + private func handleSingleTap(_ gesture: UITapGestureRecognizer) { + guard !isPictureInPictureActive else { + return + } + + guard !shouldKeepControlsVisible else { + showControls(animated: false) + stopControlsHideTimer() + return + } + + let location = gesture.location(in: view) + + if controlsVisible { + guard !controlsHitFramesContain(location) else { + return + } + + hideControls(animated: true) + } else { + showControls(animated: true) + scheduleControlsHide() + } + } + + // MARK: - Playback + + /// Prepares AVPlayer playback without starting it automatically. + private func start() { + guard preparedURL != url else { + updatePlayPauseButton() + updateProgressControls() + updateSeekingState() + return + } + + preparedURL = url + + let item = AVPlayerItem(asset: makeAsset()) + + player.replaceCurrentItem(with: item) + playerContainerView.player = player + showPreviewImage() + + configureObservers() + configurePictureInPicture() + updatePlayPauseButton() + updateProgressControls() + updateSeekingState() + + nkLog( + tag: NCGlobal.shared.logTagViewer, + emoji: .debug, + message: "VIDEO AVPlayer UIKit prepared without autoplay ocId \(metadata.ocId), url \(url.absoluteString)", + consoleOnly: true + ) + } + + /// Stops AVPlayer playback and releases resources. + private func stop() { + preparedURL = nil + player.pause() + cleanupObservers() + player.replaceCurrentItem(with: nil) + playerContainerView.player = nil + showPreviewImage() + pictureInPictureController?.delegate = nil + pictureInPictureController = nil + updatePlayPauseButton() + updateProgressControls() + } + + /// Creates the AVFoundation asset for the current URL. + private func makeAsset() -> AVURLAsset { + guard let userAgent, + !userAgent.isEmpty, + !url.isFileURL else { + return AVURLAsset(url: url) + } + + return AVURLAsset( + url: url, + options: [ + "AVURLAssetHTTPHeaderFieldsKey": [ + "User-Agent": userAgent + ] + ] + ) + } + + /// Configures the visible AVPlayerLayer used by fullscreen playback. + private func configurePlayerLayer() { + playerContainerView.playerLayer.videoGravity = .resizeAspect + playerContainerView.player = player + } + + /// Configures Picture in Picture from the visible AVPlayerLayer. + private func configurePictureInPicture() { + guard AVPictureInPictureController.isPictureInPictureSupported() else { + controlsView.setTopActionsMode(.none) + return + } + + playerContainerView.player = player + playerContainerView.playerLayer.videoGravity = .resizeAspect + playerContainerView.playerLayer.frame = playerContainerView.bounds + + if pictureInPictureController == nil { + pictureInPictureController = AVPictureInPictureController( + playerLayer: playerContainerView.playerLayer + ) + pictureInPictureController?.delegate = self + } + + controlsView.setTopActionsMode(.pictureInPicture) + } + + /// Updates Picture in Picture layout without changing playback state. + private func updatePictureInPictureLayout() { + playerContainerView.playerLayer.frame = playerContainerView.bounds + } + + /// Toggles Picture in Picture if available. + func togglePictureInPicture() { + guard let pictureInPictureController else { + return + } + + if pictureInPictureController.isPictureInPictureActive { + pictureInPictureController.stopPictureInPicture() + } else { + pictureInPictureController.startPictureInPicture() + } + } + + /// Configures AVPlayer observers. + private func configureObservers() { + cleanupObservers() + + itemStatusObservation = player.currentItem?.observe( + \.status, + options: [.initial, .new] + ) { [weak self] _, _ in + Task { @MainActor in + self?.handleCurrentItemStatusChange() + } + } + + timeControlStatusObservation = player.observe( + \.timeControlStatus, + options: [.initial, .new] + ) { [weak self] _, _ in + Task { @MainActor in + self?.handleTimeControlStatusChange() + } + } + + timeObserverToken = player.addPeriodicTimeObserver( + forInterval: CMTime(seconds: 0.5, preferredTimescale: CMTimeScale(NSEC_PER_SEC)), + queue: .main + ) { [weak self] _ in + guard let self, + !self.isScrubbing else { + return + } + + self.updateProgressControls() + } + + if let currentItem = player.currentItem { + playbackEndObserver = NotificationCenter.default.addObserver( + forName: .AVPlayerItemDidPlayToEndTime, + object: currentItem, + queue: .main + ) { [weak self] _ in + self?.handlePlaybackEnded() + } + } + } + + /// Releases AVPlayer observers owned by this controller. + private func cleanupObservers() { + itemStatusObservation?.invalidate() + timeControlStatusObservation?.invalidate() + + itemStatusObservation = nil + timeControlStatusObservation = nil + + if let timeObserverToken { + player.removeTimeObserver(timeObserverToken) + self.timeObserverToken = nil + } + + if let playbackEndObserver { + NotificationCenter.default.removeObserver(playbackEndObserver) + self.playbackEndObserver = nil + } + } + + /// Handles AVPlayer item status changes. + private func handleCurrentItemStatusChange() { + updateProgressControls() + updatePlayPauseButton() + updateSeekingState() + + guard player.currentItem?.status == .readyToPlay else { + return + } + + if !controlsVisible, + !isPictureInPictureActive { + showControls(animated: false) + scheduleControlsHide() + } + } + + /// Handles AVPlayer playback state changes. + private func handleTimeControlStatusChange() { + updatePlayPauseButton() + + guard player.timeControlStatus == .playing else { + showControls(animated: false) + stopControlsHideTimer() + return + } + + hidePreviewImage() + + if controlsVisible { + scheduleControlsHide() + } + } + + /// Updates the fullscreen preview image shown before the first video frame is ready. + private func updatePreviewImage() { + guard let previewURL, + previewURL.isFileURL else { + previewImageView.image = nil + previewImageView.isHidden = true + return + } + + previewImageView.image = UIImage(contentsOfFile: previewURL.path) + previewImageView.isHidden = previewImageView.image == nil + previewImageView.alpha = 1 + } + + /// Shows the preview image while the AVPlayer item is preparing. + private func showPreviewImage() { + guard previewImageView.image != nil else { + previewImageView.isHidden = true + return + } + + previewImageView.layer.removeAllAnimations() + previewImageView.alpha = 1 + previewImageView.isHidden = false + } + + /// Hides the preview image after AVPlayer actually starts playback. + private func hidePreviewImage() { + guard !previewImageView.isHidden else { + return + } + + previewImageView.layer.removeAllAnimations() + previewImageView.alpha = 0 + previewImageView.isHidden = true + } + + /// Handles playback reaching the end. + private func handlePlaybackEnded() { + updatePlayPauseButton() + updateProgressControls() + showControls(animated: true) + } + + /// Updates the shared controls top actions reference using the real navigation bar. + private func updateControlsNavigationBar() { + controlsView.setTopActionsNavigationBar(navigationController?.navigationBar) + } + + /// Returns whether a point is inside one of the visible controls areas. + /// + /// - Parameter location: Point in this controller's root view coordinate space. + /// - Returns: True when the point is inside center or bottom controls. + internal func controlsHitFramesContain(_ location: CGPoint) -> Bool { + let topActionsFrame = controlsView.topActionsView.convert( + controlsView.topActionsView.bounds, + to: view + ) + let centerControlsFrame = controlsView.centerControlsView.convert( + controlsView.centerControlsView.bounds, + to: view + ) + let bottomControlsFrame = controlsView.bottomControlsView.convert( + controlsView.bottomControlsView.bounds, + to: view + ) + + return topActionsFrame.contains(location) + || centerControlsFrame.contains(location) + || bottomControlsFrame.contains(location) + } + + /// Configures the audio session for movie playback. + private func configureAudioSession() { + do { + try AVAudioSession.sharedInstance().setCategory( + .playback, + mode: .moviePlayback, + options: [] + ) + + try AVAudioSession.sharedInstance().setActive(true) + } catch { + nkLog( + tag: NCGlobal.shared.logTagViewer, + emoji: .error, + message: "VIDEO AVPlayer audio session error: \(error.localizedDescription)", + consoleOnly: true + ) + } + } + + /// Updates the shared controls play/pause state. + internal func updatePlayPauseButton() { + controlsView.updatePlayPauseButton( + isPlaying: player.timeControlStatus == .playing + ) + } + + /// Updates the shared controls progress state. + internal func updateProgressControls() { + let currentTime = player.currentTime().seconds + let duration = player.currentItem?.duration.seconds ?? 0 + + guard currentTime.isFinite, + duration.isFinite, + duration > 0 else { + controlsView.updateProgress( + progress: 0, + elapsedText: "0:00", + remainingText: "−0:00" + ) + return + } + + let progress = Float(max(0, min(1, currentTime / duration))) + let remainingTime = max(0, duration - currentTime) + + controlsView.updateProgress( + progress: progress, + elapsedText: Self.formatTime(currentTime), + remainingText: "−\(Self.formatTime(remainingTime))" + ) + } + + /// Updates whether seek controls are enabled. + internal func updateSeekingState() { + controlsView.setSeekingEnabled( + player.currentItem?.duration.seconds.isFinite == true + ) + } + + internal static func formatTime(_ seconds: Double) -> String { + let totalSeconds = max(0, Int(seconds.rounded())) + let minutes = totalSeconds / 60 + let seconds = totalSeconds % 60 + + return String(format: "%d:%02d", minutes, seconds) + } +} + +// MARK: - Picture in Picture Delegate + +extension NCVideoAVPlayerViewController: AVPictureInPictureControllerDelegate { + func pictureInPictureControllerWillStartPictureInPicture( + _ pictureInPictureController: AVPictureInPictureController + ) { + nkLog( + tag: NCGlobal.shared.logTagViewer, + emoji: .debug, + message: "VIDEO AVPlayer PiP will start", + consoleOnly: true + ) + + stopControlsHideTimer() + hideControls(animated: false) + hidePreviewImage() + } + + func pictureInPictureControllerDidStartPictureInPicture( + _ pictureInPictureController: AVPictureInPictureController + ) { + nkLog( + tag: NCGlobal.shared.logTagViewer, + emoji: .debug, + message: "VIDEO AVPlayer PiP did start", + consoleOnly: true + ) + + stopControlsHideTimer() + hideControls(animated: false) + hidePreviewImage() + } + + func pictureInPictureControllerWillStopPictureInPicture( + _ pictureInPictureController: AVPictureInPictureController + ) { + nkLog( + tag: NCGlobal.shared.logTagViewer, + emoji: .debug, + message: "VIDEO AVPlayer PiP will stop", + consoleOnly: true + ) + } + + func pictureInPictureControllerDidStopPictureInPicture( + _ pictureInPictureController: AVPictureInPictureController + ) { + nkLog( + tag: NCGlobal.shared.logTagViewer, + emoji: .debug, + message: "VIDEO AVPlayer PiP did stop", + consoleOnly: true + ) + + updatePlayPauseButton() + updateProgressControls() + updateSeekingState() + showControls(animated: false) + } + + func pictureInPictureController( + _ pictureInPictureController: AVPictureInPictureController, + failedToStartPictureInPictureWithError error: Error + ) { + nkLog( + tag: NCGlobal.shared.logTagViewer, + emoji: .error, + message: "VIDEO AVPlayer PiP failed to start: \(error.localizedDescription)", + consoleOnly: true + ) + + updatePlayPauseButton() + updateProgressControls() + updateSeekingState() + showControls(animated: true) + } +} + +// MARK: - Gesture Delegate + +extension NCVideoAVPlayerViewController: UIGestureRecognizerDelegate { + + /// Allows tap gestures to coexist with AVPlayer's view and UIKit controls. + /// + /// - Parameters: + /// - gestureRecognizer: Gesture recognizer asking for simultaneous recognition. + /// - otherGestureRecognizer: Other gesture recognizer involved in the decision. + /// - Returns: True to avoid AVPlayer/touch handling from suppressing viewer gestures. + func gestureRecognizer( + _ gestureRecognizer: UIGestureRecognizer, + shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer + ) -> Bool { + true + } + + /// Prevents the background tap recognizer from stealing touches that begin on controls. + /// + /// - Parameters: + /// - gestureRecognizer: Gesture recognizer asking whether it should receive the touch. + /// - touch: Source touch. + /// - Returns: False for visible playback controls, true otherwise. + func gestureRecognizer( + _ gestureRecognizer: UIGestureRecognizer, + shouldReceive touch: UITouch + ) -> Bool { + guard !isPictureInPictureActive else { + return false + } + + guard controlsVisible else { + return true + } + + let location = touch.location(in: view) + + if controlsHitFramesContain(location) { + return false + } + + return true + } + + /// Allows the close pan to start only when the gesture is mainly downward. + /// + /// - Parameter gestureRecognizer: Gesture recognizer asking whether it should begin. + /// - Returns: True for non-pan gestures or downward-dominant pan gestures. + func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { + guard gestureRecognizer is UIPanGestureRecognizer else { + return true + } + + guard !isPictureInPictureActive else { + return false + } + + guard !isScrubbing else { + return false + } + + let velocity = (gestureRecognizer as? UIPanGestureRecognizer)?.velocity(in: view) ?? .zero + + guard velocity.y > 0 else { + return false + } + + return abs(velocity.y) > abs(velocity.x) * 1.10 + } +} diff --git a/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoAVPlayerViewControls.swift b/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoAVPlayerViewControls.swift new file mode 100644 index 0000000000..5399627e8c --- /dev/null +++ b/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoAVPlayerViewControls.swift @@ -0,0 +1,269 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Marino Faggiana +// SPDX-License-Identifier: GPL-3.0-or-later + +import AVFoundation +import UIKit + +// MARK: - Playback Controls + +extension NCVideoAVPlayerViewController { + + func seekBackwardTapped() { + seek(bySeconds: -10) + } + + func playPauseTapped() { + switch player.timeControlStatus { + case .playing: + player.pause() + + case .paused, + .waitingToPlayAtSpecifiedRate: + if let duration = player.currentItem?.duration.seconds, + duration.isFinite, + player.currentTime().seconds >= duration - 0.2 { + player.seek(to: .zero) + } + + player.play() + + @unknown default: + player.play() + } + + updatePlayPauseButton() + scheduleControlsHide() + } + + func seekForwardTapped() { + seek(bySeconds: 10) + } + + private func seek(bySeconds seconds: Double) { + guard let duration = player.currentItem?.duration.seconds, + duration.isFinite, + duration > 0 else { + return + } + + let currentTime = player.currentTime().seconds + let targetSeconds = max( + 0, + min(duration, currentTime + seconds) + ) + + let targetTime = CMTime( + seconds: targetSeconds, + preferredTimescale: 600 + ) + + player.seek( + to: targetTime, + toleranceBefore: .zero, + toleranceAfter: .zero + ) { [weak self] _ in + Task { @MainActor in + self?.updateProgressControls() + self?.scheduleControlsHide() + } + } + } +} + +// MARK: - Controls Visibility + +extension NCVideoAVPlayerViewController { + + func showControls(animated: Bool) { + guard !isPictureInPictureActive else { + setControlsVisible( + false, + animated: false + ) + setNavigationBarVisible( + false, + animated: false + ) + return + } + + setNavigationBarVisible( + true, + animated: animated + ) + setControlsVisible( + true, + animated: animated + ) + } + + func hideControls(animated: Bool) { + guard !shouldKeepControlsVisible else { + showControls(animated: false) + stopControlsHideTimer() + return + } + + setNavigationBarVisible( + false, + animated: animated + ) + setControlsVisible( + false, + animated: animated + ) + } + + private func setControlsVisible( + _ visible: Bool, + animated: Bool + ) { + stopControlsHideTimer() + + controlsVisible = visible + controlsView.isUserInteractionEnabled = visible + + if visible { + controlsView.isHidden = false + } + + let updates = { + self.controlsView.alpha = visible ? 1 : 0 + } + + let completion: (Bool) -> Void = { _ in + if !visible { + self.controlsView.isHidden = true + } + } + + if animated { + UIView.animate( + withDuration: 0.18, + animations: updates, + completion: completion + ) + } else { + updates() + completion(true) + } + } + + func scheduleControlsHide() { + stopControlsHideTimer() + + guard !isPictureInPictureActive else { + return + } + + guard !shouldKeepControlsVisible else { + return + } + + guard controlsVisible else { + return + } + + controlsHideTimer = Timer.scheduledTimer( + withTimeInterval: 3, + repeats: false + ) { [weak self] _ in + Task { @MainActor in + guard let self, + !self.isScrubbing else { + return + } + + self.hideControls(animated: true) + } + } + } + + func stopControlsHideTimer() { + controlsHideTimer?.invalidate() + controlsHideTimer = nil + } +} + +// MARK: - Shared Controls Delegate + +extension NCVideoAVPlayerViewController: NCVideoControlsViewDelegate { + func videoControlsDidTapSubtitle(_ controlsView: NCVideoControlsView) { + // AVPlayer does not expose VLC subtitle track controls. + } + + func videoControlsDidTapAudio(_ controlsView: NCVideoControlsView) { + // AVPlayer does not expose VLC audio track controls. + } + + func videoControlsDidTapSeekBackward(_ controlsView: NCVideoControlsView) { + seekBackwardTapped() + } + + func videoControlsDidTapPlayPause(_ controlsView: NCVideoControlsView) { + playPauseTapped() + } + + func videoControlsDidTapSeekForward(_ controlsView: NCVideoControlsView) { + seekForwardTapped() + } + + func videoControlsDidTapPictureInPicture(_ controlsView: NCVideoControlsView) { + togglePictureInPicture() + } + + func videoControlsDidBeginScrubbing(_ controlsView: NCVideoControlsView) { + isScrubbing = true + stopControlsHideTimer() + } + + func videoControls( + _ controlsView: NCVideoControlsView, + didScrubTo progress: Float + ) { + guard let duration = player.currentItem?.duration.seconds, + duration.isFinite, + duration > 0 else { + return + } + + let targetTime = duration * Double(progress) + + controlsView.updateProgress( + progress: progress, + elapsedText: Self.formatTime(targetTime), + remainingText: "−\(Self.formatTime(max(0, duration - targetTime)))" + ) + } + + func videoControlsDidEndScrubbing( + _ controlsView: NCVideoControlsView, + progress: Float + ) { + guard let duration = player.currentItem?.duration.seconds, + duration.isFinite, + duration > 0 else { + isScrubbing = false + scheduleControlsHide() + return + } + + let targetTime = CMTime( + seconds: duration * Double(progress), + preferredTimescale: 600 + ) + + player.seek( + to: targetTime, + toleranceBefore: .zero, + toleranceAfter: .zero + ) { [weak self] _ in + Task { @MainActor in + self?.isScrubbing = false + self?.updateProgressControls() + self?.scheduleControlsHide() + } + } + } +} diff --git a/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoControlsView.swift b/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoControlsView.swift new file mode 100644 index 0000000000..887d85b258 --- /dev/null +++ b/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoControlsView.swift @@ -0,0 +1,800 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Marino Faggiana +// SPDX-License-Identifier: GPL-3.0-or-later + +import SwiftUI +import UIKit + +// MARK: - Video Controls View Delegate + +/// Receives user actions from the shared video controls view. +/// +/// The controls view is playback-engine agnostic. +/// AVFoundation and VLC controllers translate these callbacks into their own player APIs. +protocol NCVideoControlsViewDelegate: AnyObject { + func videoControlsDidTapSeekBackward(_ controlsView: NCVideoControlsView) + func videoControlsDidTapPlayPause(_ controlsView: NCVideoControlsView) + func videoControlsDidTapSeekForward(_ controlsView: NCVideoControlsView) + func videoControlsDidTapPictureInPicture(_ controlsView: NCVideoControlsView) + func videoControlsDidTapSubtitle(_ controlsView: NCVideoControlsView) + func videoControlsDidTapAudio(_ controlsView: NCVideoControlsView) + func videoControlsDidTapAddExternalSubtitle(_ controlsView: NCVideoControlsView) + func videoControls(_ controlsView: NCVideoControlsView, didSelectSubtitleTrackIndex index: Int32) + func videoControls(_ controlsView: NCVideoControlsView, didSelectAudioTrackIndex index: Int32) + func videoControlsDidBeginScrubbing(_ controlsView: NCVideoControlsView) + func videoControls(_ controlsView: NCVideoControlsView, didScrubTo progress: Float) + func videoControlsDidEndScrubbing(_ controlsView: NCVideoControlsView, progress: Float) +} + +extension NCVideoControlsViewDelegate { + /// Handles the Picture in Picture action when implemented by a playback controller. + /// + /// - Parameter controlsView: Shared controls view that emitted the action. + func videoControlsDidTapPictureInPicture(_ controlsView: NCVideoControlsView) { } + + /// Handles the subtitle track action when implemented by a playback controller. + /// + /// - Parameter controlsView: Shared controls view that emitted the action. + func videoControlsDidTapSubtitle(_ controlsView: NCVideoControlsView) { } + + /// Handles the audio track action when implemented by a playback controller. + /// + /// - Parameter controlsView: Shared controls view that emitted the action. + func videoControlsDidTapAudio(_ controlsView: NCVideoControlsView) { } + + /// Handles the external subtitle import action when implemented by a playback controller. + /// + /// - Parameter controlsView: Shared controls view that emitted the action. + func videoControlsDidTapAddExternalSubtitle(_ controlsView: NCVideoControlsView) { } + + /// Handles subtitle track selection when implemented by a playback controller. + /// + /// - Parameters: + /// - controlsView: Shared controls view that emitted the action. + /// - index: VLC subtitle track index selected by the user. + func videoControls(_ controlsView: NCVideoControlsView, didSelectSubtitleTrackIndex index: Int32) { } + + /// Handles audio track selection when implemented by a playback controller. + /// + /// - Parameters: + /// - controlsView: Shared controls view that emitted the action. + /// - index: VLC audio track index selected by the user. + func videoControls(_ controlsView: NCVideoControlsView, didSelectAudioTrackIndex index: Int32) { } +} + +// MARK: - Video Controls Top Actions Mode + +/// Describes the engine-specific actions rendered in the top controls area. + +enum NCVideoControlsTopActionsMode: Equatable { + case none + case pictureInPicture + case vlcTracks +} + +// MARK: - Video Track Menu Item + +/// Represents a selectable VLC track rendered by the shared SwiftUI controls menu. +struct NCVideoTrackMenuItem: Identifiable, Equatable { + let index: Int32 + let title: String + let isSelected: Bool + + var id: Int32 { + index + } +} + +// MARK: - Video Controls View + +/// Shared UIKit wrapper used by video engines. +/// +/// AVPlayer and VLC still receive a regular `UIView`, while the visual controls are rendered +/// by SwiftUI through an embedded hosting controller. This keeps playback integration stable +/// and makes the custom UI easy to preview and iterate. +final class NCVideoControlsView: UIView { + + // MARK: - Public + + weak var delegate: NCVideoControlsViewDelegate? + + // MARK: - Hit Test Proxies + + let centerControlsView = UIView() + let bottomControlsView = UIView() + let topActionsView = UIView() + + // MARK: - Layout Constants + + fileprivate static let centerControlsWidth: CGFloat = 220 + fileprivate static let centerControlsHeight: CGFloat = 76 + fileprivate static let bottomControlsHeight: CGFloat = 64 + fileprivate static let bottomControlsHorizontalInset: CGFloat = 28 + fileprivate static let bottomControlsBottomInset: CGFloat = 18 + fileprivate static let topActionsHeight: CGFloat = 46 + fileprivate static let topActionsHorizontalInset: CGFloat = 28 + fileprivate static let topActionsButtonSize: CGFloat = 38 + fileprivate static let topActionsSpacing: CGFloat = 8 + + // MARK: - State + + private var state = NCVideoControlsState() + private var topActionsTopConstraint: NSLayoutConstraint? + private weak var navigationBar: UINavigationBar? + + private lazy var hostingController = UIHostingController( + rootView: makeRootView() + ) + + // MARK: - Init + + override init(frame: CGRect) { + super.init(frame: frame) + configureLayout() + updateHostedView() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + configureLayout() + updateHostedView() + } + + // MARK: - Public Updates + + /// Updates the play/pause icon. + /// + /// - Parameter isPlaying: True when playback is currently active. + func updatePlayPauseButton(isPlaying: Bool) { + state.isPlaying = isPlaying + updateHostedView() + } + + /// Updates slider and time labels. + /// + /// - Parameters: + /// - progress: Normalized playback progress between 0 and 1. + /// - elapsedText: Formatted elapsed time. + /// - remainingText: Formatted remaining time. + func updateProgress( + progress: Float, + elapsedText: String, + remainingText: String + ) { + state.progress = max(0, min(1, progress)) + state.elapsedText = elapsedText + state.remainingText = remainingText + updateHostedView() + } + + /// Enables or disables seeking controls. + /// + /// - Parameter isEnabled: True when the current engine supports seeking. + func setSeekingEnabled(_ isEnabled: Bool) { + state.isSeekingEnabled = isEnabled + updateHostedView() + } + + /// Shows or hides the Picture in Picture action. + /// + /// - Parameter isVisible: True when the current playback engine supports Picture in Picture. + func setPictureInPictureVisible(_ isVisible: Bool) { + setTopActionsMode(isVisible ? .pictureInPicture : .none) + } + + /// Shows or hides the VLC subtitle and audio track actions. + /// + /// - Parameter isVisible: True when the VLC playback engine should expose track controls. + func setVLCTrackControlsVisible(_ isVisible: Bool) { + setTopActionsMode(isVisible ? .vlcTracks : .none) + } + + /// Updates the engine-specific actions rendered in the top controls area. + /// + /// - Parameter mode: Top actions mode requested by the current playback engine. + func setTopActionsMode(_ mode: NCVideoControlsTopActionsMode) { + let didChangeMode = state.topActionsMode != mode + var didResetTrackItems = false + + state.topActionsMode = mode + + if mode != .vlcTracks, + (!state.subtitleTrackItems.isEmpty || !state.audioTrackItems.isEmpty) { + state.subtitleTrackItems = [] + state.audioTrackItems = [] + didResetTrackItems = true + } + + guard didChangeMode || didResetTrackItems else { + return + } + + updateHostedView() + } + + /// Updates the subtitle track menu items rendered by the VLC controls. + /// + /// - Parameter items: Available subtitle tracks with selection state. + func setSubtitleTrackMenuItems(_ items: [NCVideoTrackMenuItem]) { + guard state.subtitleTrackItems != items else { + return + } + + state.subtitleTrackItems = items + updateHostedView() + } + + /// Updates the audio track menu items rendered by the VLC controls. + /// + /// - Parameter items: Available audio tracks with selection state. + func setAudioTrackMenuItems(_ items: [NCVideoTrackMenuItem]) { + guard state.audioTrackItems != items else { + return + } + + state.audioTrackItems = items + updateHostedView() + } + + /// Updates the navigation bar reference used by the top actions area. + /// + /// The controls view converts the real navigation bar frame into its own coordinate space + /// so top actions remain aligned below the actual viewer chrome across iPhone, iPad, + /// rotation, and compact/regular layouts. + /// + /// - Parameter navigationBar: Navigation bar used as vertical reference for top actions. + func setTopActionsNavigationBar(_ navigationBar: UINavigationBar?) { + self.navigationBar = navigationBar + updateTopActionsPosition() + } + + override func layoutSubviews() { + super.layoutSubviews() + updateTopActionsPosition() + } + + // MARK: - Configuration + + private func configureLayout() { + backgroundColor = .clear + translatesAutoresizingMaskIntoConstraints = false + + configureHostingView() + configureHitTestProxyViews() + } + + private func configureHostingView() { + let hostingView = hostingController.view! + hostingView.backgroundColor = .clear + hostingView.translatesAutoresizingMaskIntoConstraints = false + + addSubview(hostingView) + + NSLayoutConstraint.activate([ + hostingView.leadingAnchor.constraint(equalTo: leadingAnchor), + hostingView.trailingAnchor.constraint(equalTo: trailingAnchor), + hostingView.topAnchor.constraint(equalTo: topAnchor), + hostingView.bottomAnchor.constraint(equalTo: bottomAnchor) + ]) + } + + private func configureHitTestProxyViews() { + [centerControlsView, bottomControlsView, topActionsView].forEach { proxyView in + proxyView.backgroundColor = .clear + proxyView.isUserInteractionEnabled = false + proxyView.translatesAutoresizingMaskIntoConstraints = false + addSubview(proxyView) + } + + let topActionsTopConstraint = topActionsView.topAnchor.constraint(equalTo: topAnchor) + self.topActionsTopConstraint = topActionsTopConstraint + + NSLayoutConstraint.activate([ + centerControlsView.centerXAnchor.constraint(equalTo: centerXAnchor), + centerControlsView.centerYAnchor.constraint(equalTo: centerYAnchor), + centerControlsView.widthAnchor.constraint(equalToConstant: Self.centerControlsWidth), + centerControlsView.heightAnchor.constraint(equalToConstant: Self.centerControlsHeight), + + bottomControlsView.leadingAnchor.constraint(equalTo: leadingAnchor, constant: Self.bottomControlsHorizontalInset), + bottomControlsView.trailingAnchor.constraint(equalTo: trailingAnchor, constant: -Self.bottomControlsHorizontalInset), + bottomControlsView.bottomAnchor.constraint(equalTo: safeAreaLayoutGuide.bottomAnchor, constant: -Self.bottomControlsBottomInset), + bottomControlsView.heightAnchor.constraint(equalToConstant: Self.bottomControlsHeight), + + topActionsView.leadingAnchor.constraint(equalTo: leadingAnchor), + topActionsView.trailingAnchor.constraint(equalTo: trailingAnchor), + topActionsTopConstraint, + topActionsView.heightAnchor.constraint(equalToConstant: Self.topActionsHeight) + ]) + } + + private func updateTopActionsPosition() { + guard let topActionsTopConstraint else { + return + } + + let topOffset: CGFloat + + if let navigationBar { + let navigationFrame = navigationBar.convert( + navigationBar.bounds, + to: self + ) + topOffset = navigationFrame.maxY + } else { + topOffset = safeAreaInsets.top + } + + guard state.topActionsTopOffset != topOffset else { + return + } + + state.topActionsTopOffset = topOffset + topActionsTopConstraint.constant = topOffset + updateHostedView() + } + + private func updateHostedView() { + hostingController.rootView = makeRootView() + } + + private func makeRootView() -> NCVideoControlsSwiftUIView { + NCVideoControlsSwiftUIView( + state: state, + onSeekBackward: { [weak self] in + guard let self else { + return + } + delegate?.videoControlsDidTapSeekBackward(self) + }, + onPlayPause: { [weak self] in + guard let self else { + return + } + delegate?.videoControlsDidTapPlayPause(self) + }, + onSeekForward: { [weak self] in + guard let self else { + return + } + delegate?.videoControlsDidTapSeekForward(self) + }, + onScrubBegan: { [weak self] in + guard let self else { + return + } + delegate?.videoControlsDidBeginScrubbing(self) + }, + onScrubChanged: { [weak self] progress in + guard let self else { + return + } + state.progress = progress + updateHostedView() + delegate?.videoControls(self, didScrubTo: progress) + }, + onScrubEnded: { [weak self] progress in + guard let self else { + return + } + state.progress = progress + updateHostedView() + delegate?.videoControlsDidEndScrubbing(self, progress: progress) + }, + onPictureInPicture: { [weak self] in + guard let self else { + return + } + delegate?.videoControlsDidTapPictureInPicture(self) + }, + onSubtitle: { [weak self] in + guard let self else { + return + } + delegate?.videoControlsDidTapSubtitle(self) + }, + onAudio: { [weak self] in + guard let self else { + return + } + delegate?.videoControlsDidTapAudio(self) + }, + onSubtitleTrackSelected: { [weak self] index in + guard let self else { + return + } + delegate?.videoControls(self, didSelectSubtitleTrackIndex: index) + }, + onAddExternalSubtitle: { [weak self] in + guard let self else { + return + } + delegate?.videoControlsDidTapAddExternalSubtitle(self) + }, + onAudioTrackSelected: { [weak self] index in + guard let self else { + return + } + delegate?.videoControls(self, didSelectAudioTrackIndex: index) + } + ) + } +} + +// MARK: - SwiftUI State + +private struct NCVideoControlsState: Equatable { + var isPlaying = false + var progress: Float = 0 + var elapsedText = "0:00" + var remainingText = "−0:00" + var isSeekingEnabled = true + var topActionsMode: NCVideoControlsTopActionsMode = .none + var subtitleTrackItems: [NCVideoTrackMenuItem] = [] + var audioTrackItems: [NCVideoTrackMenuItem] = [] + var topActionsTopOffset: CGFloat = 0 +} + +// MARK: - SwiftUI Controls + +private struct NCVideoControlsSwiftUIView: View { + let state: NCVideoControlsState + let onSeekBackward: () -> Void + let onPlayPause: () -> Void + let onSeekForward: () -> Void + let onScrubBegan: () -> Void + let onScrubChanged: (Float) -> Void + let onScrubEnded: (Float) -> Void + let onPictureInPicture: () -> Void + let onSubtitle: () -> Void + let onAudio: () -> Void + let onSubtitleTrackSelected: (_ index: Int32) -> Void + let onAddExternalSubtitle: () -> Void + let onAudioTrackSelected: (_ index: Int32) -> Void + + var body: some View { + GeometryReader { proxy in + ZStack { + centerControls + .position( + x: proxy.size.width / 2, + y: proxy.size.height / 2 + ) + + bottomControls + .frame(height: NCVideoControlsView.bottomControlsHeight) + .padding(.horizontal, NCVideoControlsView.bottomControlsHorizontalInset) + .position( + x: proxy.size.width / 2, + 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) + ) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + .background(Color.clear) + } + + private var topActionsCenterX: CGFloat { + let visibleButtonsCount: CGFloat + + switch state.topActionsMode { + case .none: + visibleButtonsCount = 0 + case .pictureInPicture: + visibleButtonsCount = 1 + case .vlcTracks: + visibleButtonsCount = 2 + } + + let totalWidth = (visibleButtonsCount * NCVideoControlsView.topActionsButtonSize) + (max(0, visibleButtonsCount - 1) * NCVideoControlsView.topActionsSpacing) + return NCVideoControlsView.topActionsHorizontalInset + (totalWidth / 2) + } + + private var centerControls: some View { + HStack(spacing: 28) { + circleButton( + systemName: "gobackward.10", + size: 44, + pointSize: 22, + isEnabled: state.isSeekingEnabled, + action: onSeekBackward + ) + + circleButton( + systemName: state.isPlaying ? "pause.fill" : "play.fill", + size: 62, + pointSize: 36, + isEnabled: true, + action: onPlayPause + ) + + circleButton( + systemName: "goforward.10", + size: 44, + pointSize: 22, + isEnabled: state.isSeekingEnabled, + action: onSeekForward + ) + } + .frame( + width: NCVideoControlsView.centerControlsWidth, + height: NCVideoControlsView.centerControlsHeight + ) + } + + private var bottomControls: some View { + HStack(spacing: NCVideoControlsView.topActionsSpacing) { + timeLabel(state.elapsedText) + .frame(width: 54) + + Slider( + value: Binding( + get: { Double(state.progress) }, + set: { onScrubChanged(Float($0)) } + ), + in: 0...1, + onEditingChanged: { isEditing in + if isEditing { + onScrubBegan() + } else { + onScrubEnded(state.progress) + } + } + ) + .disabled(!state.isSeekingEnabled) + .tint(.black.opacity(0.38)) + .opacity(state.isSeekingEnabled ? 1 : 0.45) + + timeLabel(state.remainingText) + .frame(width: 58) + } + .padding(.horizontal, 18) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(.white.opacity(0.92)) + .clipShape(Capsule()) + .shadow(color: .black.opacity(0.16), radius: 18, x: 0, y: 5) + } + + private var topActions: some View { + HStack(spacing: NCVideoControlsView.topActionsSpacing) { + switch state.topActionsMode { + case .none: + EmptyView() + + case .pictureInPicture: + topActionButton( + systemName: "pip.enter", + pointSize: 18, + action: onPictureInPicture + ) + + case .vlcTracks: + subtitleActionMenu( + systemName: "captions.bubble", + pointSize: 17, + items: state.subtitleTrackItems, + emptyTitle: "_no_subtitles_available_", + onOpen: onSubtitle, + onSelect: onSubtitleTrackSelected, + onAddExternalSubtitle: onAddExternalSubtitle + ) + + topActionMenu( + systemName: "speaker.wave.2", + pointSize: 17, + items: state.audioTrackItems, + emptyTitle: "_no_audio_tracks_available_", + onOpen: onAudio, + onSelect: onAudioTrackSelected + ) + } + } + } + + private func subtitleActionMenu( + systemName: String, + pointSize: CGFloat, + items: [NCVideoTrackMenuItem], + emptyTitle: String, + onOpen: @escaping () -> Void, + onSelect: @escaping (_ index: Int32) -> Void, + onAddExternalSubtitle: @escaping () -> Void + ) -> some View { + return Menu { + if items.isEmpty { + Text(NSLocalizedString(emptyTitle, comment: "")) + } else { + ForEach(items) { item in + Button { + onSelect(item.index) + } label: { + HStack { + Text(item.title) + + if item.isSelected { + Image(systemName: "checkmark") + } + } + } + } + } + + Divider() + + Button { + onAddExternalSubtitle() + } label: { + Label( + NSLocalizedString("_add_external_subtitle_", comment: ""), + systemImage: "plus" + ) + } + } label: { + topActionIcon( + systemName: systemName, + pointSize: pointSize + ) + } + .buttonStyle(.plain) + } + + private func topActionButton( + systemName: String, + pointSize: CGFloat, + action: @escaping () -> Void + ) -> some View { + Button(action: action) { + topActionIcon( + systemName: systemName, + pointSize: pointSize + ) + } + .buttonStyle(.plain) + } + + private func topActionMenu( + systemName: String, + pointSize: CGFloat, + items: [NCVideoTrackMenuItem], + emptyTitle: String, + onOpen: @escaping () -> Void, + onSelect: @escaping (_ index: Int32) -> Void + ) -> some View { + return Menu { + if items.isEmpty { + Text(NSLocalizedString(emptyTitle, comment: "")) + } else { + ForEach(items) { item in + Button { + onSelect(item.index) + } label: { + HStack { + Text(item.title) + + if item.isSelected { + Image(systemName: "checkmark") + } + } + } + } + } + } label: { + topActionIcon( + systemName: systemName, + pointSize: pointSize + ) + } + .buttonStyle(.plain) + } + + private func topActionIcon( + systemName: String, + pointSize: CGFloat + ) -> some View { + Image(systemName: systemName) + .font(.system(size: pointSize, weight: .regular)) + .foregroundStyle(.black) + .frame( + width: NCVideoControlsView.topActionsButtonSize, + height: NCVideoControlsView.topActionsButtonSize + ) + .background(.white.opacity(0.92)) + .clipShape(Circle()) + .shadow(color: .black.opacity(0.16), radius: 14, x: 0, y: 4) + } + + private func circleButton( + systemName: String, + size: CGFloat, + pointSize: CGFloat, + isEnabled: Bool, + action: @escaping () -> Void + ) -> some View { + Button { + guard isEnabled else { + return + } + + action() + } label: { + Image(systemName: systemName) + .font(.system(size: pointSize, weight: .regular)) + .foregroundStyle(.black) + .frame(width: size, height: size) + .background(.white.opacity(0.92)) + .clipShape(Circle()) + .shadow(color: .black.opacity(0.16), radius: 14, x: 0, y: 4) + } + .buttonStyle(.plain) + .transaction { transaction in + transaction.animation = nil + } + } + + private func timeLabel(_ text: String) -> some View { + Text(text) + .font(.system(size: 15, weight: .medium, design: .rounded).monospacedDigit()) + .foregroundStyle(.black.opacity(0.72)) + .lineLimit(1) + .minimumScaleFactor(0.85) + } +} + +// MARK: - Preview + +#Preview("Video Controls") { + NCVideoControlsPreviewView() + .frame(width: 393, height: 852) + .background(Color.black) + .ignoresSafeArea() +} + +private struct NCVideoControlsPreviewView: UIViewRepresentable { + func makeUIView(context: Context) -> UIView { + let containerView = UIView() + containerView.backgroundColor = .black + + let controlsView = NCVideoControlsView() + controlsView.translatesAutoresizingMaskIntoConstraints = false + controlsView.setTopActionsMode(.pictureInPicture) + // controlsView.setTopActionsMode(.vlcTracks) + controlsView.updatePlayPauseButton(isPlaying: true) + controlsView.updateProgress( + progress: 0.42, + elapsedText: "1:24", + remainingText: "−2:31" + ) + controlsView.setSubtitleTrackMenuItems([ + NCVideoTrackMenuItem(index: -1, title: "Disable", isSelected: true), + NCVideoTrackMenuItem(index: 0, title: "English", isSelected: false) + ]) + controlsView.setAudioTrackMenuItems([ + NCVideoTrackMenuItem(index: 1, title: "Italian", isSelected: true), + NCVideoTrackMenuItem(index: 2, title: "English", isSelected: false) + ]) + + containerView.addSubview(controlsView) + + NSLayoutConstraint.activate([ + controlsView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor), + controlsView.trailingAnchor.constraint(equalTo: containerView.trailingAnchor), + controlsView.topAnchor.constraint(equalTo: containerView.topAnchor), + controlsView.bottomAnchor.constraint(equalTo: containerView.bottomAnchor) + ]) + + return containerView + } + + func updateUIView( + _ uiView: UIView, + context: Context + ) { } +} diff --git a/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoPlaybackController.swift b/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoPlaybackController.swift new file mode 100644 index 0000000000..217c9258c1 --- /dev/null +++ b/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoPlaybackController.swift @@ -0,0 +1,524 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Marino Faggiana +// SPDX-License-Identifier: GPL-3.0-or-later + +import AVFoundation +import Foundation +import NextcloudKit + +// MARK: - Video Playback Engine + +/// Describes the currently rendered video playback engine. +/// +/// The engine is owned by `NCVideoPlaybackController`. +/// Views only render the selected engine; they do not own AVFoundation playback resources. +/// VLC playback is rendered by a dedicated legacy-style UIKit VLC view. +enum NCVideoPlaybackEngine { + /// No playable engine is currently ready. + case loading + + /// Native AVFoundation playback using a resolved playable URL. + /// + /// The real fullscreen AVPlayer is owned by `NCVideoAVPlayerViewController`. + case avFoundation(url: URL) + + /// VLC fallback playback using a resolved playable URL. + /// + /// The VLC player itself is owned by `NCVideoVLCViewerContentView`, not by this controller. + case vlc(url: URL) + + /// Playback could not be prepared. + case failed(message: String) +} + +// MARK: - Video Playback Controller + +/// Shared video playback controller used by the SwiftUI media viewer. +/// +/// This controller owns AVFoundation playback resources and resolves whether +/// a video should be rendered through AVFoundation or VLC. +/// +/// VLC is intentionally not owned here. The VLC renderer uses a legacy-style +/// UIKit controller with a stable `UIImageView` drawable, matching the old +/// media viewer behavior. +@MainActor +final class NCVideoPlaybackController: ObservableObject { + static let shared = NCVideoPlaybackController() + + // MARK: - Published State + + @Published private(set) var engine: NCVideoPlaybackEngine = .loading + + // MARK: - Private State + + private var avProbePlayer: AVPlayer? + private var avProbeItem: AVPlayerItem? + private var statusObservation: NSKeyValueObservation? + private var timeoutTask: Task? + + private var currentOcId: String? + private var currentEtag: String? + private var currentURL: URL? + private var currentFileName: String? + private var loadToken = UUID() + + private let fallbackTimeoutMilliseconds = 1_500 + + private init() { } + + // MARK: - Public API + + /// Returns whether the requested metadata and URL already match the current video. + /// + /// This check is used for local videos, where the playable file URL is known before + /// loading. It prevents unnecessary reloads while still allowing the viewer to switch + /// from a remote URL to a newly available local file URL. + /// + /// - Parameters: + /// - ocId: Nextcloud file identifier. + /// - etag: Metadata ETag. + /// - url: Expected local or remote playable URL. + /// - Returns: True when the current loaded media matches the supplied identity and URL. + func isCurrentVideo( + ocId: String, + etag: String, + url: URL + ) -> Bool { + currentOcId == ocId && + currentEtag == etag && + currentURL == url + } + + /// Returns whether the requested metadata already matches the current video. + /// + /// This check is used for remote videos where the resolved playback URL is not + /// known before the resolver runs. It prevents SwiftUI rebuilds, such as rotation, + /// from resolving and loading the same remote video again. + /// + /// Local videos should use the URL-based overload so the viewer can still switch + /// from a remote URL to a newly available local file URL. + /// + /// - Parameters: + /// - ocId: Nextcloud file identifier. + /// - etag: Metadata ETag. + /// - Returns: True when the current loaded media matches the supplied metadata. + func isCurrentVideo( + ocId: String, + etag: String + ) -> Bool { + currentOcId == ocId && + currentEtag == etag && + currentURL != nil + } + + /// Loads a video URL if it is not already loaded. + /// + /// Calling this method again for the same `ocId`, `etag`, and URL is idempotent. + /// It does not stop, recreate, or restart the existing AV player. For VLC, + /// it keeps the same engine URL so the VLC view can reuse its own controller. + /// + /// - Parameters: + /// - metadata: Video metadata used as playback identity. + /// - url: Local or remote playable URL. + /// - fileName: Original metadata file name used to detect legacy formats. + /// - userAgent: Optional User-Agent used by VLC for remote playback. + /// - httpHeaders: Optional HTTP headers used by AVFoundation for remote playback. + /// - shouldAutoPlay: Whether playback should start automatically. + func loadVideo( + metadata: tableMetadata, + url: URL, + fileName: String, + userAgent: String?, + httpHeaders: [String: String], + shouldAutoPlay: Bool + ) { + if isSameLoadedVideo( + metadata: metadata, + url: url + ) { + resumeCurrentPlaybackIfNeeded(shouldAutoPlay: shouldAutoPlay) + + nkLog( + tag: NCGlobal.shared.logTagViewer, + emoji: .debug, + message: "VIDEO controller reuse existing player ocId \(metadata.ocId)", + consoleOnly: true + ) + + return + } + + stop() + + let token = UUID() + loadToken = token + currentOcId = metadata.ocId + currentEtag = metadata.etag + currentURL = url + currentFileName = fileName + engine = .loading + + if url.isFileURL, + !isValidLocalFile(url: url) { + engine = .failed(message: "Video file is not available.") + return + } + + configureAudioSession() + + if shouldUseVLCWithoutAVFoundation( + url: url, + fileName: fileName + ) { + resolveWithVLC( + url: url, + reason: "direct legacy format \(resolvedVideoExtension(url: url, fileName: fileName))", + token: token + ) + return + } + + prepareAVFoundation( + metadata: metadata, + url: url, + httpHeaders: url.isFileURL ? [:] : httpHeaders, + shouldAutoPlay: shouldAutoPlay, + token: token + ) + + startFallbackTimeout( + url: url, + token: token + ) + } + + /// Stops the current video only if the supplied page owns playback. + /// + /// - Parameter ocId: Page file identifier. + func stopIfCurrent(ocId: String) { + guard currentOcId == ocId else { + return + } + + stop() + } + + /// Stops current playback state and releases AVFoundation resources. + /// + /// VLC playback is stopped by `NCVideoVLCViewerContentView` through + /// `.ncMediaViewerStopPlayback`, because the VLC player is owned by that view. + func stop() { + loadToken = UUID() + + timeoutTask?.cancel() + timeoutTask = nil + + statusObservation?.invalidate() + statusObservation = nil + + avProbePlayer?.pause() + avProbePlayer = nil + avProbeItem = nil + + currentOcId = nil + currentEtag = nil + currentURL = nil + currentFileName = nil + + engine = .loading + } + + // MARK: - AVFoundation + + /// Prepares an AVFoundation player item and observes its readiness. + private func prepareAVFoundation( + metadata: tableMetadata, + url: URL, + httpHeaders: [String: String], + shouldAutoPlay: Bool, + token: UUID + ) { + let assetOptions: [String: Any]? = httpHeaders.isEmpty + ? nil + : [ + "AVURLAssetHTTPHeaderFieldsKey": httpHeaders + ] + + let asset = AVURLAsset( + url: url, + options: assetOptions + ) + + let item = AVPlayerItem(asset: asset) + let player = AVPlayer(playerItem: item) + + player.actionAtItemEnd = .pause + + avProbeItem = item + avProbePlayer = player + + statusObservation = item.observe( + \.status, + options: [.initial, .new] + ) { [weak self] item, _ in + Task { @MainActor in + guard let self else { + return + } + + guard self.isCurrentLoad( + url: url, + token: token + ) else { + return + } + + switch item.status { + case .readyToPlay: + self.resolveWithAVFoundation( + url: url, + player: player, + shouldAutoPlay: shouldAutoPlay, + token: token + ) + + case .failed: + self.resolveWithVLC( + url: url, + reason: item.error?.localizedDescription ?? "AVFoundation failed.", + token: token + ) + + case .unknown: + break + + @unknown default: + self.resolveWithVLC( + url: url, + reason: "AVFoundation returned an unknown status.", + token: token + ) + } + } + } + } + + /// Selects AVFoundation as the active rendering engine. + /// + /// - Parameters: + /// - url: The resolved playable URL. + /// - player: Prepared AVFoundation player. + /// - shouldAutoPlay: Whether playback should start after AVFoundation becomes ready. + /// - token: Load token used to ignore stale callbacks. + private func resolveWithAVFoundation( + url: URL, + player: AVPlayer, + shouldAutoPlay: Bool, + token: UUID + ) { + guard loadToken == token, + avProbePlayer === player else { + return + } + + timeoutTask?.cancel() + timeoutTask = nil + + engine = .avFoundation(url: url) + + nkLog( + tag: NCGlobal.shared.logTagViewer, + emoji: .debug, + message: "VIDEO engine AVFoundation ready autoplay disabled requested \(shouldAutoPlay)", + consoleOnly: true + ) + } + + /// Starts a timeout after which VLC is selected if AVFoundation is still loading. + private func startFallbackTimeout( + url: URL, + token: UUID + ) { + timeoutTask = Task { [weak self] in + guard let self else { + return + } + + try? await Task.sleep( + for: .milliseconds(self.fallbackTimeoutMilliseconds) + ) + + await MainActor.run { + guard self.isCurrentLoad( + url: url, + token: token + ) else { + return + } + + if case .loading = self.engine { + self.resolveWithVLC( + url: url, + reason: "AVFoundation timeout.", + token: token + ) + } + } + } + } + + // MARK: - VLC + + /// Selects VLC as the active rendering engine. + /// + /// This does not create or own the VLC player. It only exposes the URL to + /// `NCVideoVLCViewerContentView`, which owns its legacy-style VLC controller. + private func resolveWithVLC( + url: URL, + reason: String, + token: UUID + ) { + guard isCurrentLoad( + url: url, + token: token + ) else { + return + } + + timeoutTask?.cancel() + timeoutTask = nil + + statusObservation?.invalidate() + statusObservation = nil + + avProbePlayer?.pause() + avProbePlayer = nil + avProbeItem = nil + + engine = .vlc(url: url) + + nkLog( + tag: NCGlobal.shared.logTagViewer, + emoji: .debug, + message: "VIDEO engine VLC: \(reason)", + consoleOnly: true + ) + } + + // MARK: - State Helpers + + /// Returns whether the supplied media request is already loaded. + private func isSameLoadedVideo( + metadata: tableMetadata, + url: URL + ) -> Bool { + currentOcId == metadata.ocId && + currentEtag == metadata.etag && + currentURL == url + } + + /// Returns whether a callback belongs to the current load request. + private func isCurrentLoad( + url: URL, + token: UUID + ) -> Bool { + loadToken == token && currentURL == url + } + + /// Resumes the current AV player if requested. + /// + /// VLC auto-play is handled by `NCVideoVLCViewerContentView`. + private func resumeCurrentPlaybackIfNeeded(shouldAutoPlay: Bool) { + guard shouldAutoPlay else { + return + } + + switch engine { + case .avFoundation: + break + + case .vlc, + .loading, + .failed: + break + } + } + + // MARK: - Private Helpers + + /// Configures the audio session for video playback. + private func configureAudioSession() { + do { + try AVAudioSession.sharedInstance().setCategory( + .playback, + mode: .moviePlayback, + options: [] + ) + + try AVAudioSession.sharedInstance().setActive(true) + } catch { + nkLog( + tag: NCGlobal.shared.logTagViewer, + emoji: .error, + message: "VIDEO audio session error: \(error.localizedDescription)", + consoleOnly: true + ) + } + } + + /// Returns whether a video format should bypass AVFoundation and use VLC directly. + private func shouldUseVLCWithoutAVFoundation( + url: URL, + fileName: String + ) -> Bool { + let pathExtension = resolvedVideoExtension( + url: url, + fileName: fileName + ) + + let legacyVideoExtensions: Set = [ + "avi", + "divx", + "xvid", + "wmv", + "flv", + "vob", + "mkv" + ] + + return legacyVideoExtensions.contains(pathExtension) + } + + /// Resolves the best available video extension. + private func resolvedVideoExtension( + url: URL, + fileName: String + ) -> String { + let metadataExtension = URL(fileURLWithPath: fileName) + .pathExtension + .lowercased() + + if !metadataExtension.isEmpty { + return metadataExtension + } + + return url.pathExtension.lowercased() + } + + /// Checks whether a local file exists and has a non-zero size. + private func isValidLocalFile(url: URL) -> Bool { + let path = url.path + + guard FileManager.default.fileExists(atPath: path) else { + return false + } + + guard let attributes = try? FileManager.default.attributesOfItem(atPath: path), + let fileSize = attributes[.size] as? Int64, + fileSize > 0 else { + return false + } + + return true + } +} diff --git a/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoViewerContentView.swift b/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoViewerContentView.swift new file mode 100644 index 0000000000..0314436c9f --- /dev/null +++ b/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoViewerContentView.swift @@ -0,0 +1,820 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Marino Faggiana +// SPDX-License-Identifier: GPL-3.0-or-later + +import SwiftUI +import NextcloudKit + +// MARK: - Video Viewer Content View + +/// Displays a video using the shared video playback controller. +/// +/// This view does not own the AVPlayer directly. +/// AVFoundation playback is presented as a separate UIKit-only controller through +/// `NCVideoAVPlayerPresenter`, outside the SwiftUI paging hierarchy. +/// VLC playback is presented as a separate UIKit-only controller through +/// `NCVideoVLCPresenter`, outside the SwiftUI paging hierarchy. +/// +/// Loading rules: +/// - If a valid local URL is already available, it is used directly. +/// - The remote resolver is used only when no local URL is available. +/// - If the same video is already loaded, the existing player is reused. +/// - If the page is not selected, the view does not load a new video. +/// - AVFoundation is presented outside SwiftUI when selected. +/// - VLC is presented outside SwiftUI when selected. +/// - Real global stop events are handled through `.ncMediaViewerStopPlayback`. +struct NCVideoViewerContentView: View { + let metadata: tableMetadata + let localURL: URL? + let previewURL: URL? + let userAgent: String? + let isSelected: Bool + let contextMenuController: NCMainTabBarController? + let navigationBar: UINavigationBar? + let canGoPrevious: Bool + let canGoNext: Bool + let onPreviousPage: (() -> Void)? + let onNextPage: (() -> Void)? + let onClose: ((_ ocId: String?) -> Void)? + + @ObservedObject private var playback = NCVideoPlaybackController.shared + + @State private var errorMessage: String? + @State private var presentedAVPlayerURL: URL? + @State private var resolvedVideoURL: URL? + @State private var presentedVLCURL: URL? + @State private var loadGeneration = UUID() + + private let resolver = NCVideoURLResolver() + + @MainActor + private static var resolvingTasks = [String: Task<(url: URL?, autoplay: Bool, error: NKError), Never>]() + + init( + metadata: tableMetadata, + localURL: URL?, + previewURL: URL? = nil, + userAgent: String? = nil, + isSelected: Bool = true, + contextMenuController: NCMainTabBarController? = nil, + navigationBar: UINavigationBar? = nil, + canGoPrevious: Bool = false, + canGoNext: Bool = false, + onPreviousPage: (() -> Void)? = nil, + onNextPage: (() -> Void)? = nil, + onClose: ((_ ocId: String?) -> Void)? = nil + ) { + self.metadata = metadata + self.localURL = localURL + self.previewURL = previewURL + self.userAgent = userAgent + self.isSelected = isSelected + self.contextMenuController = contextMenuController + self.navigationBar = navigationBar + self.canGoPrevious = canGoPrevious + self.canGoNext = canGoNext + self.onPreviousPage = onPreviousPage + self.onNextPage = onNextPage + self.onClose = onClose + } + + var body: some View { + ZStack { + Color.black + .ignoresSafeArea() + + previewPlaceholderView + + if let errorMessage { + failedView(errorMessage) + } else { + switch playback.engine { + case .loading: + EmptyView() + + case .avFoundation(let url): + if isSelected, + isCurrentPlaybackVideo() { + Color.clear + .ignoresSafeArea() + .allowsHitTesting(false) + .onAppear { + presentAVPlayerIfSelected(url: url) + } + .onChange(of: url) { _, newURL in + presentedAVPlayerURL = nil + presentAVPlayerIfSelected(url: newURL) + } + .onChange(of: isSelected) { _, selected in + guard selected else { + return + } + + presentAVPlayerIfSelected(url: url) + } + } else { + EmptyView() + } + + case .vlc(let url): + if isSelected, + isCurrentPlaybackVideo() { + Color.clear + .ignoresSafeArea() + .allowsHitTesting(false) + .onAppear { + presentVLCIfSelected(url: url) + } + .onChange(of: url) { _, newURL in + presentedVLCURL = nil + presentVLCIfSelected(url: newURL) + } + .onChange(of: isSelected) { _, selected in + guard selected else { + return + } + + presentVLCIfSelected(url: url) + } + } else { + EmptyView() + } + + case .failed(let message): + if isSelected { + failedView(message) + } else { + EmptyView() + } + } + } + } + .background(Color.black) + .task(id: taskIdentifier) { + await loadVideoIfSelected() + } + .onChange(of: isSelected) { _, selected in + loadGeneration = UUID() + + guard selected else { + stopPlaybackForDeselection() + return + } + + Task { + await loadVideoIfSelected() + } + } + .onReceive(NotificationCenter.default.publisher(for: .ncMediaViewerStopPlayback)) { _ in + stopPlaybackForDeselection() + } + .onDisappear { + // Do not stop or hide the player here. + // SwiftUI can call onDisappear during rotation or layout rebuilds. + // Real playback stops are driven by `.ncMediaViewerStopPlayback`. + } + } + + // MARK: - Views + + @ViewBuilder + private var previewPlaceholderView: some View { + NCVideoPreviewPlaceholderView(previewURL: previewURL) + } + + private func failedView(_ message: String) -> some View { + VStack(spacing: 12) { + Image(systemName: "video.slash") + .font(.system(size: 44, weight: .regular)) + + Text("Video not available") + .font(.headline) + + Text(message) + .font(.caption) + .foregroundStyle(.white.opacity(0.6)) + .multilineTextAlignment(.center) + .padding(.horizontal, 24) + } + .foregroundStyle(.white) + .padding(24) + } + + // MARK: - Loading + + /// Stops fullscreen video playback when this video page is no longer selected. + /// + /// This is intentionally not done from `onDisappear`, because SwiftUI may call + /// `onDisappear` during rotation or layout rebuilds. A transition from selected + /// to not selected is instead a real page change. + @MainActor + private func stopPlaybackForDeselection() { + presentedAVPlayerURL = nil + resolvedVideoURL = nil + presentedVLCURL = nil + + NCVideoAVPlayerPresenter.dismiss() + NCVideoVLCPresenter.dismiss() + playback.stop() + } + + private var taskIdentifier: String { + let localIdentifier = localURL?.absoluteString ?? "remote" + return "\(metadata.ocId)|\(metadata.etag)|\(localIdentifier)" + } + + /// Loads or reveals the video only when this page is still selected and stable. + /// + /// This is the single entry point for selected video loading. + /// It is used by both `.task(id:)` and `isSelected` changes because SwiftUI may + /// create a video page before it becomes selected, and `.task(id:)` may not run + /// again when the same page later becomes selected. + @MainActor + private func loadVideoIfSelected() async { + let expectedTaskIdentifier = taskIdentifier + let expectedLoadGeneration = loadGeneration + + guard await waitForStableSelection( + expectedTaskIdentifier: expectedTaskIdentifier, + expectedLoadGeneration: expectedLoadGeneration + ) else { + return + } + + errorMessage = nil + + if isCurrentPlaybackVideo() { + revealCurrentPlaybackIfNeeded() + return + } + + await resolveAndLoadVideo( + expectedTaskIdentifier: expectedTaskIdentifier, + expectedLoadGeneration: expectedLoadGeneration + ) + } + + /// Waits briefly before allowing a selected video page to resolve or load playback. + /// + /// Fast swipe gestures can make intermediate video pages selected for a very short time. + /// This gate keeps those transient pages as preview-only without slowing image paging, + /// because it exists only inside the video viewer. + /// + /// - Parameters: + /// - expectedTaskIdentifier: Task identity captured before the delay. + /// - expectedLoadGeneration: Load generation captured before the delay. + /// - Returns: True if the page is still selected and still represents the same load request. + @MainActor + private func waitForStableSelection( + expectedTaskIdentifier: String, + expectedLoadGeneration: UUID + ) async -> Bool { + guard isSelected else { + return false + } + + do { + try await Task.sleep(nanoseconds: Self.videoSelectionSettleDelayNanoseconds) + } catch { + return false + } + + guard !Task.isCancelled else { + return false + } + + guard expectedTaskIdentifier == taskIdentifier else { + return false + } + + guard expectedLoadGeneration == loadGeneration else { + return false + } + + return isSelected + } + + /// Resolves the playable video URL and loads it into the shared playback controller. + /// + /// Local URLs are loaded directly and have priority over remote resolution. + /// + /// - Parameters: + /// - expectedTaskIdentifier: Task identity captured before starting async resolution. + /// - expectedLoadGeneration: Load generation captured before starting async resolution. + @MainActor + private func resolveAndLoadVideo( + expectedTaskIdentifier: String, + expectedLoadGeneration: UUID + ) async { + errorMessage = nil + + if let localURL { + loadResolvedVideo( + url: localURL, + autoplay: true, + expectedTaskIdentifier: expectedTaskIdentifier, + expectedLoadGeneration: expectedLoadGeneration, + source: "local" + ) + return + } + + nkLog( + tag: NCGlobal.shared.logTagViewer, + emoji: .debug, + message: "VIDEO resolve start ocId \(metadata.ocId), fileName \(metadata.fileNameView), fileId \(metadata.fileId)", + consoleOnly: true + ) + + let result = await resolvedVideoURL( + taskIdentifier: expectedTaskIdentifier + ) + + guard !Task.isCancelled else { + nkLog( + tag: NCGlobal.shared.logTagViewer, + emoji: .debug, + message: "VIDEO resolve cancelled ocId \(metadata.ocId)", + consoleOnly: true + ) + return + } + + guard expectedTaskIdentifier == taskIdentifier else { + nkLog( + tag: NCGlobal.shared.logTagViewer, + emoji: .debug, + message: "VIDEO resolve ignored stale task ocId \(metadata.ocId)", + consoleOnly: true + ) + return + } + + guard expectedLoadGeneration == loadGeneration else { + nkLog( + tag: NCGlobal.shared.logTagViewer, + emoji: .debug, + message: "VIDEO resolve ignored stale generation ocId \(metadata.ocId)", + consoleOnly: true + ) + return + } + + guard isSelected else { + nkLog( + tag: NCGlobal.shared.logTagViewer, + emoji: .debug, + message: "VIDEO resolve skipped final not selected ocId \(metadata.ocId), fileName \(metadata.fileNameView)", + consoleOnly: true + ) + return + } + + guard result.error == .success, + let url = result.url else { + nkLog( + tag: NCGlobal.shared.logTagViewer, + emoji: .error, + message: "VIDEO resolve failed ocId \(metadata.ocId), error \(result.error.errorDescription)", + consoleOnly: true + ) + + errorMessage = result.error.errorDescription + return + } + + loadResolvedVideo( + url: url, + autoplay: result.autoplay, + expectedTaskIdentifier: expectedTaskIdentifier, + expectedLoadGeneration: expectedLoadGeneration, + source: "resolved" + ) + } + + /// Loads a resolved video URL into the shared playback controller. + /// + /// - Parameters: + /// - url: Local or remote playable URL. + /// - autoplay: Whether the resolved URL requests autoplay. + /// - expectedTaskIdentifier: Task identity used to ignore stale async work. + /// - expectedLoadGeneration: Load generation used to ignore stale async work. + /// - source: Debug source label used in logs. + @MainActor + private func loadResolvedVideo( + url: URL, + autoplay: Bool, + expectedTaskIdentifier: String, + expectedLoadGeneration: UUID, + source: String + ) { + guard expectedTaskIdentifier == taskIdentifier else { + nkLog( + tag: NCGlobal.shared.logTagViewer, + emoji: .debug, + message: "VIDEO load ignored stale task ocId \(metadata.ocId), source \(source), url \(url.absoluteString)", + consoleOnly: true + ) + return + } + + guard expectedLoadGeneration == loadGeneration else { + nkLog( + tag: NCGlobal.shared.logTagViewer, + emoji: .debug, + message: "VIDEO load ignored stale generation ocId \(metadata.ocId), source \(source), url \(url.absoluteString)", + consoleOnly: true + ) + return + } + + guard isSelected else { + nkLog( + tag: NCGlobal.shared.logTagViewer, + emoji: .debug, + message: "VIDEO load skipped not selected ocId \(metadata.ocId), source \(source), url \(url.absoluteString)", + consoleOnly: true + ) + return + } + + nkLog( + tag: NCGlobal.shared.logTagViewer, + emoji: .debug, + message: "VIDEO load \(source) url \(url.absoluteString), isFileURL \(url.isFileURL), fileName \(resolvedFileName)", + consoleOnly: true + ) + + resolvedVideoURL = url + + playback.loadVideo( + metadata: metadata, + url: url, + fileName: resolvedFileName, + userAgent: userAgent, + httpHeaders: httpHeaders(for: url), + shouldAutoPlay: autoplay + ) + } + + /// Returns HTTP headers for remote video playback. + /// + /// Local file URLs do not need HTTP headers. + /// + /// - Parameter url: Resolved video URL. + /// - Returns: HTTP headers for AVFoundation remote playback. + private func httpHeaders(for url: URL) -> [String: String] { + guard !url.isFileURL else { + return [:] + } + + guard let userAgent, + !userAgent.isEmpty else { + return [:] + } + + return [ + "User-Agent": userAgent + ] + } + + // MARK: - Playback Selection + + /// Returns whether this page already owns an active playback engine. + /// + /// Local videos require an exact URL match. + /// Remote videos can only be checked by metadata because the direct-download URL + /// is resolved lazily when the selected page loads. + /// + /// The playback engine must already be renderable. A loading or failed engine is + /// not considered reusable, otherwise a cached video page could remain stuck as a + /// plain preview when it becomes selected again. + private func isCurrentPlaybackVideo() -> Bool { + switch playback.engine { + case .avFoundation, + .vlc: + break + + case .loading, + .failed: + return false + } + + if let localURL { + return playback.isCurrentVideo( + ocId: metadata.ocId, + etag: metadata.etag, + url: localURL + ) + } + + return playback.isCurrentVideo( + ocId: metadata.ocId, + etag: metadata.etag + ) + } + + /// Reveals the current playback engine without changing the playback state. + /// + /// This is used when SwiftUI rebuilds the selected page, for example during + /// rotation. It must not call `play()` because the user may have paused the video. + @MainActor + private func revealCurrentPlaybackIfNeeded() { + switch playback.engine { + case .avFoundation(let url): + presentAVPlayerIfSelected(url: url) + + case .vlc(let url): + presentVLCIfSelected(url: url) + + case .loading, + .failed: + break + } + } + + /// Presents the UIKit-only AVPlayer viewer when this page is selected. + /// + /// - Parameter url: Local or remote playable URL selected by AVFoundation probing. + @MainActor + private func presentAVPlayerIfSelected(url: URL) { + guard isSelected else { + return + } + + guard presentedAVPlayerURL != url else { + return + } + + presentedAVPlayerURL = url + + NCVideoAVPlayerPresenter.present( + metadata: metadata, + url: url, + previewURL: previewURL, + userAgent: userAgent, + contextMenuController: contextMenuController, + canGoPrevious: canGoPrevious, + canGoNext: canGoNext, + onPrevious: goToPreviousPageFromAVPlayer, + onNext: goToNextPageFromAVPlayer, + onClose: closeFromFullscreenVideo + ) + } + + /// Moves to the previous media item from the UIKit-only AVPlayer controller. + @MainActor + private func goToPreviousPageFromAVPlayer() { + presentedAVPlayerURL = nil + NCVideoAVPlayerPresenter.dismiss() + onPreviousPage?() + } + + /// Moves to the next media item from the UIKit-only AVPlayer controller. + @MainActor + private func goToNextPageFromAVPlayer() { + presentedAVPlayerURL = nil + NCVideoAVPlayerPresenter.dismiss() + onNextPage?() + } + + /// Closes the full media viewer from a fullscreen video controller. + /// + /// - Parameter ocId: Optional Nextcloud file identifier of the fullscreen video being closed. + @MainActor + private func closeFromFullscreenVideo(ocId: String?) { + presentedAVPlayerURL = nil + presentedVLCURL = nil + playback.stop() + onClose?(ocId) + } + + /// Presents the UIKit-only VLC fallback viewer when this page is selected. + /// + /// - Parameter url: Local or remote playable URL. + @MainActor + private func presentVLCIfSelected(url: URL) { + guard isSelected else { + return + } + + guard presentedVLCURL != url else { + return + } + + presentedVLCURL = url + + NCVideoVLCPresenter.present( + metadata: metadata, + url: url, + previewURL: previewURL, + userAgent: userAgent, + contextMenuController: contextMenuController, + canGoPrevious: canGoPrevious, + canGoNext: canGoNext, + onPrevious: goToPreviousPageFromVLC, + onNext: goToNextPageFromVLC, + onClose: closeFromFullscreenVideo + ) + } + + /// Moves to the previous media item from the UIKit-only VLC controller. + @MainActor + private func goToPreviousPageFromVLC() { + presentedVLCURL = nil + NCVideoVLCPresenter.dismiss() + onPreviousPage?() + } + + /// Moves to the next media item from the UIKit-only VLC controller. + @MainActor + private func goToNextPageFromVLC() { + presentedVLCURL = nil + NCVideoVLCPresenter.dismiss() + onNextPage?() + } + + // MARK: - In-Flight Resolution Cache + + /// Resolves a video URL through a shared in-flight task cache. + /// + /// SwiftUI can temporarily create multiple video page views for the same page while + /// the selected state transitions from prefetched preview to selected video state. + /// A shared task lets duplicated views await the same direct-link resolution instead + /// of starting duplicate requests or skipping resolution while the original view is + /// being cancelled. + /// + /// - Parameter taskIdentifier: Stable video task identity. + /// - Returns: Resolved video URL, autoplay preference, and Nextcloud error. + @MainActor + private func resolvedVideoURL( + taskIdentifier: String + ) async -> (url: URL?, autoplay: Bool, error: NKError) { + if let existingTask = Self.resolvingTasks[taskIdentifier] { + return await existingTask.value + } + + let task = Task { + await resolver.getVideoURL(metadata: metadata) + } + + Self.resolvingTasks[taskIdentifier] = task + + let result = await task.value + Self.resolvingTasks[taskIdentifier] = nil + + return result + } + + // MARK: - Helpers + + /// Delay used only for selected video pages before resolving or loading playback. + /// + /// This protects fast swipe gestures from starting remote resolution or VLC/AVPlayer + /// for transient video pages, without affecting image paging responsiveness. + private static let videoSelectionSettleDelayNanoseconds: UInt64 = 150_000_000 + + private var resolvedFileName: String { + if !metadata.fileNameView.isEmpty { + return metadata.fileNameView + } + + return metadata.fileName + } +} + +// MARK: - Video Preview Placeholder + +/// Displays a static, non-interactive preview for video pages. +/// +/// Video previews are shown only when a local preview image is already available. +/// When no preview is available, the view keeps a stable black background to avoid +/// extra icon-to-preview-to-player transitions. +private struct NCVideoPreviewPlaceholderView: View { + let previewURL: URL? + + var body: some View { + ZStack { + Color.black + .ignoresSafeArea() + + if let image = previewImage { + Image(uiImage: image) + .resizable() + .scaledToFit() + .allowsHitTesting(false) + } + } + } + + private var previewImage: UIImage? { + guard let previewURL, + previewURL.isFileURL else { + return nil + } + + return UIImage(contentsOfFile: previewURL.path) + } +} + +// MARK: - Video URL Resolution + +/// Resolves the playable URL for a video item. +/// +/// Resolution order: +/// - Explicit metadata URL. +/// - Local provider storage file. +/// - Nextcloud direct download URL. +struct NCVideoURLResolver { + private let utilityFileSystem = NCUtilityFileSystem() + + /// Resolves the playable URL for a video metadata object. + /// + /// - Parameter metadata: Video metadata. + /// - Returns: Resolved video URL, autoplay preference, and Nextcloud error. + func getVideoURL( + metadata: tableMetadata + ) async -> (url: URL?, autoplay: Bool, error: NKError) { + if !metadata.url.isEmpty { + if metadata.url.hasPrefix("/") { + return ( + url: URL(fileURLWithPath: metadata.url), + autoplay: true, + error: .success + ) + } else { + return ( + url: URL(string: metadata.url), + autoplay: true, + error: .success + ) + } + } + + if utilityFileSystem.fileProviderStorageExists(metadata) { + let localPath = utilityFileSystem.getDirectoryProviderStorageOcId( + metadata.ocId, + fileName: metadata.fileNameView, + userId: metadata.userId, + urlBase: metadata.urlBase + ) + + return ( + url: URL(fileURLWithPath: localPath), + autoplay: true, + error: .success + ) + } + + return await getDirectDownloadURL(metadata: metadata) + } + + /// Resolves a direct download URL from Nextcloud. + /// + /// - Parameter metadata: Video metadata. + /// - Returns: Direct download URL, autoplay preference, and Nextcloud error. + private func getDirectDownloadURL( + metadata: tableMetadata + ) async -> (url: URL?, autoplay: Bool, error: NKError) { + await withCheckedContinuation { continuation in + NextcloudKit.shared.getDirectDownload( + fileId: metadata.fileId, + account: metadata.account + ) { task in + Task { + let identifier = await NCNetworking.shared.networkingTasks.createIdentifier( + account: metadata.account, + path: metadata.fileId, + name: "getDirectDownload" + ) + + await NCNetworking.shared.networkingTasks.track( + identifier: identifier, + task: task + ) + } + } completion: { _, urlString, _, error in + guard error == .success, + let urlString, + let url = URL(string: urlString) else { + continuation.resume( + returning: ( + url: nil, + autoplay: false, + error: error + ) + ) + return + } + + continuation.resume( + returning: ( + url: url, + autoplay: false, + error: error + ) + ) + } + } + } +} diff --git a/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCPresenter.swift b/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCPresenter.swift new file mode 100644 index 0000000000..8b96cd497a --- /dev/null +++ b/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCPresenter.swift @@ -0,0 +1,240 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Marino Faggiana +// SPDX-License-Identifier: GPL-3.0-or-later + +import UIKit +import NextcloudKit + +// MARK: - VLC Presenter + +/// Presents one UIKit-only VLC fallback viewer outside the SwiftUI paging hierarchy. +/// +/// This presenter guarantees that only one VLC viewer is presented at a time. +@MainActor +enum NCVideoVLCPresenter { + + // MARK: - State + + private static weak var currentViewController: NCVideoVLCViewController? + private static var currentURL: URL? + private static var isPresenting = false + + // MARK: - Public API + + /// Presents the VLC fallback viewer from the current top view controller. + /// + /// Repeated calls with the same URL are ignored to avoid multiple VLC instances + /// during SwiftUI recomposition or device rotation. + /// + /// - Parameters: + /// - metadata: Video metadata used for logging. + /// - url: Local or remote playable URL. + /// - previewURL: Optional local preview image URL shown until VLC starts rendering. + /// - userAgent: Optional HTTP User-Agent for remote playback. + /// - contextMenuController: Main tab bar controller used by context menu actions. + /// - canGoPrevious: Whether VLC can navigate to the previous media item. + /// - canGoNext: Whether VLC can navigate to the next media item. + /// - onPrevious: Callback invoked when VLC receives a right swipe. + /// - onNext: Callback invoked when VLC receives a left swipe. + /// - onClose: Callback invoked with the current media ocId when VLC closes the fullscreen media viewer. + static func present( + metadata: tableMetadata, + url: URL, + previewURL: URL?, + userAgent: String?, + contextMenuController: NCMainTabBarController?, + canGoPrevious: Bool = false, + canGoNext: Bool = false, + onPrevious: (() -> Void)? = nil, + onNext: (() -> Void)? = nil, + onClose: ((_ ocId: String?) -> Void)? = nil + ) { + if currentURL == url, + let currentViewController { + currentViewController.update( + metadata: metadata, + url: url, + previewURL: previewURL, + userAgent: userAgent, + contextMenuController: contextMenuController + ) + currentViewController.onPrevious = onPrevious + currentViewController.onNext = onNext + currentViewController.onClose = onClose + currentViewController.canGoPrevious = canGoPrevious + currentViewController.canGoNext = canGoNext + nkLog( + tag: NCGlobal.shared.logTagViewer, + emoji: .debug, + message: "VIDEO VLC presenter ignored duplicate URL \(url.absoluteString)", + consoleOnly: true + ) + return + } + + if isPresenting { + nkLog( + tag: NCGlobal.shared.logTagViewer, + emoji: .debug, + message: "VIDEO VLC presenter ignored while presentation is in progress", + consoleOnly: true + ) + return + } + + if let currentViewController { + currentViewController.update( + metadata: metadata, + url: url, + previewURL: previewURL, + userAgent: userAgent, + contextMenuController: contextMenuController + ) + currentViewController.onPrevious = onPrevious + currentViewController.onNext = onNext + currentViewController.onClose = onClose + currentViewController.canGoPrevious = canGoPrevious + currentViewController.canGoNext = canGoNext + + currentURL = url + return + } + + guard let presenter = topViewController() else { + nkLog( + tag: NCGlobal.shared.logTagViewer, + emoji: .error, + message: "VIDEO VLC presenter failed: no top view controller", + consoleOnly: true + ) + return + } + + if presenter is NCVideoVLCViewController { + return + } + + if presenter is UINavigationController, + (presenter as? UINavigationController)?.topViewController is NCVideoVLCViewController { + return + } + + isPresenting = true + + let viewController = NCVideoVLCViewController( + metadata: metadata, + url: url, + previewURL: previewURL, + userAgent: userAgent, + contextMenuController: contextMenuController + ) + viewController.onPrevious = onPrevious + viewController.onNext = onNext + viewController.onClose = onClose + viewController.canGoPrevious = canGoPrevious + viewController.canGoNext = canGoNext + + currentViewController = viewController + currentURL = url + + let navigationController = UINavigationController( + rootViewController: viewController + ) + + navigationController.modalPresentationStyle = .fullScreen + navigationController.modalTransitionStyle = .crossDissolve + navigationController.navigationBar.prefersLargeTitles = false + navigationController.navigationBar.barStyle = .black + navigationController.navigationBar.tintColor = .white + navigationController.navigationBar.titleTextAttributes = [ + .foregroundColor: UIColor.white + ] + + presenter.present( + navigationController, + animated: false + ) { + isPresenting = false + } + } + + /// Clears the current VLC presentation state. + /// + /// Call this from `NCVideoVLCViewController` when it closes. + /// + /// - Parameter viewController: VLC view controller being closed. + static func clearCurrent( + _ viewController: NCVideoVLCViewController + ) { + guard currentViewController === viewController else { + return + } + + currentViewController = nil + currentURL = nil + isPresenting = false + } + + /// Dismisses the current VLC viewer if one is currently presented. + static func dismissCurrent() { + guard let currentViewController else { + return + } + + currentViewController.dismiss(animated: false) { + clearCurrent(currentViewController) + } + } + + /// Dismisses the current VLC viewer if one is currently presented. + /// + /// This short alias is used by video-page navigation callbacks before moving + /// the SwiftUI media viewer to the previous or next page. + static func dismiss() { + dismissCurrent() + } + + // MARK: - Private + + /// Resolves the top-most visible view controller. + private static func topViewController() -> UIViewController? { + let windowScene = UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + .first { $0.activationState == .foregroundActive } + + let rootViewController = windowScene? + .windows + .first { $0.isKeyWindow }? + .rootViewController + + return visibleViewController(from: rootViewController) + } + + /// Recursively resolves the visible view controller. + /// + /// - Parameter viewController: Root or intermediate view controller. + /// - Returns: Top-most visible view controller. + private static func visibleViewController( + from viewController: UIViewController? + ) -> UIViewController? { + if let navigationController = viewController as? UINavigationController { + return visibleViewController( + from: navigationController.visibleViewController + ) + } + + if let tabBarController = viewController as? UITabBarController { + return visibleViewController( + from: tabBarController.selectedViewController + ) + } + + if let presentedViewController = viewController?.presentedViewController { + return visibleViewController( + from: presentedViewController + ) + } + + return viewController + } +} diff --git a/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCViewController.swift b/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCViewController.swift new file mode 100644 index 0000000000..b55394a124 --- /dev/null +++ b/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCViewController.swift @@ -0,0 +1,1100 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Marino Faggiana +// SPDX-License-Identifier: GPL-3.0-or-later + +import AVFoundation +import UIKit +import SwiftUI +import MobileVLCKit +import NextcloudKit +import UniformTypeIdentifiers + +// MARK: - VLC View Controller + +/// UIKit-only VLC video controller. +/// +/// This controller is intentionally outside the SwiftUI paging hierarchy. +/// It owns one stable drawable view, one VLCMediaPlayer, and one shared controls view. +final class NCVideoVLCViewController: UIViewController { + + // MARK: - Input + + private var metadata: tableMetadata + private var url: URL + private var previewURL: URL? + private var userAgent: String? + private weak var contextMenuController: NCMainTabBarController? + + // MARK: - Paging Callbacks + + var onPrevious: (() -> Void)? + var onNext: (() -> Void)? + var onClose: ((_ ocId: String?) -> Void)? + var canGoPrevious = false + var canGoNext = false + + // MARK: - Views + + internal let drawableView = UIView() + private let previewImageView = UIImageView() + internal let controlsView = NCVideoControlsView() + + private let floatingTitleView = NCViewerFloatingTitleView() + + private lazy var floatingTitleDateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.locale = .current + formatter.dateStyle = .medium + formatter.timeStyle = .short + return formatter + }() + + // MARK: - VLC + + internal let mediaPlayer = VLCMediaPlayer() + private var externalSubtitleURL: URL? + + internal var progressTimer: Timer? + internal var controlsHideTimer: Timer? + internal var controlsVisible = false + internal var isScrubbing = false + + internal var shouldKeepControlsVisible: Bool { + mediaPlayer.state != .playing && !mediaPlayer.isPlaying + } + + internal func setNavigationBarVisible( + _ isVisible: Bool, + animated: Bool + ) { + navigationController?.setNavigationBarHidden( + !isVisible, + animated: animated + ) + } + + // MARK: - Navigation Items + + private lazy var moreNavigationItem = UIBarButtonItem( + image: NCImageCache.shared.getImageButtonMore(), + primaryAction: nil, + menu: makeMoreMenu() + ) + + private lazy var mediaDetailNavigationItem = UIBarButtonItem( + image: NCUtility().loadImage( + named: "info.circle", + colors: [NCBrandColor.shared.iconImageColor] + ), + style: .plain, + target: self, + action: #selector(mediaDetailButtonTapped) + ) + + // MARK: - Init + + init( + metadata: tableMetadata, + url: URL, + previewURL: URL?, + userAgent: String?, + contextMenuController: NCMainTabBarController? + ) { + self.metadata = metadata + self.url = url + self.previewURL = previewURL + self.userAgent = userAgent + self.contextMenuController = contextMenuController + + super.init( + nibName: nil, + bundle: nil + ) + + modalPresentationStyle = .fullScreen + modalTransitionStyle = .crossDissolve + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + stopControlsHideTimer() + mediaPlayer.delegate = nil + stop() + } + + // MARK: - Lifecycle + + override func loadView() { + let rootView = UIView() + rootView.backgroundColor = .black + rootView.isOpaque = true + rootView.clipsToBounds = true + + drawableView.backgroundColor = .black + drawableView.isOpaque = true + drawableView.clipsToBounds = true + drawableView.translatesAutoresizingMaskIntoConstraints = false + + previewImageView.backgroundColor = .black + previewImageView.contentMode = .scaleAspectFit + previewImageView.clipsToBounds = true + previewImageView.translatesAutoresizingMaskIntoConstraints = false + updatePreviewImage() + + controlsView.delegate = self + controlsView.setTopActionsMode(.vlcTracks) + controlsView.alpha = 0 + controlsView.isHidden = true + controlsView.translatesAutoresizingMaskIntoConstraints = false + + rootView.addSubview(drawableView) + rootView.addSubview(previewImageView) + rootView.addSubview(controlsView) + + NSLayoutConstraint.activate([ + drawableView.leadingAnchor.constraint(equalTo: rootView.leadingAnchor), + drawableView.trailingAnchor.constraint(equalTo: rootView.trailingAnchor), + drawableView.topAnchor.constraint(equalTo: rootView.topAnchor), + drawableView.bottomAnchor.constraint(equalTo: rootView.bottomAnchor), + + previewImageView.leadingAnchor.constraint(equalTo: rootView.leadingAnchor), + previewImageView.trailingAnchor.constraint(equalTo: rootView.trailingAnchor), + previewImageView.topAnchor.constraint(equalTo: rootView.topAnchor), + previewImageView.bottomAnchor.constraint(equalTo: rootView.bottomAnchor), + + controlsView.leadingAnchor.constraint(equalTo: rootView.leadingAnchor), + controlsView.trailingAnchor.constraint(equalTo: rootView.trailingAnchor), + controlsView.topAnchor.constraint(equalTo: rootView.topAnchor), + controlsView.bottomAnchor.constraint(equalTo: rootView.bottomAnchor) + ]) + + controlsView.setTopActionsNavigationBar(navigationController?.navigationBar) + + view = rootView + } + + override func viewDidLoad() { + super.viewDidLoad() + + view.backgroundColor = .black + + configureNavigationItem() + updateTitleLabel(metadata: metadata) + configureAudioSession() + mediaPlayer.delegate = self + configureSwipeGestures() + configureTapGesture() + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + start() + showControls(animated: false) + stopControlsHideTimer() + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + + attachDrawable() + updateControlsNavigationBar() + configureFloatingTitleViewIfNeeded() + } + + override func viewWillTransition( + to size: CGSize, + with coordinator: UIViewControllerTransitionCoordinator + ) { + super.viewWillTransition( + to: size, + with: coordinator + ) + + coordinator.animate(alongsideTransition: { [weak self] _ in + self?.view.layoutIfNeeded() + }, completion: { [weak self] _ in + self?.attachDrawable() + self?.updateControlsNavigationBar() + self?.configureFloatingTitleViewIfNeeded() + }) + } + + // MARK: - Public API + + /// Updates the current VLC input. + /// + /// If the URL changes, the current media is stopped and the new media is prepared. + /// The context menu is refreshed for the new metadata. + /// + /// - Parameters: + /// - metadata: Updated video metadata. + /// - url: Updated playable URL. + /// - previewURL: Optional local preview image URL shown until VLC starts rendering. + /// - userAgent: Optional HTTP User-Agent. + func update( + metadata: tableMetadata, + url: URL, + previewURL: URL?, + userAgent: String?, + contextMenuController: NCMainTabBarController? + ) { + let urlChanged = self.url != url + + if urlChanged { + stop() + } + + self.metadata = metadata + self.url = url + self.previewURL = previewURL + self.userAgent = userAgent + self.contextMenuController = contextMenuController + updatePreviewImage() + updateTitleLabel(metadata: metadata) + refreshVLCTrackMenuItemsWhenPlayerIsActive() + + refreshMoreMenu() + + if urlChanged { + start() + } + + updatePlayPauseButton() + } + + // MARK: - Navigation + + /// Configures the navigation bar items. + private func configureNavigationItem() { + title = nil + navigationItem.title = nil + navigationItem.titleView = nil + + navigationItem.leftBarButtonItem = UIBarButtonItem( + image: UIImage(systemName: "chevron.backward"), + style: .plain, + target: self, + action: #selector(closeTapped) + ) + + navigationItem.rightBarButtonItems = [ + moreNavigationItem, + mediaDetailNavigationItem + ] + } + + /// Configures the floating title view inside the navigation bar chrome. + private func configureFloatingTitleViewIfNeeded() { + guard let navigationBar = navigationController?.navigationBar else { + return + } + + floatingTitleView.attach(to: navigationBar) + } + + /// Updates the floating title view using the provided video metadata. + /// + /// - Parameter metadata: Video metadata used to build the visible title content. + private func updateTitleLabel(metadata: tableMetadata) { + let primaryTitle = metadata.fileNameView.isEmpty + ? metadata.fileName + : metadata.fileNameView + + floatingTitleView.update( + primaryText: primaryTitle, + secondaryText: floatingTitleSecondaryText(for: metadata), + textColor: .white + ) + } + + /// Builds the secondary floating title text for the provided metadata. + /// + /// - Parameter metadata: Video metadata used to derive the secondary title line. + /// - Returns: Secondary title text shown below the main title. + private func floatingTitleSecondaryText(for metadata: tableMetadata) -> String? { + floatingTitleDateFormatter.string(from: metadata.date as Date) + } + + /// Rebuilds the More menu using the current metadata. + private func refreshMoreMenu() { + moreNavigationItem.menu = makeMoreMenu() + } + + /// Builds the VLC-specific More menu. + /// + /// The menu uses `sender: self`, so menu actions present from the visible + /// VLC controller instead of the SwiftUI viewer underneath. + private func makeMoreMenu() -> UIMenu { + UIMenu(title: "", children: [ + UIDeferredMenuElement.uncached { [weak self] completion in + guard let self else { + completion([]) + return + } + + if let menu = NCContextMenuViewer( + metadata: self.metadata, + controller: self.contextMenuController, + viewController: self, + webView: false, + sender: self + ).viewMenu() { + completion(menu.children) + } else { + completion([]) + } + } + ]) + } + + @objc + private func closeTapped() { + close() + } + + @objc + private func mediaDetailButtonTapped() { + presentDetailView(animated: true) + } + + /// Presents the media metadata detail panel for the current video. + /// + /// Video metadata usually has no EXIF payload, so the detail view receives an empty EXIF model. + /// + /// - Parameter animated: Whether presentation should be animated. + private func presentDetailView(animated: Bool) { + let detailView = NCMediaViewerDetailView( + metadata: metadata, + exif: ExifData() + ) + + let hostingController = UIHostingController(rootView: detailView) + hostingController.modalPresentationStyle = .pageSheet + + if let sheetPresentationController = hostingController.sheetPresentationController { + sheetPresentationController.detents = [.medium(), .large()] + sheetPresentationController.prefersGrabberVisible = true + sheetPresentationController.preferredCornerRadius = 24 + sheetPresentationController.prefersEdgeAttachedInCompactHeight = true + sheetPresentationController.widthFollowsPreferredContentSizeWhenEdgeAttached = false + } + + present( + hostingController, + animated: animated + ) + } + + func close() { + stopControlsHideTimer() + stopProgressTimer() + stop() + + NCVideoVLCPresenter.clearCurrent(self) + + dismiss(animated: false) { [onClose, metadata] in + DispatchQueue.main.async { + onClose?(metadata.ocId) + } + } + } + + func closeImmediately() { + stopControlsHideTimer() + stopProgressTimer() + stop() + + NCVideoVLCPresenter.clearCurrent(self) + + dismiss(animated: false) { [onClose] in + onClose?(nil) + } + } + + // MARK: - Swipe Navigation + + /// Configures UIKit swipe gestures for media navigation and viewer closing. + private func configureSwipeGestures() { + let swipeLeft = UISwipeGestureRecognizer( + target: self, + action: #selector(handleSwipe(_:)) + ) + swipeLeft.direction = .left + swipeLeft.delegate = self + + let swipeRight = UISwipeGestureRecognizer( + target: self, + action: #selector(handleSwipe(_:)) + ) + swipeRight.direction = .right + swipeRight.delegate = self + + let closePanGesture = UIPanGestureRecognizer( + target: self, + action: #selector(handleClosePan(_:)) + ) + closePanGesture.delegate = self + + view.addGestureRecognizer(swipeLeft) + view.addGestureRecognizer(swipeRight) + view.addGestureRecognizer(closePanGesture) + } + + /// Configures a single tap gesture to toggle VLC playback controls. + private func configureTapGesture() { + let tapGesture = UITapGestureRecognizer( + target: self, + action: #selector(handleSingleTap(_:)) + ) + tapGesture.numberOfTapsRequired = 1 + tapGesture.cancelsTouchesInView = false + tapGesture.delegate = self + view.addGestureRecognizer(tapGesture) + } + + /// Handles single taps by toggling the VLC playback controls. + /// + /// Taps are ignored while playback is not running because controls and the + /// navigation bar must remain visible in prepared, paused, and stopped states. + /// + /// - Parameter gesture: Source tap gesture recognizer. + @objc + private func handleSingleTap(_ gesture: UITapGestureRecognizer) { + guard !shouldKeepControlsVisible else { + showControls(animated: false) + stopControlsHideTimer() + return + } + + let location = gesture.location(in: view) + + if controlsVisible { + guard !controlsHitFramesContain(location) else { + return + } + + hideControls(animated: true) + } else { + showControls(animated: true) + scheduleControlsHide() + } + } + + /// Handles horizontal VLC swipe gestures. + /// + /// Left moves to the next media item when available. + /// Right moves to the previous media item when available. + /// The controller itself does not know the media list; it only forwards the intent + /// through callbacks owned by the presenter/viewer layer. + /// + /// - Parameter gesture: Source swipe gesture recognizer. + @objc + private func handleSwipe(_ gesture: UISwipeGestureRecognizer) { + guard !isScrubbing else { + return + } + switch gesture.direction { + case .left: + guard canGoNext else { + return + } + onNext?() + + case .right: + guard canGoPrevious else { + return + } + onPrevious?() + + default: + break + } + } + + /// Handles downward pan gestures by closing the VLC viewer. + /// + /// This mirrors the common media viewer drag-to-close behavior: a short downward + /// drag or a quick downward flick is enough, while horizontal paging still wins + /// when the gesture is mostly horizontal. + /// + /// - Parameter gesture: Source pan gesture recognizer. + @objc + private func handleClosePan(_ gesture: UIPanGestureRecognizer) { + let translation = gesture.translation(in: view) + let velocity = gesture.velocity(in: view) + + guard translation.y > 0 else { + return + } + + switch gesture.state { + case .ended, + .cancelled: + let verticalDistance = translation.y + let horizontalDistance = abs(translation.x) + let downwardVelocity = velocity.y + let isMostlyVertical = verticalDistance > horizontalDistance * 1.10 + let shouldClose = verticalDistance > 70 || downwardVelocity > 550 + + guard isMostlyVertical, + shouldClose else { + return + } + + close() + + default: + break + } + } + + // MARK: - Playback + + /// Prepares VLC playback without starting it automatically. + private func start() { + attachDrawable() + showPreviewImage() + + let media = VLCMedia(url: url) + + if let userAgent, + !userAgent.isEmpty, + !url.isFileURL { + media.addOption(":http-user-agent=\(userAgent)") + } + + mediaPlayer.media = media + updatePlayPauseButton() + updateProgressControls() + clearVLCTrackMenuItems() + startProgressTimer() + showControls(animated: false) + stopControlsHideTimer() + + nkLog( + tag: NCGlobal.shared.logTagViewer, + emoji: .debug, + message: "VIDEO VLC UIKit prepared without autoplay ocId \(metadata.ocId), url \(url.absoluteString)", + consoleOnly: true + ) + } + + /// Stops VLC playback and releases resources. + private func stop() { + mediaPlayer.stop() + mediaPlayer.media = nil + mediaPlayer.drawable = nil + externalSubtitleURL = nil + showPreviewImage() + stopProgressTimer() + updatePlayPauseButton() + updateProgressControls() + clearVLCTrackMenuItems() + } + + /// Attaches the drawable view to VLC. + private func attachDrawable() { + guard drawableView.bounds.width > 0, + drawableView.bounds.height > 0 else { + return + } + + mediaPlayer.drawable = drawableView + if mediaPlayer.isPlaying { + hidePreviewImage() + } + } + + /// Handles VLC playback state changes. + private func handleMediaPlayerStateChange() { + updatePlayPauseButton() + updateProgressControls() + refreshVLCTrackMenuItemsWhenPlayerIsActive() + + guard mediaPlayer.state == .playing else { + showControls(animated: false) + stopControlsHideTimer() + return + } + + scheduleControlsHideIfNeededAfterPlaybackStart() + } + + /// Arms the controls auto-hide timer when VLC is confirmed to be playing. + /// + /// VLC state notifications and `isPlaying` may not become true at exactly the same + /// time. This helper is safe to call from both state and time callbacks because it + /// does not restart an already scheduled timer. + private func scheduleControlsHideIfNeededAfterPlaybackStart() { + guard !shouldKeepControlsVisible else { + return + } + + guard controlsVisible else { + return + } + + guard controlsHideTimer == nil else { + return + } + + hidePreviewImage() + scheduleControlsHide() + } + + // MARK: - VLC Track Menus + + /// Refreshes the SwiftUI track menus using the current VLC player state. + func refreshVLCTrackMenuItems() { + controlsView.setSubtitleTrackMenuItems(makeSubtitleTrackMenuItems()) + controlsView.setAudioTrackMenuItems(makeAudioTrackMenuItems()) + } + + /// Clears the SwiftUI track menus while VLC has not exposed media tracks yet. + func clearVLCTrackMenuItems() { + controlsView.setSubtitleTrackMenuItems([]) + controlsView.setAudioTrackMenuItems([]) + } + + /// Refreshes the SwiftUI track menus only when VLC is active enough to expose tracks. + func refreshVLCTrackMenuItemsWhenPlayerIsActive() { + switch mediaPlayer.state { + case .opening, .buffering, .playing, .paused: + refreshVLCTrackMenuItems() + default: + clearVLCTrackMenuItems() + } + } + + /// Selects a VLC subtitle track and persists the selection for the current metadata. + /// + /// - Parameter index: VLC subtitle track index selected by the user. + func selectSubtitleTrack(index: Int32) { + mediaPlayer.currentVideoSubTitleIndex = index + NCManageDatabase.shared.addVideo( + metadata: metadata, + currentVideoSubTitleIndex: Int(index) + ) + refreshVLCTrackMenuItems() + } + + /// Selects a VLC audio track and persists the selection for the current metadata. + /// + /// - Parameter index: VLC audio track index selected by the user. + func selectAudioTrack(index: Int32) { + mediaPlayer.currentAudioTrackIndex = index + NCManageDatabase.shared.addVideo( + metadata: metadata, + currentAudioTrackIndex: Int(index) + ) + refreshVLCTrackMenuItems() + } + + /// Presents a document picker that lets the user select an external subtitle file for VLC playback. + func presentExternalSubtitlePicker() { + let picker = UIDocumentPickerViewController( + forOpeningContentTypes: [.item], + asCopy: true + ) + picker.delegate = self + picker.allowsMultipleSelection = false + present(picker, animated: true) + } + + /// Returns whether the selected file extension is supported as an external subtitle. + /// + /// - Parameter url: File URL selected by the user. + /// - Returns: True when VLC should try to load the file as an external subtitle. + private func isSupportedExternalSubtitleURL(_ url: URL) -> Bool { + let supportedExtensions: Set = [ + "srt", + "vtt", + "ass", + "ssa", + "sub" + ] + + return supportedExtensions.contains(url.pathExtension.lowercased()) + } + + /// Loads an external subtitle file into the current VLC media player. + /// + /// - Parameter url: Local subtitle file URL selected by the user. + private func loadExternalSubtitle(url: URL) { + guard isSupportedExternalSubtitleURL(url) else { + nkLog( + tag: NCGlobal.shared.logTagViewer, + emoji: .error, + message: "VIDEO VLC unsupported external subtitle extension: \(url.lastPathComponent)", + consoleOnly: true + ) + return + } + + do { + let localURL = try copyExternalSubtitleToTemporaryDirectory(from: url) + + externalSubtitleURL = localURL + + _ = mediaPlayer.addPlaybackSlave( + localURL.standardizedFileURL, + type: .subtitle, + enforce: true + ) + + refreshExternalSubtitleTracksAfterLoad() + } catch { + nkLog( + tag: NCGlobal.shared.logTagViewer, + emoji: .error, + message: "VIDEO VLC external subtitle load error: \(error.localizedDescription)", + consoleOnly: true + ) + } + } + + /// Copies the selected subtitle to a stable temporary file that VLC can read. + /// + /// - Parameter url: Security-scoped or temporary document picker URL. + /// - Returns: Local temporary file URL used by VLC. + private func copyExternalSubtitleToTemporaryDirectory(from url: URL) throws -> URL { + let didStartAccessing = url.startAccessingSecurityScopedResource() + defer { + if didStartAccessing { + url.stopAccessingSecurityScopedResource() + } + } + + let fileName = url.lastPathComponent.isEmpty + ? "external-subtitle.\(url.pathExtension.lowercased())" + : url.lastPathComponent + + let destinationURL = FileManager.default.temporaryDirectory + .appendingPathComponent("vlc-external-subtitles", isDirectory: true) + .appendingPathComponent(fileName) + + let destinationDirectory = destinationURL.deletingLastPathComponent() + try FileManager.default.createDirectory( + at: destinationDirectory, + withIntermediateDirectories: true + ) + + if FileManager.default.fileExists(atPath: destinationURL.path) { + try FileManager.default.removeItem(at: destinationURL) + } + + try FileManager.default.copyItem( + at: url, + to: destinationURL + ) + + return destinationURL + } + + /// Refreshes VLC subtitle tracks after VLC has had time to register the external subtitle file. + private func refreshExternalSubtitleTracksAfterLoad() { + refreshVLCTrackMenuItems() + + Task { @MainActor [weak self] in + try? await Task.sleep(for: .milliseconds(250)) + self?.refreshVLCTrackMenuItems() + } + } + + /// Builds subtitle menu items from VLC subtitle tracks. + /// + /// - Returns: Subtitle menu items rendered by the shared SwiftUI controls. + private func makeSubtitleTrackMenuItems() -> [NCVideoTrackMenuItem] { + makeTrackMenuItems( + titles: mediaPlayer.videoSubTitlesNames, + indexes: mediaPlayer.videoSubTitlesIndexes, + currentIndex: currentSubtitleTrackIndex() + ) + } + + /// Builds audio menu items from VLC audio tracks. + /// + /// - Returns: Audio menu items rendered by the shared SwiftUI controls. + private func makeAudioTrackMenuItems() -> [NCVideoTrackMenuItem] { + makeTrackMenuItems( + titles: mediaPlayer.audioTrackNames, + indexes: mediaPlayer.audioTrackIndexes, + currentIndex: currentAudioTrackIndex() + ) + } + + /// Returns the persisted subtitle track index, falling back to VLC's current subtitle track index. + /// + /// - Returns: Current subtitle track index used to mark the selected menu item. + private func currentSubtitleTrackIndex() -> Int? { + if let data = NCManageDatabase.shared.getVideo(metadata: metadata), + let currentVideoSubTitleIndex = data.currentVideoSubTitleIndex { + return currentVideoSubTitleIndex + } + + return Int(mediaPlayer.currentVideoSubTitleIndex) + } + + /// Returns the persisted audio track index, falling back to VLC's current audio track index. + /// + /// - Returns: Current audio track index used to mark the selected menu item. + private func currentAudioTrackIndex() -> Int? { + if let data = NCManageDatabase.shared.getVideo(metadata: metadata), + let currentAudioTrackIndex = data.currentAudioTrackIndex { + return currentAudioTrackIndex + } + + return Int(mediaPlayer.currentAudioTrackIndex) + } + + /// Builds SwiftUI menu items from VLC track names and indexes. + /// + /// - Parameters: + /// - titles: VLC track titles. + /// - indexes: VLC track indexes. + /// - currentIndex: Currently selected VLC track index. + /// - Returns: Track menu items with selection state. + private func makeTrackMenuItems( + titles: [Any], + indexes: [Any], + currentIndex: Int? + ) -> [NCVideoTrackMenuItem] { + titles.indices.compactMap { index in + guard let title = titles[index] as? String, + let trackIndex = normalizedTrackIndex(indexes, at: index) else { + return nil + } + + return NCVideoTrackMenuItem( + index: trackIndex, + title: title, + isSelected: currentIndex == Int(trackIndex) + ) + } + } + + /// Normalizes a VLC track index to Int32. + /// + /// - Parameters: + /// - indexes: VLC track indexes returned by MobileVLCKit. + /// - index: Position to read. + /// - Returns: Normalized VLC track index, if available. + private func normalizedTrackIndex( + _ indexes: [Any], + at index: Int + ) -> Int32? { + guard indexes.indices.contains(index) else { + return nil + } + + switch indexes[index] { + case let value as Int32: + return value + case let value as Int: + return Int32(value) + case let value as NSNumber: + return value.int32Value + default: + return nil + } + } + + // MARK: - Helpers + + /// Updates the fullscreen preview image shown before VLC starts rendering video. + private func updatePreviewImage() { + guard let previewURL, + previewURL.isFileURL else { + previewImageView.image = nil + previewImageView.isHidden = true + return + } + + previewImageView.image = UIImage(contentsOfFile: previewURL.path) + previewImageView.isHidden = previewImageView.image == nil + previewImageView.alpha = 1 + } + + /// Shows the preview image while VLC prepares the first rendered frame. + private func showPreviewImage() { + guard previewImageView.image != nil else { + previewImageView.isHidden = true + return + } + + previewImageView.layer.removeAllAnimations() + previewImageView.alpha = 1 + previewImageView.isHidden = false + } + + /// Hides the preview image after VLC starts rendering playback. + private func hidePreviewImage() { + guard !previewImageView.isHidden else { + return + } + + previewImageView.layer.removeAllAnimations() + previewImageView.alpha = 0 + previewImageView.isHidden = true + } + + /// Updates the shared controls top actions reference using the real navigation bar. + private func updateControlsNavigationBar() { + controlsView.setTopActionsNavigationBar(navigationController?.navigationBar) + } + + /// Returns whether a point is inside one of the visible controls areas. + /// + /// - Parameter location: Point in this controller's root view coordinate space. + /// - Returns: True when the point is inside top action, center, or bottom controls. + private func controlsHitFramesContain(_ location: CGPoint) -> Bool { + let topActionsFrame = controlsView.topActionsView.convert( + controlsView.topActionsView.bounds, + to: view + ) + let centerControlsFrame = controlsView.centerControlsView.convert( + controlsView.centerControlsView.bounds, + to: view + ) + let bottomControlsFrame = controlsView.bottomControlsView.convert( + controlsView.bottomControlsView.bounds, + to: view + ) + + return topActionsFrame.contains(location) + || centerControlsFrame.contains(location) + || bottomControlsFrame.contains(location) + } + + /// Configures the audio session for movie playback. + private func configureAudioSession() { + do { + try AVAudioSession.sharedInstance().setCategory( + .playback, + mode: .moviePlayback, + options: [] + ) + + try AVAudioSession.sharedInstance().setActive(true) + } catch { + nkLog( + tag: NCGlobal.shared.logTagViewer, + emoji: .error, + message: "VIDEO VLC audio session error: \(error.localizedDescription)", + consoleOnly: true + ) + } + } +} + +// MARK: - VLC Delegate + +extension NCVideoVLCViewController: VLCMediaPlayerDelegate { + func mediaPlayerStateChanged(_ aNotification: Notification) { + Task { @MainActor in + handleMediaPlayerStateChange() + } + } + + func mediaPlayerTimeChanged(_ aNotification: Notification) { + Task { @MainActor in + guard !isScrubbing else { + return + } + + updateProgressControls() + scheduleControlsHideIfNeededAfterPlaybackStart() + } + } +} + +// MARK: - Gesture Delegate + +extension NCVideoVLCViewController: UIGestureRecognizerDelegate { + /// Allows tap and swipe gestures to coexist with VLC's drawable view and UIKit controls. + /// + /// - Parameters: + /// - gestureRecognizer: Gesture recognizer asking for simultaneous recognition. + /// - otherGestureRecognizer: Other gesture recognizer involved in the decision. + /// - Returns: True to avoid VLC/touch handling from suppressing viewer gestures. + func gestureRecognizer( + _ gestureRecognizer: UIGestureRecognizer, + shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer + ) -> Bool { + true + } + + /// Prevents the background tap recognizer from stealing touches that begin on controls. + /// + /// - Parameters: + /// - gestureRecognizer: Gesture recognizer asking whether it should receive the touch. + /// - touch: Source touch. + /// - Returns: False for visible playback controls, true otherwise. + func gestureRecognizer( + _ gestureRecognizer: UIGestureRecognizer, + shouldReceive touch: UITouch + ) -> Bool { + guard controlsVisible else { + return true + } + + let location = touch.location(in: view) + + if controlsHitFramesContain(location) { + return false + } + + return true + } + + /// Allows the close pan to start only when the gesture is mainly downward. + /// + /// - Parameter gestureRecognizer: Gesture recognizer asking whether it should begin. + /// - Returns: True for non-pan gestures or downward-dominant pan gestures. + func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { + guard gestureRecognizer is UIPanGestureRecognizer else { + return true + } + + guard !isScrubbing else { + return false + } + + let velocity = (gestureRecognizer as? UIPanGestureRecognizer)?.velocity(in: view) ?? .zero + + guard velocity.y > 0 else { + return false + } + + return abs(velocity.y) > abs(velocity.x) * 1.10 + } +} + +// MARK: - Document Picker Delegate + +extension NCVideoVLCViewController: UIDocumentPickerDelegate { + /// Handles the selected external subtitle file and attaches it to the VLC player. + /// + /// - Parameters: + /// - controller: Document picker controller. + /// - urls: Selected file URLs. + func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) { + guard let url = urls.first else { + return + } + + loadExternalSubtitle(url: url) + showControls(animated: true) + } + + /// Handles document picker cancellation. + /// + /// - Parameter controller: Document picker controller. + func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) { + showControls(animated: true) + } +} diff --git a/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCViewControls.swift b/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCViewControls.swift new file mode 100644 index 0000000000..ca03b5930c --- /dev/null +++ b/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCViewControls.swift @@ -0,0 +1,339 @@ +import UIKit +import MobileVLCKit + +// MARK: - Playback Controls + +extension NCVideoVLCViewController { + /// Seeks ten seconds backward in the current VLC media. + @objc + func seekBackwardTapped() { + showControls(animated: true) + scheduleControlsHide() + seek(byMilliseconds: -10_000) + } + + /// Toggles VLC playback. + @objc + func playPauseTapped() { + showControls(animated: true) + + if mediaPlayer.isPlaying { + mediaPlayer.pause() + showControls(animated: false) + stopControlsHideTimer() + } else { + mediaPlayer.play() + } + + updatePlayPauseButton() + updateProgressControls() + } + + /// Seeks ten seconds forward in the current VLC media. + @objc + func seekForwardTapped() { + showControls(animated: true) + scheduleControlsHide() + seek(byMilliseconds: 10_000) + } + + /// Moves the current VLC playback time by a relative millisecond offset. + /// + /// - Parameter deltaMilliseconds: Relative seek offset in milliseconds. + func seek(byMilliseconds deltaMilliseconds: Int32) { + let duration = mediaPlayer.media?.length.intValue ?? 0 + guard duration > 0 else { + return + } + + let currentTime = mediaPlayer.time.intValue + let targetTime = max( + 0, + min( + Int(duration), + Int(currentTime + deltaMilliseconds) + ) + ) + + mediaPlayer.time = VLCTime(int: Int32(targetTime)) + updateProgressControls() + } + + /// Updates the play/pause button icon from the current VLC playback state. + func updatePlayPauseButton() { + controlsView.updatePlayPauseButton(isPlaying: mediaPlayer.isPlaying) + } + + /// Starts periodic progress updates. + func startProgressTimer() { + stopProgressTimer() + + progressTimer = Timer.scheduledTimer( + withTimeInterval: 0.35, + repeats: true + ) { [weak self] _ in + self?.updateProgressControls() + } + } + + /// Stops periodic progress updates. + func stopProgressTimer() { + progressTimer?.invalidate() + progressTimer = nil + } + + /// Updates slider and time labels from the current VLC playback position. + func updateProgressControls() { + guard !isScrubbing else { + return + } + + let position = max(0, min(1, mediaPlayer.position)) + updateProgressLabels(position: position) + updatePlayPauseButton() + } + + /// Updates elapsed and remaining time labels. + /// + /// - Parameter position: Normalized playback position between 0 and 1. + func updateProgressLabels(position: Float) { + let duration = mediaPlayer.media?.length.intValue ?? 0 + let elapsed = Int(Float(duration) * position) + let remaining = max(0, Int(duration) - elapsed) + + controlsView.updateProgress( + progress: position, + elapsedText: formatPlaybackTime(milliseconds: elapsed), + remainingText: "−" + formatPlaybackTime(milliseconds: remaining) + ) + } + + /// Formats milliseconds as a compact playback time. + /// + /// - Parameter milliseconds: Time value in milliseconds. + /// - Returns: Formatted time string. + func formatPlaybackTime(milliseconds: Int) -> String { + let totalSeconds = max(0, milliseconds / 1000) + let hours = totalSeconds / 3600 + let minutes = (totalSeconds % 3600) / 60 + let seconds = totalSeconds % 60 + + if hours > 0 { + return String(format: "%d:%02d:%02d", hours, minutes, seconds) + } + + return String(format: "%d:%02d", minutes, seconds) + } +} + +// MARK: - Controls Visibility + +extension NCVideoVLCViewController { + /// Shows the VLC playback controls. + /// + /// - Parameter animated: Whether the visibility change should be animated. + internal func showControls(animated: Bool) { + setNavigationBarVisible( + true, + animated: animated + ) + controlsVisible = true + setControlsVisible(true, animated: animated) + } + + /// Hides the VLC playback controls. + /// + /// - Parameter animated: Whether the visibility change should be animated. + internal func hideControls(animated: Bool) { + guard !shouldKeepControlsVisible else { + showControls(animated: false) + stopControlsHideTimer() + return + } + + setNavigationBarVisible( + false, + animated: animated + ) + controlsVisible = false + stopControlsHideTimer() + setControlsVisible(false, animated: animated) + } + + /// Applies the current controls visibility to the control views. + /// + /// - Parameters: + /// - visible: Whether controls should be visible. + /// - animated: Whether the visibility change should be animated. + internal func setControlsVisible(_ visible: Bool, animated: Bool) { + let changes = { + self.controlsView.alpha = visible ? 1 : 0 + } + + let completion: (Bool) -> Void = { _ in + self.controlsView.isHidden = !visible + } + + if visible { + controlsView.isHidden = false + } + + guard animated else { + changes() + completion(true) + return + } + + UIView.animate( + withDuration: 0.22, + delay: 0, + options: [.beginFromCurrentState, .curveEaseInOut], + animations: changes, + completion: completion + ) + } + + /// Schedules automatic hiding for the VLC playback controls. + internal func scheduleControlsHide() { + stopControlsHideTimer() + + guard !shouldKeepControlsVisible else { + return + } + + guard controlsVisible else { + return + } + + controlsHideTimer = Timer.scheduledTimer( + withTimeInterval: 3.0, + repeats: false + ) { [weak self] _ in + Task { @MainActor in + guard let self, + !self.isScrubbing else { + return + } + + self.hideControls(animated: true) + } + } + } + + /// Stops the automatic controls hide timer. + internal func stopControlsHideTimer() { + controlsHideTimer?.invalidate() + controlsHideTimer = nil + } +} + +// MARK: - Shared Controls Delegate + +extension NCVideoVLCViewController: NCVideoControlsViewDelegate { + /// Handles the shared controls backward seek action. + /// + /// - Parameter controlsView: Shared controls view that emitted the action. + func videoControlsDidTapSeekBackward(_ controlsView: NCVideoControlsView) { + seekBackwardTapped() + } + + /// Handles the shared controls play/pause action. + /// + /// - Parameter controlsView: Shared controls view that emitted the action. + func videoControlsDidTapPlayPause(_ controlsView: NCVideoControlsView) { + playPauseTapped() + } + + /// Handles the shared controls forward seek action. + /// + /// - Parameter controlsView: Shared controls view that emitted the action. + func videoControlsDidTapSeekForward(_ controlsView: NCVideoControlsView) { + seekForwardTapped() + } + + /// Handles the Picture in Picture action from the shared controls view. + /// + /// - Parameter controlsView: Shared controls view that emitted the action. + func videoControlsDidTapPictureInPicture(_ controlsView: NCVideoControlsView) { + // VLC does not expose Picture in Picture controls. + } + + /// Handles the beginning of slider scrubbing from the shared controls view. + /// + /// - Parameter controlsView: Shared controls view that emitted the action. + func videoControlsDidBeginScrubbing(_ controlsView: NCVideoControlsView) { + showControls(animated: true) + stopControlsHideTimer() + isScrubbing = true + } + + /// Handles the VLC subtitle track action from the shared controls view. + /// + /// - Parameter controlsView: Shared controls view that emitted the action. + func videoControlsDidTapSubtitle(_ controlsView: NCVideoControlsView) { + showControls(animated: true) + stopControlsHideTimer() + refreshVLCTrackMenuItemsWhenPlayerIsActive() + } + + /// Handles the VLC audio track action from the shared controls view. + /// + /// - Parameter controlsView: Shared controls view that emitted the action. + func videoControlsDidTapAudio(_ controlsView: NCVideoControlsView) { + showControls(animated: true) + stopControlsHideTimer() + refreshVLCTrackMenuItemsWhenPlayerIsActive() + } + + /// Handles the external subtitle import action from the shared controls view. + /// + /// - Parameter controlsView: Shared controls view that emitted the action. + func videoControlsDidTapAddExternalSubtitle(_ controlsView: NCVideoControlsView) { + showControls(animated: true) + stopControlsHideTimer() + presentExternalSubtitlePicker() + } + + /// Handles VLC subtitle track selection from the SwiftUI controls menu. + /// + /// - Parameters: + /// - controlsView: Shared controls view that emitted the action. + /// - index: VLC subtitle track index selected by the user. + func videoControls(_ controlsView: NCVideoControlsView, didSelectSubtitleTrackIndex index: Int32) { + showControls(animated: true) + stopControlsHideTimer() + selectSubtitleTrack(index: index) + } + + /// Handles VLC audio track selection from the SwiftUI controls menu. + /// + /// - Parameters: + /// - controlsView: Shared controls view that emitted the action. + /// - index: VLC audio track index selected by the user. + func videoControls(_ controlsView: NCVideoControlsView, didSelectAudioTrackIndex index: Int32) { + showControls(animated: true) + stopControlsHideTimer() + selectAudioTrack(index: index) + } + + /// Updates VLC time labels while scrubbing from the shared controls view. + /// + /// - Parameters: + /// - controlsView: Shared controls view that emitted the action. + /// - progress: Normalized target progress between 0 and 1. + func videoControls(_ controlsView: NCVideoControlsView, didScrubTo progress: Float) { + updateProgressLabels(position: progress) + } + + /// Applies the selected VLC playback position after scrubbing ends. + /// + /// - Parameters: + /// - controlsView: Shared controls view that emitted the action. + /// - progress: Normalized target progress between 0 and 1. + func videoControlsDidEndScrubbing(_ controlsView: NCVideoControlsView, progress: Float) { + mediaPlayer.position = progress + isScrubbing = false + updateProgressControls() + scheduleControlsHide() + } +} diff --git a/iOSClient/Viewer/NCViewerMedia/Helpers/NCViewerAppearance.swift b/iOSClient/Viewer/NCViewerMedia/Helpers/NCViewerAppearance.swift new file mode 100644 index 0000000000..18fa6ede54 --- /dev/null +++ b/iOSClient/Viewer/NCViewerMedia/Helpers/NCViewerAppearance.swift @@ -0,0 +1,100 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Marino Faggiana +// SPDX-License-Identifier: GPL-3.0-or-later + +import SwiftUI +import UIKit +import NextcloudKit + +// MARK: - Viewer Background Style + +/// Defines the background style used by viewer containers and media pages. +enum NCViewerBackgroundStyle { + /// Uses the current system appearance. + case system + + /// Always uses black, useful for video and cinema-style media viewers. + case black + + /// Always uses white, useful for document-like viewers. + case white + + /// Uses a custom UIKit color. + case custom(UIColor) +} + +// MARK: - UIColor Viewer Background + +extension UIColor { + /// Returns the background color for a viewer background style. + /// + /// - Parameter style: Viewer background style. + /// - Returns: Resolved UIKit background color. + static func ncViewerBackground(_ style: NCViewerBackgroundStyle = .system) -> UIColor { + switch style { + case .system: + return .systemBackground + case .black: + return .black + case .white: + return .white + case .custom(let color): + return color + } + } +} + +// MARK: - Color Viewer Background + +extension Color { + /// Returns the background color for a viewer background style. + /// + /// - Parameter style: Viewer background style. + /// - Returns: Resolved SwiftUI background color. + static func ncViewerBackground(_ style: NCViewerBackgroundStyle = .system) -> Color { + Color(uiColor: .ncViewerBackground(style)) + } +} + +// MARK: - Color Viewer Progress Tint + +extension Color { + /// Returns a readable progress tint color for a viewer background style. + /// + /// - Parameter style: Viewer background style. + /// - Returns: SwiftUI tint color suitable for loading indicators. + static func ncViewerProgressTint(_ style: NCViewerBackgroundStyle = .system) -> Color { + switch style { + case .black: + return .white + + case .system, + .white, + .custom: + return .accentColor + } + } +} + +// MARK: - Viewer Background Resolution + +/// Returns the preferred viewer background style for a metadata item. +/// +/// - Parameter metadata: Optional detached metadata. +/// - Returns: Background style preferred for the media type. +func ncViewerBackgroundStyle(for metadata: tableMetadata?) -> NCViewerBackgroundStyle { + guard let metadata else { + return .system + } + + switch metadata.classFile { + case NKTypeClassFile.image.rawValue: + return .system + case NKTypeClassFile.video.rawValue: + return .black + case NKTypeClassFile.audio.rawValue: + return .system + default: + return .system + } +} diff --git a/iOSClient/Viewer/NCViewerMedia/Helpers/NCViewerTransitionSource.swift b/iOSClient/Viewer/NCViewerMedia/Helpers/NCViewerTransitionSource.swift new file mode 100644 index 0000000000..c95b459005 --- /dev/null +++ b/iOSClient/Viewer/NCViewerMedia/Helpers/NCViewerTransitionSource.swift @@ -0,0 +1,34 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Marino Faggiana +// SPDX-License-Identifier: GPL-3.0-or-later + +import UIKit + +// MARK: - Viewer Transition Source + +/// Describes the visual source used to animate the media viewer presentation. +/// +/// The transition starts from the thumbnail currently visible in the source UI +/// and expands it to the final image frame inside the fullscreen viewer. +struct NCViewerTransitionSource { + /// Image currently visible in the source cell. + let image: UIImage + + /// Thumbnail frame converted to window coordinates. + let sourceFrame: CGRect + + /// Corner radius used by the source thumbnail. + let cornerRadius: CGFloat + + /// Creates a media viewer transition source. + /// + /// - Parameters: + /// - image: Image currently visible in the source cell. + /// - sourceFrame: Thumbnail frame converted to window coordinates. + /// - cornerRadius: Corner radius used by the source thumbnail. + init(image: UIImage, sourceFrame: CGRect, cornerRadius: CGFloat = 0) { + self.image = image + self.sourceFrame = sourceFrame + self.cornerRadius = cornerRadius + } +} diff --git a/iOSClient/Viewer/NCViewerMedia/Helpers/Notification+Extension.swift b/iOSClient/Viewer/NCViewerMedia/Helpers/Notification+Extension.swift new file mode 100644 index 0000000000..f35b84eb12 --- /dev/null +++ b/iOSClient/Viewer/NCViewerMedia/Helpers/Notification+Extension.swift @@ -0,0 +1,9 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Marino Faggiana +// SPDX-License-Identifier: GPL-3.0-or-later + +import Foundation + +extension Notification.Name { + static let ncMediaViewerStopPlayback = Notification.Name("ncMediaViewerStopPlayback") +} diff --git a/iOSClient/Viewer/NCViewerMedia/Loading/NCNextcloudMediaViewerLoader.swift b/iOSClient/Viewer/NCViewerMedia/Loading/NCNextcloudMediaViewerLoader.swift new file mode 100644 index 0000000000..64e98241f8 --- /dev/null +++ b/iOSClient/Viewer/NCViewerMedia/Loading/NCNextcloudMediaViewerLoader.swift @@ -0,0 +1,363 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Marino Faggiana +// SPDX-License-Identifier: GPL-3.0-or-later + +import Foundation +import NextcloudKit + +// MARK: - Media Viewer Loader + +/// Concrete media viewer loader for the Nextcloud app. +/// +/// This object is responsible for: +/// - resolving detached metadata from `ocId` +/// - checking if the full media file exists locally +/// - returning or downloading a preview file +/// - downloading the full media file when needed +/// +/// It must always return detached `tableMetadata` objects. +final class NCMediaViewerLoader: NCMediaViewerLoading, @unchecked Sendable { + private let database = NCManageDatabase.shared + private let global = NCGlobal.shared + private let utilityFileSystem = NCUtilityFileSystem() + private let fileManager = FileManager.default + + // MARK: - NCMediaViewerLoading + + /// Resolves detached metadata from an `ocId`. + /// + /// The primary lookup uses the local Realm database. + /// If the metadata is not available locally, the numeric fileId is extracted + /// from the `ocId` and the file is resolved from the server. + /// + /// - Parameters: + /// - ocId: Nextcloud file identifier. + /// - account: Account used to scope the remote fileId lookup. + /// - Returns: Detached metadata if available. + func metadata(for ocId: String, account: String, mediaSearch: Bool) async -> tableMetadata? { + if let metadata = await database.getMetadataFromOcIdAsync(ocId) { + return metadata + } + + guard let fileId = NCUtilityFileSystem().extractFileId(from: ocId) else { + return nil + } + + let resultsFile = await NextcloudKit.shared.getFileFromFileIdAsync( + fileId: fileId, + account: account + ) + + guard resultsFile.error == .success, + let file = resultsFile.file else { + return nil + } + + let metadata = await NCManageDatabaseCreateMetadata().convertFileToMetadataAsync(file, mediaSearch: mediaSearch) + await NCManageDatabase.shared.addMetadataAsync(metadata) + + return metadata + } + + /// Returns a local preview URL. + /// + /// This method first checks the local preview cache. If no preview exists, + /// it downloads one from the server and stores it using the existing app + /// preview cache pipeline. + /// + /// - Parameter metadata: Detached metadata for the media file. + /// - Returns: Local preview URL if available. + func previewURL(for metadata: tableMetadata, index: Int) async -> URL? { + let localPath = previewLocalPath(for: metadata) + + if isValidLocalFile(path: localPath) { + nkLog(tag: NCGlobal.shared.logTagViewer, emoji: .debug, message: "PREVIEW local \(index)", consoleOnly: true) + return URL(fileURLWithPath: localPath) + } + + nkLog(tag: NCGlobal.shared.logTagViewer, emoji: .debug, message: "PREVIEW request \(index)", consoleOnly: true) + + let result = await NextcloudKit.shared.downloadPreviewAsync( + fileId: metadata.fileId, + etag: metadata.etag, + account: metadata.account + ) + + if result.error == .success, + let data = result.responseData?.data { + NCUtility().createImageFileFrom( + data: data, + metadata: metadata + ) + } + + guard isValidLocalFile(path: localPath) else { + nkLog(tag: NCGlobal.shared.logTagViewer, emoji: .debug, message: "PREVIEW failed \(index)", consoleOnly: true) + return nil + } + + nkLog(tag: NCGlobal.shared.logTagViewer, emoji: .debug, message: "PREVIEW ready \(index)", consoleOnly: true) + + return URL(fileURLWithPath: localPath) + } + + /// Returns the local full media URL if the file is already available. + /// + /// This method never performs network requests. + /// + /// - Parameter metadata: Detached metadata for the media file. + /// - Returns: Local full media URL if available. + func localMediaURL(for metadata: tableMetadata, index: Int) async -> URL? { + let localPath = fullLocalPath(for: metadata) + + guard isValidLocalFile(path: localPath) else { + return nil + } + + nkLog(tag: NCGlobal.shared.logTagViewer, emoji: .debug, message: "FULL local \(index)", consoleOnly: true) + + return URL(fileURLWithPath: localPath) + } + + /// Downloads the full media file if needed. + /// + /// - Parameter metadata: Detached metadata for the media file. + /// - Returns: Local full media URL after completion. + func downloadMedia(for metadata: tableMetadata, index: Int) async throws -> URL { + if let localURL = await localMediaURL(for: metadata, index: index) { + nkLog(tag: NCGlobal.shared.logTagViewer, emoji: .debug, message: "FULL resolve \(index)", consoleOnly: true) + return localURL + } + + nkLog(tag: NCGlobal.shared.logTagViewer, emoji: .debug, message: "FULL network request \(index)", consoleOnly: true) + + guard let metadata = await self.database.setMetadataSessionInWaitDownloadAsync( + ocId: metadata.ocId, + session: NCNetworking.shared.sessionDownload, + selector: NCGlobal.shared.selectorDownloadFile) else { + nkLog(tag: NCGlobal.shared.logTagViewer, emoji: .debug, message: "FULL error \(index)", consoleOnly: true) + throw NSError(domain: "Download Media", code: 1, userInfo: [NSLocalizedDescriptionKey: "FULL error \(index)"]) + } + + let result = await NCNetworking.shared.downloadFile(metadata: metadata) + + if let afError = result.afError { + nkLog(tag: NCGlobal.shared.logTagViewer, emoji: .debug, message: "FULL error \(index)", consoleOnly: true) + throw afError + } + + if result.nkError != .success { + nkLog(tag: NCGlobal.shared.logTagViewer, emoji: .debug, message: "FULL error \(index)", consoleOnly: true) + throw result.nkError + } + + if let localURL = await localMediaURL(for: metadata, index: index) { + nkLog(tag: NCGlobal.shared.logTagViewer, emoji: .debug, message: "FULL ready \(index)", consoleOnly: true) + return localURL + } + + nkLog(tag: NCGlobal.shared.logTagViewer, emoji: .debug, message: "FULL unavailable after download \(index)", consoleOnly: true) + + throw NCMediaViewerLoaderError.localFileUnavailable + } + + /// Returns the local Live Photo paired media URL if available. + /// + /// - Parameters: + /// - metadata: Detached metadata for the main Live Photo image. + /// - index: Page index used for debug logs. + /// - Returns: Local paired Live Photo media URL if available. + func localLivePhotoURL(for metadata: tableMetadata, index: Int) async -> URL? { + guard metadata.isLivePhoto else { + return nil + } + + guard let livePhotoMetadata = database.getMetadataLivePhoto(metadata: metadata) else { + nkLog(tag: NCGlobal.shared.logTagViewer, emoji: .debug, message: "LIVE metadata missing \(index)", consoleOnly: true) + return nil + } + + let localPath = fullLocalPath(for: livePhotoMetadata) + + guard isValidLocalFile(path: localPath) else { + return nil + } + + nkLog(tag: NCGlobal.shared.logTagViewer, emoji: .debug, message: "LIVE local \(index)", consoleOnly: true) + + return URL(fileURLWithPath: localPath) + } + + /// Downloads the Live Photo paired media if needed. + /// + /// This method is optional by design. If the paired media cannot be found or + /// downloaded, the viewer should continue to behave like a normal image viewer. + /// + /// - Parameters: + /// - metadata: Detached metadata for the main Live Photo image. + /// - index: Page index used for debug logs. + /// - Returns: Local paired Live Photo media URL if available. + func downloadLivePhotoMedia(for metadata: tableMetadata, index: Int) async -> URL? { + guard metadata.isLivePhoto else { + return nil + } + + if let localURL = await localLivePhotoURL(for: metadata, index: index) { + nkLog(tag: NCGlobal.shared.logTagViewer, emoji: .debug, message: "LIVE resolve \(index)", consoleOnly: true) + return localURL + } + + guard NCNetworking.shared.isOnline else { + nkLog(tag: NCGlobal.shared.logTagViewer, emoji: .debug, message: "LIVE offline \(index)", consoleOnly: true) + return nil + } + + guard let livePhotoMetadata = database.getMetadataLivePhoto(metadata: metadata) else { + nkLog(tag: NCGlobal.shared.logTagViewer, emoji: .debug, message: "LIVE metadata missing \(index)", consoleOnly: true) + return nil + } + + guard !utilityFileSystem.fileProviderStorageExists(livePhotoMetadata) else { + return await localLivePhotoURL(for: metadata, index: index) + } + + nkLog(tag: NCGlobal.shared.logTagViewer, emoji: .debug, message: "LIVE network request \(index)", consoleOnly: true) + + guard let downloadMetadata = await database.setMetadataSessionInWaitDownloadAsync( + ocId: livePhotoMetadata.ocId, + session: NCNetworking.shared.sessionDownload, + selector: "" + ) else { + nkLog(tag: NCGlobal.shared.logTagViewer, emoji: .debug, message: "LIVE session error \(index)", consoleOnly: true) + return nil + } + + let result = await NCNetworking.shared.downloadFile(metadata: downloadMetadata) + + if result.afError != nil || result.nkError != .success { + nkLog(tag: NCGlobal.shared.logTagViewer, emoji: .debug, message: "LIVE error \(index)", consoleOnly: true) + return nil + } + + if let localURL = await localLivePhotoURL(for: metadata, index: index) { + nkLog(tag: NCGlobal.shared.logTagViewer, emoji: .debug, message: "LIVE ready \(index)", consoleOnly: true) + return localURL + } + + nkLog(tag: NCGlobal.shared.logTagViewer, emoji: .debug, message: "LIVE unavailable after download \(index)", consoleOnly: true) + + return nil + } + + // MARK: - Private Helpers + + /// Builds the expected full local file path. + /// + /// - Parameter metadata: Detached metadata for the media file. + /// - Returns: Local full media file path. + private func fullLocalPath(for metadata: tableMetadata) -> String { + utilityFileSystem.getDirectoryProviderStorageOcId( + metadata.ocId, + fileName: metadata.fileNameView, + userId: metadata.userId, + urlBase: metadata.urlBase + ) + } + + /// Builds the expected local preview file path. + /// + /// - Parameter metadata: Detached metadata for the media file. + /// - Returns: Local preview file path. + private func previewLocalPath(for metadata: tableMetadata) -> String { + utilityFileSystem.getDirectoryProviderStorageImageOcId( + metadata.ocId, + etag: metadata.etag, + ext: global.previewExt1024, + userId: metadata.userId, + urlBase: metadata.urlBase + ) + } + + /// Checks whether a local file exists and has a non-zero size. + /// + /// - Parameter path: Local file path. + /// - Returns: True when the file exists and is not empty. + private func isValidLocalFile(path: String) -> Bool { + guard !path.isEmpty else { + return false + } + + guard fileManager.fileExists(atPath: path) else { + return false + } + + guard let attributes = try? fileManager.attributesOfItem(atPath: path), + let fileSize = attributes[.size] as? Int64, + fileSize > 0 else { + return false + } + + return true + } +} + +// MARK: - Loader Error + +/// Errors thrown by the media viewer loader. +enum NCMediaViewerLoaderError: LocalizedError { + case localFileUnavailable + + var errorDescription: String? { + switch self { + case .localFileUnavailable: + return "The local file is not available." + } + } +} + +// MARK: - Media Viewer Loading + +/// Defines the loading operations required by the media viewer. +protocol NCMediaViewerLoading: Sendable { + /// Resolves detached metadata from an `ocId`. + /// + /// - Parameter ocId: Nextcloud file identifier. + /// - Returns: Detached metadata if available. + func metadata(for ocId: String, account: String, mediaSearch: Bool) async -> tableMetadata? + + /// - Parameters: + /// - metadata: Detached metadata for the media file. + /// - index: Page index used for debug logs. + /// - Returns: Local full media URL if available. + func localMediaURL(for metadata: tableMetadata, index: Int) async -> URL? + + /// Returns a local preview URL. + /// + /// The implementation can return a cached preview or download one if needed. + /// + /// - Parameter metadata: Detached metadata for the media file. + /// - Returns: Local preview URL if available. + func previewURL(for metadata: tableMetadata, index: Int) async -> URL? + + /// Downloads the full media file if needed. + /// + /// - Parameter metadata: Detached metadata for the media file. + /// - Returns: Local full media URL after completion. + func downloadMedia(for metadata: tableMetadata, index: Int) async throws -> URL + + /// Returns the local Live Photo paired media URL if available. + /// + /// - Parameters: + /// - metadata: Detached metadata for the main Live Photo image. + /// - index: Page index used for debug logs. + /// - Returns: Local paired Live Photo media URL if available. + func localLivePhotoURL(for metadata: tableMetadata, index: Int) async -> URL? + + /// Downloads the Live Photo paired media if needed. + /// + /// - Parameters: + /// - metadata: Detached metadata for the main Live Photo image. + /// - index: Page index used for debug logs. + /// - Returns: Local paired Live Photo media URL if available. + func downloadLivePhotoMedia(for metadata: tableMetadata, index: Int) async -> URL? +} diff --git a/iOSClient/Viewer/NCViewerMedia/Model - View/NCMediaViewerModel.swift b/iOSClient/Viewer/NCViewerMedia/Model - View/NCMediaViewerModel.swift new file mode 100644 index 0000000000..e5ffeb67c9 --- /dev/null +++ b/iOSClient/Viewer/NCViewerMedia/Model - View/NCMediaViewerModel.swift @@ -0,0 +1,1086 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Marino Faggiana +// SPDX-License-Identifier: GPL-3.0-or-later + +import Foundation +import NextcloudKit + +// MARK: - Page State + +/// Represents the loading state of a media viewer page. +/// +/// The page metadata is stored in `NCMediaViewerPageModel.metadata`. +/// This state only describes the current loading/rendering phase. +enum NCMediaViewerPageState { + /// The page exists but no loading operation has started yet. + case idle + + /// The page is resolving its `tableMetadata` from `ocId`. + case loadingMetadata + + /// The metadata could not be found anymore. + case metadataMissing + + /// Metadata exists and the viewer is checking if the full media file is already local. + case checkingLocalFile + + /// Image page state. + /// + /// The same image view remains mounted while the page moves from preview + /// to full image. This avoids flickering caused by replacing SwiftUI view branches. + case image(previewURL: URL?, localURL: URL?, livePhotoURL: URL?, progress: Double?) + + /// Video page state. + /// + /// Videos can be played from a local file, metadata URL, or Nextcloud direct + /// download URL. The video viewer resolves the final playback URL by itself. + case video(previewURL: URL?) + + /// Remote media state with an optional preview and optional download progress. + /// + /// For video/audio, this can also represent a remote-only state where a preview + /// is available but the full media file has not been downloaded. + case downloading(previewURL: URL?, progress: Double?) + + /// Non-image media is locally available. + case ready(localURL: URL, previewURL: URL?) + + case deleted + + /// The page failed while resolving metadata, checking local content, or downloading. + case failed(previewURL: URL?, message: String) +} + +// MARK: - Page Model + +/// Represents one page inside the media viewer. +/// +/// The model does not create one page for every media item upfront. +/// Pages are created lazily when requested by the UIKit pager. +struct NCMediaViewerPageModel: Identifiable { + /// Stable identifier used by SwiftUI. + let id: String + + /// Absolute index inside the full `ocIds` array. + let index: Int + + /// Nextcloud file identifier. + let ocId: String + + /// Detached metadata if already available. + var metadata: tableMetadata? + + /// Current loading state of the page. + var state: NCMediaViewerPageState + + /// Creates a page model. + /// + /// - Parameters: + /// - index: Absolute index inside the full `ocIds` array. + /// - ocId: Nextcloud file identifier. + /// - metadata: Detached metadata if already available. + /// - state: Initial page state. + init(index: Int, ocId: String, metadata: tableMetadata? = nil, state: NCMediaViewerPageState = .idle) { + self.id = ocId + self.index = index + self.ocId = ocId + self.metadata = metadata + self.state = state + } +} + +// MARK: - Initial Model + +/// Initial model used to open the media viewer. +/// +/// The viewer receives: +/// - the current `tableMetadata` +/// - the ordered list of media `ocId` values +/// +/// The current metadata must be detached before being passed here. +struct NCMediaViewerInitialModel { + /// Metadata of the initially opened media. + let currentMetadata: tableMetadata + + /// Ordered list of all media identifiers. + let ocIds: [String] + + /// Creates the initial model for the media viewer. + /// + /// - Parameters: + /// - currentMetadata: Detached metadata of the initially opened media. + /// - ocIds: Ordered list of image/audio/video ocIds. + init( + currentMetadata: tableMetadata, + ocIds: [String] + ) { + self.currentMetadata = currentMetadata + self.ocIds = ocIds + } + + /// Returns the ordered list of page identifiers. + /// + /// The current `ocId` is inserted only if missing. + var normalizedOcIds: [String] { + if ocIds.contains(currentMetadata.ocId) { + return ocIds + } else { + return [currentMetadata.ocId] + ocIds + } + } + + /// Returns the initial selected index. + /// + /// If the current `ocId` is not found, the model starts from index zero. + var initialSelectedIndex: Int { + normalizedOcIds.firstIndex(of: currentMetadata.ocId) ?? 0 + } +} + +// MARK: - Loading Task Kind + +/// Describes which loader owns a running page task. +private enum NCMediaViewerLoadingTaskKind { + /// Task started because the page became selected. + case selected + + /// Task started by neighbor prefetch. + case prefetch +} + +// MARK: - Loading Task + +/// Stores a running media viewer loading task. +/// +/// The identifier prevents an old cancelled task from removing a newer task +/// stored under the same `ocId`. +private struct NCMediaViewerLoadingTask { + let identifier: UUID + let kind: NCMediaViewerLoadingTaskKind + let task: Task +} + +// MARK: - Media Viewer Model + +/// Model for the media viewer. +/// +/// This model is optimized for very large media lists. +/// It stores the full ordered `ocIds` array, but creates page models lazily only +/// when the pager asks for them. +/// +/// Responsibilities: +/// - keep the current selected index +/// - expose page count +/// - create page models lazily +/// - resolve metadata lazily +/// - request preview URLs +/// - check local media availability +/// - start full media downloads through the loader only for selected pages +/// - prefetch nearby pages without downloading full media +/// - update page states +/// +/// It does not render UI and does not directly access Realm, FileManager, +/// or networking APIs. Those responsibilities belong to `NCMediaViewerLoading`. +@MainActor +final class NCMediaViewerModel: ObservableObject { + + // MARK: - Published State + + /// Currently selected absolute index inside the full `ocIds` array. + @Published private(set) var selectedIndex: Int + + /// Incremented when a cached page changes. + /// + /// The UIKit paging coordinator observes this value and refreshes visible cells. + @Published private(set) var revision: Int = 0 + + /// Whether the viewer chrome is currently hidden. + /// + /// When hidden, the navigation bar is hidden and the viewer uses a black + /// background for a cleaner fullscreen media experience. + @Published private(set) var isChromeHidden = false + + /// Page index that should auto-start playback after navigation. + @Published private(set) var autoPlayTargetIndex: Int? + + // MARK: - Dependencies + + private let loader: NCMediaViewerLoading + + // MARK: - Source Context + + /// Session used to resolve account-scoped metadata fallback lookups. + private let session: NCSession.Session + + private let mediaSearch: Bool + + // MARK: - Source Data + + /// Full ordered media identifier list. + private let ocIds: [String] + + // MARK: - Page Cache + + /// Page state cache keyed by `ocId`. + /// + /// Pages are created lazily when the pager asks for a specific index. + private var cachedPagesByOcId: [String: NCMediaViewerPageModel] = [:] + + // MARK: - Running Tasks + + /// Running selected or prefetch loading tasks keyed by `ocId`. + private var loadingTasksByOcId: [String: NCMediaViewerLoadingTask] = [:] + + // MARK: - Public Read-Only Access + + /// Total number of media pages. + var numberOfPages: Int { + ocIds.count + } + + /// Initial selected index. + var initialSelectedIndex: Int { + selectedIndex + } + + /// Current selected media ocId. + /// + /// - Returns: The ocId for the currently selected page if available. + var selectedOcId: String? { + guard ocIds.indices.contains(selectedIndex) else { + return nil + } + + return ocIds[selectedIndex] + } + + /// Current selected page metadata. + /// + /// - Returns: Detached metadata for the currently selected page if available. + var selectedMetadata: tableMetadata? { + guard ocIds.indices.contains(selectedIndex) else { + return nil + } + + let ocId = ocIds[selectedIndex] + return cachedPagesByOcId[ocId]?.metadata + } + + /// Requests automatic playback for a target page index. + /// + /// - Parameter index: Target page index. + func requestAutoPlay(at index: Int) { + guard ocIds.indices.contains(index) else { + return + } + + autoPlayTargetIndex = index + revision &+= 1 + } + + /// Clears the automatic playback request if it matches the provided index. + /// + /// - Parameter index: Page index that consumed auto-play. + func clearAutoPlayIfNeeded(for index: Int) { + guard autoPlayTargetIndex == index else { + return + } + + autoPlayTargetIndex = nil + revision &+= 1 + } + + /// Marks a page as deleted without removing it from the viewer list. + /// + /// This is used for optimistic UI updates when a delete operation has been + /// requested but the transfer delegate has not confirmed it yet. + /// + /// - Parameter ocId: Deleted file identifier. + @MainActor + func markPageAsDeleted(ocId: String) { + NotificationCenter.default.post( + name: .ncMediaViewerStopPlayback, + object: nil + ) + + updatePage(ocId: ocId) { page in + page.state = .deleted + } + + revision += 1 + } + + // MARK: - Init + + /// Creates a media viewer model. + /// + /// - Parameters: + /// - initialModel: Initial viewer model containing current metadata and ordered ocIds. + /// - session: Current Nextcloud session used for account-scoped metadata fallback lookups. + /// - loader: Loader used to resolve metadata, local URLs, previews, and downloads. + init( + initialModel: NCMediaViewerInitialModel, + session: NCSession.Session, + mediaSearch: Bool, + loader: NCMediaViewerLoading + ) { + self.loader = loader + self.session = session + self.mediaSearch = mediaSearch + self.ocIds = initialModel.normalizedOcIds + self.selectedIndex = initialModel.initialSelectedIndex + + let currentPage = NCMediaViewerPageModel( + index: initialModel.initialSelectedIndex, + ocId: initialModel.currentMetadata.ocId, + metadata: initialModel.currentMetadata, + state: .idle + ) + + cachedPagesByOcId[initialModel.currentMetadata.ocId] = currentPage + } + + /// Creates a media viewer model from the current metadata and ordered media identifiers. + /// + /// - Parameters: + /// - currentMetadata: Detached metadata of the initially opened media. + /// - ocIds: Ordered list of image/audio/video ocIds. + /// - session: Current Nextcloud session used for account-scoped metadata fallback lookups. + /// - loader: Loader used to resolve metadata, local URLs, previews, and downloads. + convenience init( + currentMetadata: tableMetadata, + ocIds: [String], + session: NCSession.Session, + mediaSearch: Bool, + loader: NCMediaViewerLoading + ) { + let initialModel = NCMediaViewerInitialModel( + currentMetadata: currentMetadata, + ocIds: ocIds + ) + + self.init( + initialModel: initialModel, + session: session, + mediaSearch: mediaSearch, + loader: loader + ) + } + + deinit { + loadingTasksByOcId.values.forEach { $0.task.cancel() } + loadingTasksByOcId.removeAll() + } + + // MARK: - Public API + + /// Returns the page model for an absolute index. + /// + /// If the page is not cached yet, a lightweight idle page is created and cached. + /// + /// - Parameter index: Absolute index inside the full `ocIds` array. + /// - Returns: Page model if the index exists. + func pageModel(at index: Int) -> NCMediaViewerPageModel? { + guard ocIds.indices.contains(index) else { + return nil + } + + let ocId = ocIds[index] + + if let cachedPage = cachedPagesByOcId[ocId] { + return cachedPage + } + + let page = NCMediaViewerPageModel(index: index, ocId: ocId, metadata: nil, state: .idle) + + cachedPagesByOcId[ocId] = page + return page + } + + /// Handles page display from the UIKit pager. + /// + /// When a page becomes selected, a running prefetch task for that page is + /// cancelled and replaced by selected-page loading. + /// + /// - Parameter index: Absolute page index currently displayed. + func displayPage(at index: Int) async { + guard ocIds.indices.contains(index) else { + return + } + + selectedIndex = index + + // Start neighbor prefetch immediately. + // Do not wait for the selected page full download to finish. + prefetchNeighborPages(around: index) + + await loadPageIfNeeded(index: index) + } + + /// Returns the page model for the currently selected index. + /// + /// - Returns: Selected page model if available. + func selectedPageModel() -> NCMediaViewerPageModel? { + pageModel(at: selectedIndex) + } + + /// Loads the initially selected page if needed. + func loadSelectedPageIfNeeded() async { + // Start neighbor prefetch immediately. + // This prepares adjacent previews while the selected page is loading. + prefetchNeighborPages(around: selectedIndex) + + await loadPageIfNeeded(index: selectedIndex) + } + + /// Loads a page if it still needs selected-page loading. + /// + /// Prefetched pages can already have a preview, but selected-page loading + /// must still run to check or download the full media file. + /// + /// - Parameter index: Absolute page index inside the full `ocIds` array. + func loadPageIfNeeded(index: Int) async { + guard ocIds.indices.contains(index) else { + return + } + + let ocId = ocIds[index] + + guard pageState(for: ocId).needsSelectedPageLoading else { + return + } + + if loadingTasksByOcId[ocId]?.kind == .selected { + return + } + + if loadingTasksByOcId[ocId]?.kind == .prefetch { + loadingTasksByOcId[ocId]?.task.cancel() + loadingTasksByOcId[ocId] = nil + } + + let identifier = UUID() + + let task = Task { [weak self] in + guard let self else { + return + } + + await self.loadPage(index: index) + } + + loadingTasksByOcId[ocId] = NCMediaViewerLoadingTask(identifier: identifier, kind: .selected, task: task) + + await task.value + + clearLoadingTaskIfCurrent(ocId: ocId, identifier: identifier) + } + + /// Reloads a failed or missing page. + /// + /// - Parameter index: Absolute page index inside the full `ocIds` array. + func reloadPage(index: Int) async { + guard ocIds.indices.contains(index) else { + return + } + + let ocId = ocIds[index] + + loadingTasksByOcId[ocId]?.task.cancel() + loadingTasksByOcId[ocId] = nil + + updatePage(ocId: ocId) { page in + page.state = .idle + } + + await loadPageIfNeeded(index: index) + } + + /// Cancels loading for a specific page. + /// + /// - Parameter index: Absolute page index inside the full `ocIds` array. + func cancelLoading(index: Int) { + guard ocIds.indices.contains(index) else { + return + } + + let ocId = ocIds[index] + + loadingTasksByOcId[ocId]?.task.cancel() + loadingTasksByOcId[ocId] = nil + } + + /// Updates the selected index without starting full page loading. + /// + /// - Parameter index: Absolute page index inside the full `ocIds` array. + func setSelectedIndex(_ index: Int) { + guard ocIds.indices.contains(index) else { + return + } + + guard selectedIndex != index else { + return + } + + selectedIndex = index + } + + /// Prefetches the currently visible page and its nearby pages. + /// + /// This method is used while the user scrolls. It warms the target area around + /// the current visible index without starting audio or video playback. + /// + /// - Parameter index: Current visible page index. + func prefetchVisiblePageIfNeeded(index: Int) async { + guard ocIds.indices.contains(index) else { + return + } + + await prefetchPageIfNeeded(index: index) + prefetchNeighborPages(around: index) + } + + /// Toggles the media viewer chrome visibility. + /// + /// The chrome includes the navigation bar and the preferred page background. + func toggleChromeVisibility() { + isChromeHidden.toggle() + } + + // MARK: - Selected Page Loading + + /// Loads metadata and media content for a selected or explicitly requested page. + /// + /// Loading order: + /// - Resolve metadata. + /// - Preserve any preview already stored in the current page state. + /// - If the full local file exists, resolve a preview if needed and show it immediately. + /// - Otherwise, resolve/show the preview. + /// - For non-local videos, stop here and let the video viewer resolve direct playback. + /// - For images and audio, download the full media file when needed. + /// + /// - Parameter index: Absolute page index inside the full `ocIds` array. + private func loadPage(index: Int) async { + guard ocIds.indices.contains(index) else { + return + } + + nkLog( + tag: NCGlobal.shared.logTagViewer, + emoji: .debug, + message: "LOAD PAGE \(index)", + consoleOnly: true + ) + + let ocId = ocIds[index] + let metadata = await resolvedMetadata(for: ocId) + + guard !Task.isCancelled else { + return + } + + guard let metadata else { + setState(.metadataMissing, for: ocId) + return + } + + setMetadata(metadata, for: ocId) + + var previewURL = currentPreviewURL(for: ocId) + + if let localURL = await loader.localMediaURL(for: metadata, index: index) { + guard !Task.isCancelled else { + return + } + + if previewURL == nil { + previewURL = await loader.previewURL( + for: metadata, + index: index + ) + + guard !Task.isCancelled else { + return + } + } + + await setReadyState( + metadata: metadata, + previewURL: previewURL, + localURL: localURL, + for: ocId, + index: index + ) + return + } + + guard !Task.isCancelled else { + return + } + + if previewURL == nil { + previewURL = await loader.previewURL(for: metadata, index: index) + } + + guard !Task.isCancelled else { + return + } + + if isImage(metadata), let previewURL { + setState( + .image( + previewURL: previewURL, + localURL: nil, + livePhotoURL: nil, + progress: nil + ), + for: ocId + ) + } + + if isVideo(metadata) { + setState( + .video(previewURL: previewURL), + for: ocId + ) + return + } + + guard !Task.isCancelled else { + return + } + + do { + if isAudio(metadata) { + setState( + .downloading( + previewURL: previewURL, + progress: nil + ), + for: ocId + ) + } + + let downloadedURL = try await loader.downloadMedia( + for: metadata, + index: index + ) + + guard !Task.isCancelled else { + return + } + + await setReadyState( + metadata: metadata, + previewURL: previewURL, + localURL: downloadedURL, + for: ocId, + index: index + ) + } catch is CancellationError { + return + } catch { + setState( + .failed( + previewURL: previewURL, + message: error.localizedDescription + ), + for: ocId + ) + } + } + + // MARK: - Prefetch + + /// Prefetches nearby pages around the selected index. + /// + /// The prefetch window is intentionally wider for smooth image navigation. + /// Video and audio remain lightweight because `loadPageForPrefetch(index:)` + /// only resolves metadata and preview state, without starting playback, + /// creating AVPlayer/VLC instances, or resolving direct video download URLs. + /// + /// - Parameter index: Current selected absolute index. + private func prefetchNeighborPages(around index: Int) { + let prefetchRadius = 5 + + let neighborIndexes = (-prefetchRadius...prefetchRadius) + .map { index + $0 } + .filter { $0 != index } + .filter { ocIds.indices.contains($0) } + + for neighborIndex in neighborIndexes { + Task { [weak self] in + guard let self else { + return + } + + await self.prefetchPageIfNeeded(index: neighborIndex) + } + } + } + + /// Prefetches one page if it has not started loading yet. + /// + /// - Parameter index: Absolute page index inside the full `ocIds` array. + private func prefetchPageIfNeeded(index: Int) async { + guard ocIds.indices.contains(index) else { + return + } + + let ocId = ocIds[index] + + guard pageState(for: ocId).isIdle else { + return + } + + guard loadingTasksByOcId[ocId] == nil else { + return + } + + let identifier = UUID() + + let task = Task { [weak self] in + guard let self else { + return + } + + await self.loadPageForPrefetch(index: index) + } + + loadingTasksByOcId[ocId] = NCMediaViewerLoadingTask( + identifier: identifier, + kind: .prefetch, + task: task + ) + + await task.value + + clearLoadingTaskIfCurrent( + ocId: ocId, + identifier: identifier + ) + } + + /// Loads a page for neighbor prefetch. + /// + /// Prefetch resolves metadata and preview only. + /// It never downloads the full media file and never starts playback. + /// + /// - Parameter index: Absolute page index inside the full `ocIds` array. + private func loadPageForPrefetch(index: Int) async { + guard ocIds.indices.contains(index) else { + return + } + + nkLog( + tag: NCGlobal.shared.logTagViewer, + emoji: .debug, + message: "LOAD PREFETCH \(index)", + consoleOnly: true + ) + + let ocId = ocIds[index] + + let metadata = await resolvedMetadata(for: ocId) + + guard !Task.isCancelled else { + return + } + + guard let metadata else { + return + } + + setMetadata(metadata, for: ocId) + + let previewURL = await loader.previewURL( + for: metadata, + index: index + ) + + guard !Task.isCancelled else { + return + } + + if isImage(metadata), let previewURL { + setState( + .image( + previewURL: previewURL, + localURL: nil, + livePhotoURL: nil, + progress: nil + ), + for: ocId + ) + return + } + + if isVideo(metadata) { + setState( + .downloading( + previewURL: previewURL, + progress: nil + ), + for: ocId + ) + return + } + + if isAudio(metadata) { + setState( + .downloading( + previewURL: previewURL, + progress: nil + ), + for: ocId + ) + return + } + } + + // MARK: - Page Updates + + /// Resolves detached metadata for an `ocId`. + /// + /// - Parameter ocId: Nextcloud file identifier. + /// - Returns: Existing cached metadata or metadata loaded from the loader. + private func resolvedMetadata(for ocId: String) async -> tableMetadata? { + if let existingMetadata = cachedPagesByOcId[ocId]?.metadata { + return existingMetadata + } + + return await loader.metadata(for: ocId, account: session.account, mediaSearch: mediaSearch) + } + + /// Returns the current state for an `ocId`. + /// + /// - Parameter ocId: Nextcloud file identifier. + /// - Returns: Page state. + private func pageState(for ocId: String) -> NCMediaViewerPageState { + cachedPagesByOcId[ocId]?.state ?? .idle + } + + /// Returns whether the metadata represents an audio file. + /// + /// - Parameter metadata: Detached metadata. + /// - Returns: True when the media is an audio file. + private func isAudio(_ metadata: tableMetadata) -> Bool { + metadata.classFile == NKTypeClassFile.audio.rawValue + } + + /// Returns whether the metadata represents a video. + /// + /// - Parameter metadata: Detached metadata. + /// - Returns: True when the media is a video. + private func isVideo(_ metadata: tableMetadata) -> Bool { + metadata.classFile == NKTypeClassFile.video.rawValue + } + + /// Returns the currently cached preview URL for a page, if any. + /// + /// - Parameter ocId: Page file identifier. + /// - Returns: Cached preview URL if the current page state contains one. + private func currentPreviewURL(for ocId: String) -> URL? { + guard let page = cachedPagesByOcId[ocId] else { + return nil + } + + switch page.state { + case .image(let previewURL, _, _, _): + return previewURL + + case .video(let previewURL): + return previewURL + + case .downloading(let previewURL, _): + return previewURL + + case .ready(_, let previewURL), + .failed(let previewURL, _): + return previewURL + + case .idle, + .loadingMetadata, + .metadataMissing, + .deleted, + .checkingLocalFile: + return nil + } + } + + /// Updates the metadata for a page. + /// + /// - Parameters: + /// - metadata: Detached metadata. + /// - ocId: Page file identifier. + private func setMetadata(_ metadata: tableMetadata, for ocId: String) { + updatePage(ocId: ocId) { page in + page.metadata = metadata + } + } + + /// Updates the state for a page. + /// + /// - Parameters: + /// - state: New page state. + /// - ocId: Page file identifier. + private func setState(_ state: NCMediaViewerPageState, for ocId: String) { + updatePage(ocId: ocId) { page in + page.state = state + } + } + + /// Sets the correct ready state for image and non-image media. + /// + /// - Parameters: + /// - metadata: Detached metadata. + /// - previewURL: Optional local preview URL. + /// - localURL: Local full media URL. + /// - ocId: Page file identifier. + /// - index: Page index used for debug logs. + private func setReadyState( + metadata: tableMetadata, + previewURL: URL?, + localURL: URL, + for ocId: String, + index: Int + ) async { + if isImage(metadata) { + let livePhotoURL: URL? + + if metadata.isLivePhoto { + livePhotoURL = await loader.downloadLivePhotoMedia( + for: metadata, + index: index + ) + } else { + livePhotoURL = nil + } + + setState( + .image( + previewURL: previewURL, + localURL: localURL, + livePhotoURL: livePhotoURL, + progress: nil + ), + for: ocId + ) + } else { + setState( + .ready( + localURL: localURL, + previewURL: previewURL + ), + for: ocId + ) + } + } + + /// Mutates a cached page and publishes a model revision. + /// + /// - Parameters: + /// - ocId: Page file identifier. + /// - mutation: Mutation applied to the page model. + private func updatePage( + ocId: String, + mutation: (inout NCMediaViewerPageModel) -> Void + ) { + guard let index = ocIds.firstIndex(of: ocId) else { + return + } + + var page = cachedPagesByOcId[ocId] ?? NCMediaViewerPageModel( + index: index, + ocId: ocId, + metadata: nil, + state: .idle + ) + + mutation(&page) + + cachedPagesByOcId[ocId] = page + revision &+= 1 + } + + /// Clears a loading task only if it is still the current task for the page. + /// + /// This prevents an older cancelled task from removing a newer task stored + /// under the same `ocId`. + /// + /// - Parameters: + /// - ocId: Page file identifier. + /// - identifier: Task identifier to validate. + private func clearLoadingTaskIfCurrent( + ocId: String, + identifier: UUID + ) { + guard loadingTasksByOcId[ocId]?.identifier == identifier else { + return + } + + loadingTasksByOcId[ocId] = nil + } + + /// Returns whether the metadata represents an image. + /// + /// - Parameter metadata: Detached metadata. + /// - Returns: True when the media is an image. + private func isImage(_ metadata: tableMetadata) -> Bool { + metadata.classFile == NKTypeClassFile.image.rawValue + } +} + +// MARK: - NCMediaViewerPageState Helpers + +private extension NCMediaViewerPageState { + /// Returns true when the page has not started loading yet. + var isIdle: Bool { + switch self { + case .idle: + return true + + case .loadingMetadata, + .metadataMissing, + .checkingLocalFile, + .image, + .video, + .downloading, + .ready, + .deleted, + .failed: + return false + } + } + + /// Returns true when selected-page loading should continue. + /// + /// A prefetched image page can already have a preview but still needs + /// selected-page loading to download the full image file. + /// + /// Video is considered resolved only after selected-page loading sets `.video`. + /// Prefetch must use `.downloading(previewURL:progress:)` for videos so selected-page + /// loading can still run when the user reaches the page. + var needsSelectedPageLoading: Bool { + switch self { + case .idle: + return true + + case .image(_, nil, _, _): + return true + + case .downloading: + return true + + case .image(_, .some, _, _), + .video, + .loadingMetadata, + .metadataMissing, + .checkingLocalFile, + .ready, + .deleted, + .failed: + return false + } + } +} diff --git a/iOSClient/Viewer/NCViewerMedia/Model - View/NCMediaViewerView.swift b/iOSClient/Viewer/NCViewerMedia/Model - View/NCMediaViewerView.swift new file mode 100644 index 0000000000..f1367dbf6b --- /dev/null +++ b/iOSClient/Viewer/NCViewerMedia/Model - View/NCMediaViewerView.swift @@ -0,0 +1,104 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Marino Faggiana +// SPDX-License-Identifier: GPL-3.0-or-later + +import SwiftUI + +// MARK: - Media Viewer View + +/// Main SwiftUI media viewer. +/// +/// This view owns the `NCMediaViewerModel` as a `StateObject`. +/// Paging is handled by `NCMediaViewerPagingView`, which is backed by +/// `UICollectionView` to support large virtualized media lists. +/// +/// Navigation buttons and title are provided by `NCMediaViewerHostingController`. +struct NCMediaViewerView: View { + @StateObject private var model: NCMediaViewerModel + let contextMenuController: NCMainTabBarController? + let navigationBar: UINavigationBar? + let onVisibleMetadataChanged: (_ metadata: tableMetadata?, _ backgroundColor: UIColor) -> Void + let onClose: (_ ocId: String?) -> Void + + /// Creates the media viewer view. + /// + /// - Parameters: + /// - model: Media viewer model containing page state and loading logic. + /// - contextMenuController: Optional controller used to present context menu actions. + /// - navigationBar: Optional navigation bar reference used by video controls for top action positioning. + /// - onVisibleMetadataChanged: Callback invoked when the visually visible page metadata and background color change. + /// - onClose: Callback invoked with the current media ocId when the media viewer should close. + init( + model: NCMediaViewerModel, + contextMenuController: NCMainTabBarController? = nil, + navigationBar: UINavigationBar? = nil, + onVisibleMetadataChanged: @escaping (_ metadata: tableMetadata?, _ backgroundColor: UIColor) -> Void = { _, _ in }, + onClose: @escaping (_ ocId: String?) -> Void = { _ in } + ) { + _model = StateObject(wrappedValue: model) + self.contextMenuController = contextMenuController + self.navigationBar = navigationBar + self.onVisibleMetadataChanged = onVisibleMetadataChanged + self.onClose = onClose + } + + var body: some View { + ZStack { + Color.ncViewerBackground(.system) + .ignoresSafeArea() + + NCMediaViewerPagingView( + model: model, + contextMenuController: contextMenuController, + navigationBar: navigationBar, + onVisibleMetadataChanged: onVisibleMetadataChanged, + onClose: onClose + ) + .ignoresSafeArea() + } + .background(Color.ncViewerBackground(.system)) + .ignoresSafeArea() + .statusBarHidden(true) + .task { + await model.loadSelectedPageIfNeeded() + } + } +} + +// MARK: - Media Viewer Preview + +#if DEBUG +import NextcloudKit + +#Preview("Media Viewer - Light") { + NCMediaViewerView.previewView() + .preferredColorScheme(.light) +} + +#Preview("Media Viewer - Dark") { + NCMediaViewerView.previewView() + .preferredColorScheme(.dark) +} + +private extension NCMediaViewerView { + static func previewView() -> some View { + let metadata = tableMetadata() + metadata.ocId = "preview-ocid" + metadata.fileName = "preview.jpg" + metadata.fileNameView = "preview.jpg" + metadata.classFile = NKTypeClassFile.image.rawValue + + let model = NCMediaViewerModel( + currentMetadata: metadata.detachedCopy(), + ocIds: [ + metadata.ocId + ], + session: NCSession().getSession(account: ""), + mediaSearch: false, + loader: NCMediaViewerLoader() + ) + + return NCMediaViewerView(model: model) + } +} +#endif diff --git a/iOSClient/Viewer/NCViewerMedia/NCMediaViewerHostingController.swift b/iOSClient/Viewer/NCViewerMedia/NCMediaViewerHostingController.swift new file mode 100644 index 0000000000..666e84a0ef --- /dev/null +++ b/iOSClient/Viewer/NCViewerMedia/NCMediaViewerHostingController.swift @@ -0,0 +1,520 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Marino Faggiana +// SPDX-License-Identifier: GPL-3.0-or-later + +import SwiftUI +import UIKit +import Combine +import NextcloudKit + +// MARK: - Media Viewer Hosting Controller + +/// UIKit hosting controller used by the media viewer. +/// +/// This controller embeds the SwiftUI media viewer and provides standard UIKit +/// navigation items for the title, close button, context menu button, and detail button. +@MainActor +final class NCMediaViewerHostingController: UIHostingController, UIAdaptivePresentationControllerDelegate { + private let model: NCMediaViewerModel + private let onClose: (_ ocId: String?) -> Void + private weak var contextMenuController: NCMainTabBarController? + + private var detailHostingController: UIHostingController? + private var isShowingDetail = false + private var cancellables = Set() + private var transferDelegate: NCMediaViewerTransferDelegate? + private weak var currentNavigationBar: UINavigationBar? + private let floatingTitleView = NCViewerFloatingTitleView() + + private lazy var floatingTitleDateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.locale = .current + formatter.dateStyle = .medium + formatter.timeStyle = .short + return formatter + }() + + private lazy var moreNavigationItem = UIBarButtonItem( + image: NCImageCache.shared.getImageButtonMore(), + primaryAction: nil, + menu: UIMenu(title: "", children: [ + UIDeferredMenuElement.uncached { [weak self] completion in + guard let self, + let metadata = self.model.selectedMetadata else { + completion([]) + return + } + + if let menu = NCContextMenuViewer( + metadata: metadata, + controller: self.contextMenuController, + viewController: self, + webView: false, + sender: self + ).viewMenu() { + completion(menu.children) + } else { + completion([]) + } + } + ]) + ) + + private lazy var mediaDetailNavigationItem = UIBarButtonItem( + image: NCUtility().loadImage( + named: "info.circle", + colors: [NCBrandColor.shared.iconImageColor] + ), + style: .plain, + target: self, + action: #selector(mediaDetailButtonTapped) + ) + + /// Creates a media viewer hosting controller. + /// + /// - Parameters: + /// - model: Media viewer model used to render and page through media items. + /// - contextMenuController: Main tab bar controller used to build viewer context menus. + /// - onClose: Closure called when the viewer should close, optionally with the media ocId that initiated the close. + init( + model: NCMediaViewerModel, + contextMenuController: NCMainTabBarController?, + onClose: @escaping (_ ocId: String?) -> Void + ) { + self.model = model + self.contextMenuController = contextMenuController + self.onClose = onClose + + super.init( + rootView: NCMediaViewerView( + model: model, + contextMenuController: contextMenuController, + navigationBar: nil, + onVisibleMetadataChanged: { _, _ in }, + onClose: { _ in } + ) + ) + + rootView = makeRootView(navigationBar: nil) + + transferDelegate = NCMediaViewerTransferDelegate { [weak self] deletedOcId in + guard let self else { + return + } + + self.model.markPageAsDeleted(ocId: deletedOcId) + } + + view.backgroundColor = .ncViewerBackground(.system) + edgesForExtendedLayout = [.all] + extendedLayoutIncludesOpaqueBars = true + additionalSafeAreaInsets = .zero + + configureNavigationItem() + observeModel() + } + + @MainActor + @available(*, unavailable) + dynamic required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + + updateTitleLabel( + metadata: model.selectedMetadata, + backgroundColor: .ncViewerBackground(.system) + ) + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + guard let transferDelegate else { + return + } + + Task { + await NCNetworking.shared.transferDispatcher.addDelegate(transferDelegate) + } + } + + override func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) + + guard let transferDelegate else { + return + } + + Task { + await NCNetworking.shared.transferDispatcher.removeDelegate(transferDelegate) + } + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + + updateRootViewNavigationBarIfNeeded() + configureFloatingTitleViewIfNeeded() + } + + private func updateRootViewNavigationBarIfNeeded() { + let navigationBar = navigationController?.navigationBar + + guard currentNavigationBar !== navigationBar else { + return + } + + currentNavigationBar = navigationBar + rootView = makeRootView(navigationBar: navigationBar) + } + + /// Builds the SwiftUI media viewer root view. + /// + /// - Parameter navigationBar: Current navigation bar used by hosted media pages. + /// - Returns: Configured media viewer root view. + private func makeRootView(navigationBar: UINavigationBar?) -> NCMediaViewerView { + NCMediaViewerView( + model: model, + contextMenuController: contextMenuController, + navigationBar: navigationBar, + onVisibleMetadataChanged: { [weak self] metadata, backgroundColor in + self?.updateTitleLabel( + metadata: metadata, + backgroundColor: backgroundColor + ) + }, + onClose: { [weak self] ocId in + self?.close(ocId: ocId) + } + ) + } + + // MARK: - Closing + + /// Stops media playback before the viewer is closed. + private func stop() { + NotificationCenter.default.post( + name: .ncMediaViewerStopPlayback, + object: nil + ) + } + + /// Closes the viewer. + /// + /// - Parameter ocId: Optional Nextcloud file identifier that initiated the close. + func close(ocId: String? = nil) { + stop() + onClose(ocId) + } + + // MARK: - Navigation + + /// Configures the navigation item used by the viewer. + private func configureNavigationItem() { + navigationItem.largeTitleDisplayMode = .never + navigationItem.title = nil + navigationItem.titleView = nil + + navigationItem.leftBarButtonItem = UIBarButtonItem( + image: UIImage(systemName: "chevron.left"), + style: .plain, + target: self, + action: #selector(closeButtonTapped) + ) + + navigationItem.rightBarButtonItems = [ + moreNavigationItem, + mediaDetailNavigationItem + ] + } + + /// Observes model changes and refreshes navigation UI. + private func observeModel() { + model.$isChromeHidden + .receive(on: RunLoop.main) + .sink { [weak self] isHidden in + self?.setChromeHidden(isHidden, animated: true) + } + .store(in: &cancellables) + } + + /// Configures the floating title view inside the navigation bar chrome. + private func configureFloatingTitleViewIfNeeded() { + guard let navigationBar = navigationController?.navigationBar else { + return + } + + floatingTitleView.attach(to: navigationBar) + } + + /// Updates the floating title view using the provided media metadata and background color. + /// + /// - Parameters: + /// - metadata: Media metadata used to build the visible title content. + /// - backgroundColor: Current visible page background color used to choose a readable title color. + private func updateTitleLabel( + metadata: tableMetadata?, + backgroundColor: UIColor + ) { + guard let metadata else { + floatingTitleView.clear() + return + } + + let primaryTitle = metadata.fileNameView.isEmpty + ? metadata.fileName + : metadata.fileNameView + + floatingTitleView.update( + primaryText: primaryTitle, + secondaryText: floatingTitleSecondaryText(for: metadata), + textColor: floatingTitleTextColor(for: backgroundColor) + ) + } + + /// Returns a readable title text color for the provided background color. + /// + /// - Parameter backgroundColor: Current visible page background color. + /// - Returns: White text on dark backgrounds, black text on light backgrounds. + private func floatingTitleTextColor(for backgroundColor: UIColor) -> UIColor { + let resolvedColor = backgroundColor.resolvedColor(with: traitCollection) + var red: CGFloat = 0 + var green: CGFloat = 0 + var blue: CGFloat = 0 + var alpha: CGFloat = 0 + + guard resolvedColor.getRed( + &red, + green: &green, + blue: &blue, + alpha: &alpha + ) else { + return .white + } + + let luminance = (0.299 * red) + (0.587 * green) + (0.114 * blue) + return luminance < 0.5 ? .white : .black + } + + /// Builds the secondary floating title text for the provided metadata. + /// + /// - Parameter metadata: Media metadata used to derive the secondary title line. + /// - Returns: Secondary title text shown below the main title. + private func floatingTitleSecondaryText(for metadata: tableMetadata) -> String? { + floatingTitleDateFormatter.string(from: metadata.date as Date) + } + + /// Shows or hides the viewer chrome. + /// + /// - Parameters: + /// - hidden: Whether the chrome should be hidden. + /// - animated: Whether the transition should be animated. + private func setChromeHidden(_ hidden: Bool, animated: Bool) { + navigationController?.setNavigationBarHidden( + hidden, + animated: animated + ) + + UIView.animate( + withDuration: animated ? 0.2 : 0, + delay: 0, + options: [.curveEaseInOut] + ) { + self.view.backgroundColor = hidden + ? .black + : .ncViewerBackground(.system) + self.floatingTitleView.alpha = hidden ? 0 : 1 + } + } + + @objc + private func closeButtonTapped() { + close() + } + + @objc + private func mediaDetailButtonTapped() { + guard !isSelectedPageDeleted else { + return + } + + openDetail(animated: true) + } + + // MARK: - Detail + + private var isSelectedPageDeleted: Bool { + guard let page = model.selectedPageModel() else { + return false + } + + if case .deleted = page.state { + return true + } + + return false + } + + /// Opens or closes the media detail panel for the currently selected media item. + /// + /// - Parameter animated: Whether the presentation should be animated. + private func openDetail(animated: Bool = true) { + guard !isShowingDetail else { + closeDetail(animated: animated) + return + } + + guard let metadata = model.selectedMetadata else { + return + } + + let index = model.selectedIndex + isShowingDetail = true + + NCUtility().getExif(metadata: metadata) { [weak self] exif in + Task { @MainActor in + guard let self else { + return + } + + self.presentDetailView( + metadata: metadata, + index: index, + exif: exif, + animated: animated + ) + } + } + } + + /// Presents the SwiftUI media detail panel. + /// + /// - Parameters: + /// - metadata: Current selected media metadata. + /// - index: Page index associated with the metadata. + /// - exif: EXIF information resolved for the selected media. + /// - animated: Whether presentation should be animated. + private func presentDetailView( + metadata: tableMetadata, + index: Int, + exif: ExifData, + animated: Bool + ) { + let detailView = NCMediaViewerDetailView( + metadata: metadata, + exif: exif + ) + + let hostingController = UIHostingController(rootView: detailView) + hostingController.modalPresentationStyle = .pageSheet + + if let sheetPresentationController = hostingController.sheetPresentationController { + sheetPresentationController.detents = [.medium(), .large()] + sheetPresentationController.prefersGrabberVisible = true + sheetPresentationController.preferredCornerRadius = 24 + sheetPresentationController.prefersEdgeAttachedInCompactHeight = true + sheetPresentationController.widthFollowsPreferredContentSizeWhenEdgeAttached = false + } + + detailHostingController = hostingController + hostingController.presentationController?.delegate = self + + present(hostingController, animated: animated) + } + + /// Closes the media detail panel. + /// + /// - Parameter animated: Whether dismissal should be animated. + private func closeDetail(animated: Bool = true) { + guard let detailHostingController else { + isShowingDetail = false + return + } + + detailHostingController.dismiss(animated: animated) { [weak self] in + self?.detailHostingController = nil + self?.isShowingDetail = false + } + } + + /// Resets the detail state when the sheet is dismissed interactively. + func presentationControllerDidDismiss(_ presentationController: UIPresentationController) { + detailHostingController = nil + isShowingDetail = false + } + + /// Marks the currently selected media item as deleted in the viewer. + /// + /// This is used immediately after the user confirms a delete action, before the + /// asynchronous transfer delegate reports the delete completion. + @MainActor + func markCurrentItemAsDeleted() { + guard let metadata = model.selectedMetadata else { + return + } + + model.markPageAsDeleted(ocId: metadata.ocId) + } + + /// Marks a specific media item as deleted in the viewer. + /// + /// - Parameter ocId: Deleted file identifier. + @MainActor + func markItemAsDeleted(ocId: String) { + model.markPageAsDeleted(ocId: ocId) + } +} + +// MARK: - Media Viewer Transfer Delegate + +/// Bridges transfer events into the MainActor-isolated media viewer controller. +/// +/// `NCTransferDelegate` is not MainActor-isolated, so `NCMediaViewerHostingController` +/// must not conform to it directly in Swift 6. +final class NCMediaViewerTransferDelegate: NSObject, NCTransferDelegate { + private let onDeletedOcId: @MainActor (_ ocId: String) -> Void + let sceneIdentifier: String = "" + + init(onDeletedOcId: @escaping @MainActor (_ ocId: String) -> Void) { + self.onDeletedOcId = onDeletedOcId + } + + func transferReloadData(serverUrl: String?) { } + + func transferReloadDataSource( + serverUrl: String?, + requestData: Bool, + status: Int? + ) { } + + func transferProgressDidUpdate( + progress: Float, + totalBytes: Int64, + totalBytesExpected: Int64, + fileName: String, + serverUrl: String + ) { } + + func transferChange( + status: String, + account: String, + fileName: String, + serverUrl: String, + selector: String?, + ocId: String, + destination: String?, + error: NKError + ) { + guard status == NCGlobal.shared.networkingStatusDelete, + error == .success else { + return + } + + Task { @MainActor in + onDeletedOcId(ocId) + } + } +} diff --git a/iOSClient/Viewer/NCViewerMedia/NCMediaViewerPresenter.swift b/iOSClient/Viewer/NCViewerMedia/NCMediaViewerPresenter.swift new file mode 100644 index 0000000000..4269e5f828 --- /dev/null +++ b/iOSClient/Viewer/NCViewerMedia/NCMediaViewerPresenter.swift @@ -0,0 +1,574 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Marino Faggiana +// SPDX-License-Identifier: GPL-3.0-or-later + +import SwiftUI +import UIKit + +// MARK: - Media Viewer Presenter + +/// Presents the media viewer as a fullscreen overlay above the current window. +/// +/// The presenter installs a dedicated `UINavigationController` directly on the +/// active window instead of pushing into the app navigation stack. This keeps the +/// viewer independent from the current screen while still allowing the viewer to +/// use a real navigation bar for title, close, and menu actions. +/// +/// When a transition source is provided, the presenter animates the visible +/// thumbnail into the fullscreen viewer and animates the currently selected media +/// item back into its matching thumbnail frame on dismissal. +@MainActor +final class NCMediaViewerPresenter: NSObject { + static let shared = NCMediaViewerPresenter() + + private var navigationController: UINavigationController? + private weak var viewerContainerView: UIView? + private var currentViewerTransitionSource: NCViewerTransitionSource? + private weak var currentModel: NCMediaViewerModel? + + private var closingTransitionSourceProvider: ((_ ocId: String) -> NCViewerTransitionSource?)? + private var forcedClosingOcId: String? + + private let openingAnimationDuration: TimeInterval = 0.28 + private let closingAnimationDuration: TimeInterval = 0.24 + + private var dismissPanGesture: UIPanGestureRecognizer? + private weak var dismissPanGestureView: UIView? + private var isTrackingDismissPan = false + private var isDismissing = false + + private override init() { + super.init() + } + + // MARK: - Presentation + + /// Shows the media viewer above the current window. + /// + /// - Parameters: + /// - model: Media viewer model used to render and page through media items. + /// - viewerTransitionSource: Optional thumbnail source used for the opening animation. + /// - sourceView: Optional view used to resolve the current window. When nil, the active foreground key window is used. + /// - contextMenuController: Controller used by the viewer context menu. + /// - closingTransitionSourceProvider: Optional provider used to resolve the current thumbnail source on dismissal. + func show( + model: NCMediaViewerModel, + viewerTransitionSource: NCViewerTransitionSource?, + from sourceView: UIView? = nil, + contextMenuController: NCMainTabBarController? = nil, + closingTransitionSourceProvider: ((_ ocId: String) -> NCViewerTransitionSource?)? = nil + ) { + guard let window = sourceView?.window ?? activeWindow() else { + return + } + + dismiss(animated: false) + + currentViewerTransitionSource = viewerTransitionSource + currentModel = model + self.closingTransitionSourceProvider = closingTransitionSourceProvider + forcedClosingOcId = nil + isDismissing = false + + let hostingController = NCMediaViewerHostingController( + model: model, + contextMenuController: contextMenuController, + onClose: { [weak self] ocId in + guard let self else { + return + } + + guard let ocId else { + forcedClosingOcId = nil + dismiss(animated: false) + return + } + + forcedClosingOcId = ocId + dismiss(animated: true) + } + ) + + let navigationController = UINavigationController( + rootViewController: hostingController + ) + + configureNavigationController(navigationController) + + navigationController.view.backgroundColor = .ncViewerBackground(.system) + navigationController.view.frame = window.bounds + navigationController.view.autoresizingMask = [ + .flexibleWidth, + .flexibleHeight + ] + + self.navigationController = navigationController + self.viewerContainerView = navigationController.view + + installDismissPanGesture(on: navigationController.view) + + if let viewerTransitionSource { + navigationController.view.alpha = 0 + window.addSubview(navigationController.view) + + animateOpening( + viewerTransitionSource: viewerTransitionSource, + in: window, + viewerView: navigationController.view + ) + } else { + navigationController.view.alpha = 1 + window.addSubview(navigationController.view) + } + } + + /// Dismisses the current media viewer overlay. + /// + /// - Parameter animated: Whether dismissal should be animated. + func dismiss(animated: Bool = true) { + guard !isDismissing else { + return + } + + guard let viewerContainerView else { + cleanup() + return + } + + isDismissing = true + removeDismissPanGesture() + + guard animated else { + viewerContainerView.removeFromSuperview() + cleanup() + return + } + + if let closingTransitionSource = currentClosingTransitionSource(), + let window = viewerContainerView.window { + let closingImage = currentClosingImage() + ?? closingTransitionSource.image + + animateClosing( + viewerTransitionSource: closingTransitionSource, + closingImage: closingImage, + in: window, + viewerView: viewerContainerView + ) + return + } + + UIView.animate( + withDuration: closingAnimationDuration, + delay: 0, + options: [.curveEaseInOut] + ) { + viewerContainerView.alpha = 0 + } completion: { [weak self] _ in + viewerContainerView.removeFromSuperview() + self?.cleanup() + } + } + + // MARK: - Navigation Appearance + + /// Configures the dedicated navigation controller used by the viewer. + /// + /// The navigation bar is transparent and overlays the SwiftUI content, allowing + /// media pages to remain fullscreen while still using standard UIKit navigation + /// items. + /// + /// - Parameter navigationController: Viewer navigation controller. + private func configureNavigationController(_ navigationController: UINavigationController) { + navigationController.setNavigationBarHidden(false, animated: false) + navigationController.navigationBar.isTranslucent = true + navigationController.navigationBar.tintColor = .label + navigationController.navigationBar.prefersLargeTitles = false + + let appearance = UINavigationBarAppearance() + appearance.configureWithTransparentBackground() + appearance.backgroundColor = .clear + appearance.shadowColor = .clear + appearance.titleTextAttributes = [ + .foregroundColor: UIColor.label, + .font: UIFont.systemFont(ofSize: 17, weight: .semibold) + ] + + navigationController.navigationBar.standardAppearance = appearance + navigationController.navigationBar.scrollEdgeAppearance = appearance + navigationController.navigationBar.compactAppearance = appearance + navigationController.navigationBar.compactScrollEdgeAppearance = appearance + } + + // MARK: - Dismiss Pan Gesture + + /// Installs the swipe-down dismiss gesture on the fullscreen viewer container. + /// + /// The gesture is attached at presenter level, above the paging implementation, + /// so it does not require custom logic inside collection view cells or SwiftUI pages. + /// + /// - Parameter view: Viewer container view. + private func installDismissPanGesture(on view: UIView) { + removeDismissPanGesture() + + let gesture = UIPanGestureRecognizer( + target: self, + action: #selector(handleDismissPanGesture(_:)) + ) + + gesture.cancelsTouchesInView = false + gesture.delegate = self + + view.addGestureRecognizer(gesture) + + dismissPanGesture = gesture + dismissPanGestureView = view + } + + /// Removes the swipe-down dismiss gesture from the viewer container. + private func removeDismissPanGesture() { + if let dismissPanGesture, + let dismissPanGestureView { + dismissPanGestureView.removeGestureRecognizer(dismissPanGesture) + } + + dismissPanGesture = nil + dismissPanGestureView = nil + isTrackingDismissPan = false + } + + /// Handles swipe-down dismissal from the fullscreen viewer container. + /// + /// The gesture dismisses when downward movement clearly wins over horizontal paging, + /// using permissive thresholds similar to a photo viewer drag-to-close interaction. + @objc + private func handleDismissPanGesture(_ gesture: UIPanGestureRecognizer) { + guard !isDismissing, + let view = gesture.view else { + return + } + + let translation = gesture.translation(in: view) + let velocity = gesture.velocity(in: view) + + let verticalDistance = translation.y + let horizontalDistance = abs(translation.x) + let downwardVelocity = velocity.y + + switch gesture.state { + case .began: + isTrackingDismissPan = false + + case .changed: + guard verticalDistance > 0 else { + return + } + + let isMostlyVertical = verticalDistance > horizontalDistance * 1.10 + + guard isMostlyVertical else { + return + } + + isTrackingDismissPan = true + + case .ended: + defer { + isTrackingDismissPan = false + } + + guard isTrackingDismissPan else { + return + } + + let shouldDismiss = verticalDistance > 70 || downwardVelocity > 550 + + guard shouldDismiss else { + return + } + + dismiss(animated: true) + + case .cancelled, + .failed: + isTrackingDismissPan = false + + default: + break + } + } + + // MARK: - Opening Animation + + /// Animates the source thumbnail into the fullscreen viewer. + /// + /// The real viewer is kept hidden until the temporary transition image reaches + /// its destination frame. This prevents seeing both the viewer image and the + /// transition image at the same time. + /// + /// - Parameters: + /// - viewerTransitionSource: Source thumbnail data. + /// - window: Window that contains the overlay transition views. + /// - viewerView: Real viewer container view to reveal at the end. + private func animateOpening( + viewerTransitionSource: NCViewerTransitionSource, + in window: UIWindow, + viewerView: UIView + ) { + let dimView = UIView(frame: window.bounds) + dimView.backgroundColor = .ncViewerBackground(.system) + dimView.alpha = 0 + dimView.autoresizingMask = [ + .flexibleWidth, + .flexibleHeight + ] + + let imageView = UIImageView(image: viewerTransitionSource.image) + imageView.contentMode = .scaleAspectFill + imageView.clipsToBounds = true + imageView.frame = viewerTransitionSource.sourceFrame + imageView.layer.cornerRadius = viewerTransitionSource.cornerRadius + + window.addSubview(dimView) + window.addSubview(imageView) + + let destinationFrame = aspectFitFrame( + imageSize: viewerTransitionSource.image.size, + containerSize: window.bounds.size + ) + + viewerView.alpha = 0 + + UIView.animate( + withDuration: openingAnimationDuration, + delay: 0, + options: [.curveEaseInOut] + ) { + dimView.alpha = 1 + imageView.frame = destinationFrame + imageView.layer.cornerRadius = 0 + } completion: { _ in + viewerView.alpha = 1 + imageView.removeFromSuperview() + dimView.removeFromSuperview() + } + } + + // MARK: - Closing Animation + + /// Animates the fullscreen viewer back into the current thumbnail frame. + /// + /// The real viewer is hidden immediately and replaced by a temporary transition + /// image, avoiding double-image artifacts during the zoom-out animation. + /// + /// - Parameters: + /// - viewerTransitionSource: Current thumbnail data used as closing destination. + /// - closingImage: Image currently displayed by the viewer, used during the closing transition. + /// - window: Window that contains the overlay transition views. + /// - viewerView: Real viewer container view to dismiss. + private func animateClosing( + viewerTransitionSource: NCViewerTransitionSource, + closingImage: UIImage, + in window: UIWindow, + viewerView: UIView + ) { + let startFrame = aspectFitFrame( + imageSize: closingImage.size, + containerSize: window.bounds.size + ) + + let imageView = UIImageView(image: closingImage) + imageView.contentMode = .scaleAspectFill + imageView.clipsToBounds = true + imageView.frame = startFrame + imageView.layer.cornerRadius = 0 + + window.addSubview(imageView) + + viewerView.alpha = 0 + + UIView.animate( + withDuration: closingAnimationDuration, + delay: 0, + options: [.curveEaseInOut] + ) { + imageView.frame = viewerTransitionSource.sourceFrame + imageView.layer.cornerRadius = viewerTransitionSource.cornerRadius + } completion: { [weak self] _ in + imageView.removeFromSuperview() + viewerView.removeFromSuperview() + self?.cleanup() + } + } + + // MARK: - Closing Source + + /// Returns the transition source for the currently selected media item. + /// + /// The source controller knows how to map the current `ocId` to the visible + /// thumbnail frame. If no current source can be resolved, the presenter closes + /// without a thumbnail transition. + /// + /// - Returns: Current transition source if available. + private func currentClosingTransitionSource() -> NCViewerTransitionSource? { + let ocId = forcedClosingOcId ?? currentModel?.selectedOcId + + guard let ocId else { + return nil + } + + return closingTransitionSourceProvider?(ocId) + } + + /// Returns the best currently displayed image for the closing transition. + /// + /// The full local image is preferred when available. + /// If the full image is not available yet, the preview image is used. + /// If no current image can be resolved, the caller should fall back to the + /// transition source image. + /// + /// - Returns: Current image suitable for the closing transition. + private func currentClosingImage() -> UIImage? { + guard let page = currentModel?.selectedPageModel() else { + return nil + } + + switch page.state { + case .image(let previewURL, let localURL, _, _): + if let localURL, + let image = UIImage(contentsOfFile: localURL.path) { + return image + } + + if let previewURL { + return UIImage(contentsOfFile: previewURL.path) + } + + return nil + + case .video(let previewURL): + guard let previewURL else { + return nil + } + + return UIImage(contentsOfFile: previewURL.path) + + case .ready(let localURL, let previewURL): + if let image = UIImage(contentsOfFile: localURL.path) { + return image + } + + if let previewURL { + return UIImage(contentsOfFile: previewURL.path) + } + + return nil + + case .downloading(let previewURL, _), + .failed(let previewURL, _): + guard let previewURL else { + return nil + } + + return UIImage(contentsOfFile: previewURL.path) + + case .deleted, + .idle, + .loadingMetadata, + .metadataMissing, + .checkingLocalFile: + return nil + } + } + + // MARK: - Cleanup + + /// Clears retained presenter state after the viewer has been removed. + private func cleanup() { + NotificationCenter.default.post( + name: .ncMediaViewerStopPlayback, + object: nil + ) + + navigationController = nil + viewerContainerView = nil + currentViewerTransitionSource = nil + currentModel = nil + closingTransitionSourceProvider = nil + forcedClosingOcId = nil + } + + // MARK: - Helpers + + /// Returns the current active foreground key window. + /// + /// - Returns: Active foreground key window if available. + private func activeWindow() -> UIWindow? { + UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + .filter { $0.activationState == .foregroundActive } + .flatMap(\.windows) + .first { $0.isKeyWindow } + } + + /// Computes the aspect-fit frame for an image inside the fullscreen container. + /// + /// - Parameters: + /// - imageSize: Source image size. + /// - containerSize: Window size. + /// - Returns: Aspect-fit destination frame. + private func aspectFitFrame( + imageSize: CGSize, + containerSize: CGSize + ) -> CGRect { + guard imageSize.width > 0, + imageSize.height > 0, + containerSize.width > 0, + containerSize.height > 0 else { + return CGRect(origin: .zero, size: containerSize) + } + + let widthRatio = containerSize.width / imageSize.width + let heightRatio = containerSize.height / imageSize.height + let ratio = min(widthRatio, heightRatio) + + let fittedSize = CGSize( + width: imageSize.width * ratio, + height: imageSize.height * ratio + ) + + return CGRect( + x: (containerSize.width - fittedSize.width) * 0.5, + y: (containerSize.height - fittedSize.height) * 0.5, + width: fittedSize.width, + height: fittedSize.height + ) + } +} + +// MARK: - UIGestureRecognizerDelegate + +extension NCMediaViewerPresenter: UIGestureRecognizerDelegate { + func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { + guard gestureRecognizer === dismissPanGesture, + let panGesture = gestureRecognizer as? UIPanGestureRecognizer, + let view = panGesture.view else { + return true + } + + let velocity = panGesture.velocity(in: view) + + guard velocity.y > 0 else { + return false + } + + return abs(velocity.y) > abs(velocity.x) * 1.10 + } + + func gestureRecognizer( + _ gestureRecognizer: UIGestureRecognizer, + shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer + ) -> Bool { + gestureRecognizer === dismissPanGesture + } +} diff --git a/iOSClient/Viewer/NCViewerMedia/NCPlayer/NCPlayer.swift b/iOSClient/Viewer/NCViewerMedia/NCPlayer/NCPlayer.swift deleted file mode 100644 index 15e991f3fb..0000000000 --- a/iOSClient/Viewer/NCViewerMedia/NCPlayer/NCPlayer.swift +++ /dev/null @@ -1,338 +0,0 @@ -// SPDX-FileCopyrightText: Nextcloud GmbH -// SPDX-FileCopyrightText: 2021 Marino Faggiana -// SPDX-License-Identifier: GPL-3.0-or-later - -import Foundation -import NextcloudKit -import UIKit -import MobileVLCKit - -class NCPlayer: NSObject, VLCMediaDelegate { - internal var url: URL? - internal var player = VLCMediaPlayer() - internal var dialogProvider: VLCDialogProvider? - internal var metadata: tableMetadata - internal var singleTapGestureRecognizer: UITapGestureRecognizer? - internal var activityIndicator: UIActivityIndicatorView - internal let database = NCManageDatabase.shared - internal var width: Int? - internal var height: Int? - internal var length: Int? - internal var pauseAfterPlay: Bool = false - - internal weak var playerToolBar: NCPlayerToolBar? - internal weak var viewerMediaPage: NCViewerMediaPage? - - weak var imageVideoContainer: UIImageView? - - internal var counterSeconds: Double = 0 - - // MARK: - View Life Cycle - - init(imageVideoContainer: UIImageView, playerToolBar: NCPlayerToolBar?, metadata: tableMetadata, viewerMediaPage: NCViewerMediaPage?) { - self.imageVideoContainer = imageVideoContainer - self.playerToolBar = playerToolBar - self.metadata = metadata - self.viewerMediaPage = viewerMediaPage - - self.activityIndicator = UIActivityIndicatorView(style: .large) - self.activityIndicator.color = .white - self.activityIndicator.hidesWhenStopped = true - self.activityIndicator.translatesAutoresizingMaskIntoConstraints = false - - if let viewerMediaPage = viewerMediaPage { - viewerMediaPage.view.addSubview(activityIndicator) - NSLayoutConstraint.activate([ - activityIndicator.centerXAnchor.constraint(equalTo: viewerMediaPage.view.centerXAnchor), - activityIndicator.centerYAnchor.constraint(equalTo: viewerMediaPage.view.centerYAnchor) - ]) - } - - super.init() - } - - deinit { - player.stop() - print("deinit NCPlayer with ocId \(metadata.ocId)") - NotificationCenter.default.removeObserver(self, name: UIApplication.didEnterBackgroundNotification, object: nil) - NotificationCenter.default.postOnMainThread(name: NCGlobal.shared.notificationCenterPlayerStoppedPlaying) - } - - func openAVPlayer(url: URL, autoplay: Bool = false) { - var position: Float = 0 - let userAgent = userAgent - - self.url = url - self.singleTapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(didSingleTapWith(gestureRecognizer:))) - - print("Playing URL: \(url)") - let media = VLCMedia(url: url) - - media.parse(options: url.isFileURL ? .fetchLocal : .fetchNetwork) - - player.media = media - player.delegate = self - - dialogProvider = VLCDialogProvider(library: VLCLibrary.shared(), customUI: true) - dialogProvider?.customRenderer = self - - player.media?.addOption(":http-user-agent=\(userAgent)") - - if let result = self.database.getVideo(metadata: metadata), - let resultPosition = result.position { - position = resultPosition - } - - if metadata.isVideo { - player.drawable = imageVideoContainer - if let view = player.drawable as? UIView, let singleTapGestureRecognizer = singleTapGestureRecognizer { - view.isUserInteractionEnabled = true - view.addGestureRecognizer(singleTapGestureRecognizer) - } - } - - player.play() - player.position = position - - if autoplay { - pauseAfterPlay = false - } else { - pauseAfterPlay = true - } - - playerToolBar?.setBarPlayer(position: position, ncplayer: self, metadata: metadata, viewerMediaPage: viewerMediaPage) - - NotificationCenter.default.addObserver(self, selector: #selector(applicationDidEnterBackground(_:)), name: UIApplication.didEnterBackgroundNotification, object: nil) - } - - func restartAVPlayer(position: Float, pauseAfterPlay: Bool) { - if let url = self.url, !player.isPlaying { - - player.media = VLCMedia(url: url) - player.position = position - playerToolBar?.setBarPlayer(position: position) - viewerMediaPage?.changeScreenMode(mode: .normal) - self.pauseAfterPlay = pauseAfterPlay - player.play() - - if metadata.isVideo { - if position == 0 { - imageVideoContainer?.image = NCUtility().getImage(ocId: metadata.ocId, etag: metadata.etag, ext: NCGlobal.shared.previewExt1024, userId: metadata.userId, urlBase: metadata.urlBase) - } else { - imageVideoContainer?.image = nil - } - } - } - } - - // MARK: - UIGestureRecognizerDelegate - - @objc func didSingleTapWith(gestureRecognizer: UITapGestureRecognizer) { - changeScreenMode() - } - - func changeScreenMode() { - guard let viewerMediaPage = viewerMediaPage else { return } - - if viewerMediaScreenMode == .full { - viewerMediaPage.changeScreenMode(mode: .normal) - } else { - viewerMediaPage.changeScreenMode(mode: .full) - } - } - - // MARK: - NotificationCenter - - @objc func applicationDidEnterBackground(_ notification: NSNotification) { - if metadata.isVideo { - playerPause() - } - } - - // MARK: - - - func isPlaying() -> Bool { - return player.isPlaying - } - - func playerPlay() { - playerToolBar?.playbackSliderEvent = .began - - if let result = self.database.getVideo(metadata: metadata), let position = result.position { - player.position = position - playerToolBar?.playbackSliderEvent = .moved - } - - player.play() - - DispatchQueue.main.asyncAfter(deadline: .now() + 1) { - self.playerToolBar?.playbackSliderEvent = .ended - } - } - - @objc func playerStop() { - savePosition() - player.stop() - } - - @objc func playerPause() { - savePosition() - player.pause() - } - - func playerPosition(_ position: Float) { - self.database.addVideo(metadata: metadata, position: position) - player.position = position - } - - func savePosition() { - guard metadata.isVideo, isPlaying() else { return } - self.database.addVideo(metadata: metadata, position: player.position) - } - - func jumpForward(_ seconds: Int32) { - player.play() - player.jumpForward(seconds) - } - - func jumpBackward(_ seconds: Int32) { - player.play() - player.jumpBackward(seconds) - } -} - -extension NCPlayer: VLCMediaPlayerDelegate { - func mediaPlayerStateChanged(_ aNotification: Notification) { - - if player.state == .buffering && player.isPlaying { - activityIndicator.startAnimating() - } else { - activityIndicator.stopAnimating() - } - - switch player.state { - case .stopped: - playerToolBar?.showPlayButton() - - NotificationCenter.default.postOnMainThread(name: NCGlobal.shared.notificationCenterPlayerStoppedPlaying) - - print("Player mode: STOPPED") - case .opening: - print("Player mode: OPENING") - case .buffering: - print("Player mode: BUFFERING") - case .ended: - self.database.addVideo(metadata: self.metadata, position: 0) - DispatchQueue.main.asyncAfter(deadline: .now() + 1) { - if let playRepeat = self.playerToolBar?.playRepeat { - self.restartAVPlayer(position: 0, pauseAfterPlay: !playRepeat) - } - } - playerToolBar?.showPlayButton() - print("Player mode: ENDED") - case .error: - print("Player mode: ERROR") - case .playing: - guard let playerToolBar = playerToolBar else { return } - if playerToolBar.playerButtonView.isHidden { - playerToolBar.playerButtonView.isHidden = false - viewerMediaPage?.changeScreenMode(mode: .normal) - } - if pauseAfterPlay { - player.pause() - pauseAfterPlay = false - self.viewerMediaPage?.updateCommandCenter(ncplayer: self, title: metadata.fileNameView) - } else { - playerToolBar.showPauseButton() - // Set track audio/subtitle - let data = self.database.getVideo(metadata: metadata) - if let currentAudioTrackIndex = data?.currentAudioTrackIndex { - player.currentAudioTrackIndex = Int32(currentAudioTrackIndex) - } - if let currentVideoSubTitleIndex = data?.currentVideoSubTitleIndex { - player.currentVideoSubTitleIndex = Int32(currentVideoSubTitleIndex) - } - } - let size = player.videoSize - if let mediaLength = player.media?.length.intValue { - self.length = Int(mediaLength) - } - self.width = Int(size.width) - self.height = Int(size.height) - playerToolBar.updatePlaybackPosition() - playerToolBar.updateTopToolBar(videoSubTitlesIndexes: player.videoSubTitlesIndexes, audioTrackIndexes: player.audioTrackIndexes) - self.database.addVideo(metadata: metadata, width: self.width, height: self.height, length: self.length) - - NotificationCenter.default.postOnMainThread(name: NCGlobal.shared.notificationCenterPlayerIsPlaying) - - print("Player mode: PLAYING") - case .paused: - NotificationCenter.default.postOnMainThread(name: NCGlobal.shared.notificationCenterPlayerStoppedPlaying) - - playerToolBar?.showPlayButton() - print("Player mode: PAUSED") - default: break - } - } - - func mediaPlayerTimeChanged(_ aNotification: Notification) { - activityIndicator.stopAnimating() - playerToolBar?.updatePlaybackPosition() - } -} - -extension NCPlayer: VLCMediaThumbnailerDelegate { - func mediaThumbnailerDidTimeOut(_ mediaThumbnailer: VLCMediaThumbnailer) { } - func mediaThumbnailer(_ mediaThumbnailer: VLCMediaThumbnailer, didFinishThumbnail thumbnail: CGImage) { } -} - -extension NCPlayer: VLCCustomDialogRendererProtocol { - func showError(withTitle error: String, message: String) { - let alert = UIAlertController(title: error, message: message, preferredStyle: .alert) - - alert.addAction(UIAlertAction(title: NSLocalizedString("_ok_", comment: ""), style: .default, handler: { _ in - self.playerToolBar?.removeFromSuperview() - self.viewerMediaPage?.navigationController?.popViewController(animated: true) - })) - - self.viewerMediaPage?.present(alert, animated: true) - } - - func showLogin(withTitle title: String, message: String, defaultUsername username: String?, askingForStorage: Bool, withReference reference: NSValue) { - // UIAlertController other states... - } - - func showQuestion(withTitle title: String, message: String, type questionType: VLCDialogQuestionType, cancel cancelString: String?, action1String: String?, action2String: String?, withReference reference: NSValue) { - let alert = UIAlertController(title: title, message: message, preferredStyle: .alert) - - if let action1String = action1String { - alert.addAction(UIAlertAction(title: action1String, style: .default, handler: { _ in - self.dialogProvider?.postAction(1, forDialogReference: reference) - })) - } - if let action2String = action2String { - alert.addAction(UIAlertAction(title: action2String, style: .default, handler: { _ in - self.dialogProvider?.postAction(2, forDialogReference: reference) - })) - } - if let cancelString = cancelString { - alert.addAction(UIAlertAction(title: cancelString, style: .cancel, handler: { _ in - self.dialogProvider?.postAction(3, forDialogReference: reference) - })) - } - - self.viewerMediaPage?.present(alert, animated: true) - } - - func showProgress(withTitle title: String, message: String, isIndeterminate: Bool, position: Float, cancel cancelString: String?, withReference reference: NSValue) { - // UIAlertController other states... - } - - func updateProgress(withReference reference: NSValue, message: String?, position: Float) { - // UIAlertController other states... - } - - func cancelDialog(withReference reference: NSValue) { - // UIAlertController other states... - } -} diff --git a/iOSClient/Viewer/NCViewerMedia/NCPlayer/NCPlayerToolBar.swift b/iOSClient/Viewer/NCViewerMedia/NCPlayer/NCPlayerToolBar.swift deleted file mode 100644 index 742d90fddf..0000000000 --- a/iOSClient/Viewer/NCViewerMedia/NCPlayer/NCPlayerToolBar.swift +++ /dev/null @@ -1,448 +0,0 @@ -// SPDX-FileCopyrightText: Nextcloud GmbH -// SPDX-FileCopyrightText: 2021 Marino Faggiana -// SPDX-License-Identifier: GPL-3.0-or-later - -import Foundation -import NextcloudKit -import CoreMedia -import UIKit -import AVKit -import MediaPlayer -import MobileVLCKit -import Alamofire -import LucidBanner - -class NCPlayerToolBar: UIView { - @IBOutlet weak var utilityView: UIView! - @IBOutlet weak var fullscreenButton: UIButton! - @IBOutlet weak var subtitleButton: UIButton! - @IBOutlet weak var audioButton: UIButton! - - @IBOutlet weak var playerButtonView: UIStackView! - @IBOutlet weak var backButton: UIButton! - @IBOutlet weak var playButton: UIButton! - @IBOutlet weak var forwardButton: UIButton! - - @IBOutlet weak var playbackSliderView: UIView! - @IBOutlet weak var playbackSlider: NCPlayerToolBarSlider! - @IBOutlet weak var labelLeftTime: UILabel! - @IBOutlet weak var labelCurrentTime: UILabel! - @IBOutlet weak var repeatButton: UIButton! - - enum sliderEventType { - case none - case began - case ended - case moved - } - - var playbackSliderEvent: sliderEventType = .none - var isFullscreen: Bool = false - var playRepeat: Bool = false - - private var ncplayer: NCPlayer? - private var metadata: tableMetadata? - private let audioSession = AVAudioSession.sharedInstance() - private var pointSize: CGFloat = 0 - private let utilityFileSystem = NCUtilityFileSystem() - private let utility = NCUtility() - private let global = NCGlobal.shared - private let database = NCManageDatabase.shared - private weak var viewerMediaPage: NCViewerMediaPage? - private var buttonImage = UIImage() - - // MARK: - View Life Cycle - - override func awakeFromNib() { - super.awakeFromNib() - - self.backgroundColor = UIColor.black.withAlphaComponent(0.1) - - fullscreenButton.setImage(utility.loadImage(named: "arrow.up.left.and.arrow.down.right", colors: [.white]), for: .normal) - - subtitleButton.setImage(utility.loadImage(named: "captions.bubble", colors: [.white]), for: .normal) - subtitleButton.isEnabled = false - subtitleButton.showsMenuAsPrimaryAction = true - - audioButton.setImage(utility.loadImage(named: "speaker.zzz", colors: [.white]), for: .normal) - audioButton.isEnabled = false - audioButton.showsMenuAsPrimaryAction = true - - if UIDevice.current.userInterfaceIdiom == .pad { - pointSize = 60 - } else { - pointSize = 50 - } - - playerButtonView.spacing = pointSize - playerButtonView.isHidden = true - - buttonImage = UIImage(systemName: "gobackward.10", withConfiguration: UIImage.SymbolConfiguration(pointSize: pointSize))!.withTintColor(.white, renderingMode: .alwaysOriginal) - backButton.setImage(buttonImage, for: .normal) - - buttonImage = UIImage(systemName: "play.fill", withConfiguration: UIImage.SymbolConfiguration(pointSize: pointSize))!.withTintColor(.white, renderingMode: .alwaysOriginal) - playButton.setImage(buttonImage, for: .normal) - - buttonImage = UIImage(systemName: "goforward.10", withConfiguration: UIImage.SymbolConfiguration(pointSize: pointSize))!.withTintColor(.white, renderingMode: .alwaysOriginal) - forwardButton.setImage(buttonImage, for: .normal) - - playbackSlider.addTapGesture() - playbackSlider.setThumbImage(UIImage(systemName: "circle.fill", withConfiguration: UIImage.SymbolConfiguration(pointSize: 15)), for: .normal) - playbackSlider.value = 0 - playbackSlider.tintColor = .white - playbackSlider.addTarget(self, action: #selector(playbackValChanged(slider:event:)), for: .valueChanged) - repeatButton.setImage(utility.loadImage(named: "repeat", colors: [NCBrandColor.shared.iconImageColor2]), for: .normal) - - utilityView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(tap(gestureRecognizer:)))) - playbackSliderView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(tap(gestureRecognizer:)))) - playbackSliderView.addGestureRecognizer(UIPanGestureRecognizer(target: self, action: #selector(tap(gestureRecognizer:)))) - playerButtonView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(tap(gestureRecognizer:)))) - - labelCurrentTime.textColor = .white - labelLeftTime.textColor = .white - - // Normally hide - self.alpha = 0 - self.isHidden = true - } - - required init?(coder aDecoder: NSCoder) { - super.init(coder: aDecoder) - } - - deinit { - print("deinit NCPlayerToolBar") - } - - // MARK: - - - func setBarPlayer(position: Float, ncplayer: NCPlayer? = nil, metadata: tableMetadata? = nil, viewerMediaPage: NCViewerMediaPage? = nil) { - if let ncplayer = ncplayer { - self.ncplayer = ncplayer - } - if let metadata = metadata { - self.metadata = metadata - } - if let viewerMediaPage = viewerMediaPage { - self.viewerMediaPage = viewerMediaPage - } - - playerButtonView.isHidden = true - - buttonImage = UIImage(systemName: "play.fill", withConfiguration: UIImage.SymbolConfiguration(pointSize: pointSize))!.withTintColor(.white, renderingMode: .alwaysOriginal) - playButton.setImage(buttonImage, for: .normal) - - playbackSlider.value = position - - labelCurrentTime.text = "--:--" - labelLeftTime.text = "--:--" - - if viewerMediaScreenMode == .normal { - show() - } else { - hide() - } - - MPNowPlayingInfoCenter.default().nowPlayingInfo?[MPNowPlayingInfoPropertyPlaybackRate] = position - - setupSubtitleButton() - setupAudioButton() - } - - public func updatePlaybackPosition() { - guard let ncplayer = self.ncplayer, - let media = ncplayer.player.media else { - return - } - - let length = media.length.intValue - - let position = ncplayer.player.position - - let currentSeconds = Double(position) * (Double(length) / 1000.0) - - let currentTimeObj = VLCTime(int: Int32(currentSeconds * 1000)) - let remainingTimeObj = VLCTime(int: Int32((Double(length) / 1000.0) - currentSeconds) * 1000) - - labelCurrentTime.text = currentTimeObj.stringValue == "--:--" ? "00:00" : currentTimeObj.stringValue - - let remaining = remainingTimeObj.stringValue - labelLeftTime.text = "-\(remaining)" - - if playbackSliderEvent == .ended { - playbackSlider.value = position - } - - MPNowPlayingInfoCenter.default().nowPlayingInfo?[MPMediaItemPropertyPlaybackDuration] = length / 1000 - MPNowPlayingInfoCenter.default().nowPlayingInfo?[MPNowPlayingInfoPropertyElapsedPlaybackTime] = currentSeconds - } - - public func updateTopToolBar(videoSubTitlesIndexes: [Any], audioTrackIndexes: [Any]) { - if let metadata = metadata, metadata.isVideo { - self.subtitleButton.isEnabled = true - self.audioButton.isEnabled = true - } - } - - // MARK: - - - public func show() { - UIView.animate(withDuration: 0.5, animations: { - self.alpha = 1 - }, completion: { (_: Bool) in - self.isHidden = false - }) - } - - func hide() { - UIView.animate(withDuration: 0.5, animations: { - self.alpha = 0 - }, completion: { (_: Bool) in - self.isHidden = true - }) - } - - func showPauseButton() { - buttonImage = UIImage(systemName: "pause.fill", withConfiguration: UIImage.SymbolConfiguration(pointSize: pointSize))!.withTintColor(.white, renderingMode: .alwaysOriginal) - playButton.setImage(buttonImage, for: .normal) - MPNowPlayingInfoCenter.default().nowPlayingInfo?[MPNowPlayingInfoPropertyPlaybackRate] = 1 - } - - func showPlayButton() { - buttonImage = UIImage(systemName: "play.fill", withConfiguration: UIImage.SymbolConfiguration(pointSize: pointSize))!.withTintColor(.white, renderingMode: .alwaysOriginal) - playButton.setImage(buttonImage, for: .normal) - MPNowPlayingInfoCenter.default().nowPlayingInfo?[MPNowPlayingInfoPropertyPlaybackRate] = 0 - } - - // MARK: - Event / Gesture - - @objc func playbackValChanged(slider: UISlider, event: UIEvent) { - guard let ncplayer = ncplayer else { return } - let newPosition = playbackSlider.value - - if let touchEvent = event.allTouches?.first { - switch touchEvent.phase { - case .began: - viewerMediaPage?.timerAutoHide?.invalidate() - playbackSliderEvent = .began - case .moved: - ncplayer.playerPosition(newPosition) - playbackSliderEvent = .moved - case .ended: - ncplayer.playerPosition(newPosition) - DispatchQueue.main.asyncAfter(deadline: .now() + 1) { - self.playbackSliderEvent = .ended - self.viewerMediaPage?.startTimerAutoHide() - } - default: - break - } - } else { - ncplayer.playerPosition(newPosition) - self.viewerMediaPage?.startTimerAutoHide() - } - } - - // MARK: - Action - - @objc func tap(gestureRecognizer: UITapGestureRecognizer) { } - - @IBAction func tapFullscreen(_ sender: Any) { - isFullscreen = !isFullscreen - if isFullscreen { - fullscreenButton.setImage(utility.loadImage(named: "arrow.down.right.and.arrow.up.left", colors: [.white]), for: .normal) - } else { - fullscreenButton.setImage(utility.loadImage(named: "arrow.up.left.and.arrow.down.right", colors: [.white]), for: .normal) - } - viewerMediaPage?.changeScreenMode(mode: viewerMediaScreenMode) - } - - private func setupSubtitleButton() { - guard let player = ncplayer?.player else { return } - - var currentIndex: Int? - if let data = database.getVideo(metadata: metadata), let idx = data.currentVideoSubTitleIndex { - currentIndex = idx - } else { - currentIndex = Int(player.currentVideoSubTitleIndex) - } - - subtitleButton.menu = NCContextMenuPlayerTracks( - trackType: .subtitle, - tracks: player.videoSubTitlesNames, - trackIndexes: player.videoSubTitlesIndexes, - currentIndex: currentIndex, - ncplayer: ncplayer, - metadata: metadata, - viewerMediaPage: viewerMediaPage - ).viewMenu() - } - - private func setupAudioButton() { - guard let player = ncplayer?.player else { return } - - var currentIndex: Int? - if let data = database.getVideo(metadata: metadata), let idx = data.currentAudioTrackIndex { - currentIndex = idx - } else { - currentIndex = Int(player.currentAudioTrackIndex) - } - - audioButton.menu = NCContextMenuPlayerTracks( - trackType: .audio, - tracks: player.audioTrackNames, - trackIndexes: player.audioTrackIndexes, - currentIndex: currentIndex, - ncplayer: ncplayer, - metadata: metadata, - viewerMediaPage: viewerMediaPage - ).viewMenu() - } - - @IBAction func tapPlayerPause(_ sender: Any) { - guard let ncplayer = ncplayer else { return } - - if ncplayer.isPlaying() { - ncplayer.playerPause() - } else { - ncplayer.playerPlay() - } - - self.viewerMediaPage?.startTimerAutoHide() - } - - @IBAction func tapForward(_ sender: Any) { - guard let ncplayer = ncplayer else { return } - - ncplayer.jumpForward(10) - self.viewerMediaPage?.startTimerAutoHide() - } - - @IBAction func tapBack(_ sender: Any) { - guard let ncplayer = ncplayer else { return } - - ncplayer.jumpBackward(10) - self.viewerMediaPage?.startTimerAutoHide() - } - - @IBAction func tapRepeat(_ sender: Any) { - if playRepeat { - playRepeat = false - repeatButton.setImage(utility.loadImage(named: "repeat", colors: [NCBrandColor.shared.iconImageColor2]), for: .normal) - } else { - playRepeat = true - repeatButton.setImage(utility.loadImage(named: "repeat", colors: [.white]), for: .normal) - } - } -} - -extension NCPlayerToolBar: NCSelectDelegate { - func dismissSelect(serverUrl: String?, metadata: tableMetadata?, type: String, items: [Any], overwrite: Bool, copy: Bool, move: Bool, session: NCSession.Session, controller: NCMainTabBarController?) { - if let metadata = metadata, let viewerMediaPage = viewerMediaPage { - let fileNameLocalPath = NCUtilityFileSystem().getDirectoryProviderStorageOcId(metadata.ocId, fileName: metadata.fileNameView, userId: metadata.userId, urlBase: metadata.urlBase) - let windowScene = SceneManager.shared.getWindowScene(controller: viewerMediaPage.tabBarController) - - if utilityFileSystem.fileProviderStorageExists(metadata) { - addPlaybackSlave(type: type, metadata: metadata) - } else { - var downloadRequest: DownloadRequest? - let (banner, token) = showHudBanner(windowScene: windowScene, - title: "_download_in_progress_", - stage: .button) { - if let request = downloadRequest { - request.cancel() - } - } - - NextcloudKit.shared.download(serverUrlFileName: metadata.serverUrlFileName, fileNameLocalPath: fileNameLocalPath, account: metadata.account, requestHandler: { request in - downloadRequest = request - }, taskHandler: { task in - Task { - let identifier = await NCNetworking.shared.networkingTasks.createIdentifier(account: metadata.account, - path: metadata.serverUrlFileName, - name: "download") - await NCNetworking.shared.networkingTasks.track(identifier: identifier, task: task) - - let ocId = metadata.ocId - await self.database.setMetadataSessionAsync(ocId: ocId, - sessionTaskIdentifier: task.taskIdentifier, - status: self.global.metadataStatusDownloading) - } - }, progressHandler: { progress in - Task {@MainActor in - banner?.update(payload: LucidBannerPayload.Update(progress: Double(progress.fractionCompleted)), - for: token) - } - }) { _, etag, _, _, _, _, error in - Task { - if let banner { - banner.dismiss() - } - - let ocId = metadata.ocId - await self.database.setMetadataSessionAsync(ocId: ocId, - session: "", - sessionTaskIdentifier: 0, - sessionError: "", - status: self.global.metadataStatusNormal, - etag: etag) - - if error == .success { - self.addPlaybackSlave(type: type, metadata: metadata) - } else if error.errorCode != 200 { - await showErrorBanner(windowScene: windowScene, - text: error.errorDescription, - errorCode: error.errorCode) - } - } - } - } - } - } - - // swiftlint:disable inclusive_language - func addPlaybackSlave(type: String, metadata: tableMetadata) { - // swiftlint:enable inclusive_language - let fileNameLocalPath = utilityFileSystem.getDirectoryProviderStorageOcId(metadata.ocId, fileName: metadata.fileNameView, userId: metadata.userId, urlBase: metadata.urlBase) - - if type == "subtitle" { - self.ncplayer?.player.addPlaybackSlave(URL(fileURLWithPath: fileNameLocalPath), type: .subtitle, enforce: true) - } else if type == "audio" { - self.ncplayer?.player.addPlaybackSlave(URL(fileURLWithPath: fileNameLocalPath), type: .audio, enforce: true) - } - } -} - -// https://stackoverflow.com/questions/13196263/custom-uislider-increase-hot-spot-size -// -class NCPlayerToolBarSlider: UISlider { - private var thumbTouchSize = CGSize(width: 100, height: 100) - - override func point(inside point: CGPoint, with event: UIEvent?) -> Bool { - let increasedBounds = bounds.insetBy(dx: -thumbTouchSize.width, dy: -thumbTouchSize.height) - let containsPoint = increasedBounds.contains(point) - return containsPoint - } - - override func beginTracking(_ touch: UITouch, with event: UIEvent?) -> Bool { - let percentage = CGFloat((value - minimumValue) / (maximumValue - minimumValue)) - let thumbSizeHeight = thumbRect(forBounds: bounds, trackRect: trackRect(forBounds: bounds), value: 0).size.height - let thumbPosition = thumbSizeHeight + (percentage * (bounds.size.width - (2 * thumbSizeHeight))) - let touchLocation = touch.location(in: self) - return touchLocation.x <= (thumbPosition + thumbTouchSize.width) && touchLocation.x >= (thumbPosition - thumbTouchSize.width) - } - - public func addTapGesture() { - let tap = UITapGestureRecognizer(target: self, action: #selector(handleTap(_:))) - - addGestureRecognizer(tap) - } - - @objc private func handleTap(_ sender: UITapGestureRecognizer) { - let location = sender.location(in: self) - let percent = minimumValue + Float(location.x / bounds.width) * (maximumValue - minimumValue) - - setValue(percent, animated: true) - sendActions(for: .valueChanged) - } -} diff --git a/iOSClient/Viewer/NCViewerMedia/NCPlayer/NCPlayerToolBar.xib b/iOSClient/Viewer/NCViewerMedia/NCPlayer/NCPlayerToolBar.xib deleted file mode 100644 index deaf0d7558..0000000000 --- a/iOSClient/Viewer/NCViewerMedia/NCPlayer/NCPlayerToolBar.xib +++ /dev/null @@ -1,162 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/iOSClient/Viewer/NCViewerMedia/NCViewerMedia+VisionKit.swift b/iOSClient/Viewer/NCViewerMedia/NCViewerMedia+VisionKit.swift deleted file mode 100644 index 0a79eb4d5a..0000000000 --- a/iOSClient/Viewer/NCViewerMedia/NCViewerMedia+VisionKit.swift +++ /dev/null @@ -1,29 +0,0 @@ -// SPDX-FileCopyrightText: Nextcloud GmbH -// SPDX-FileCopyrightText: 2024 Milen -// SPDX-License-Identifier: GPL-3.0-or-later - -import Foundation -import UIKit -import VisionKit - -extension NCViewerMedia { - func analyzeCurrentImage() { - if let image = image { - let interaction = ImageAnalysisInteraction() - let analyzer = ImageAnalyzer() - interaction.preferredInteractionTypes = [] - interaction.analysis = nil - - self.imageVideoContainer.addInteraction(interaction) - let configuration = ImageAnalyzer.Configuration([.text, .machineReadableCode, .visualLookUp]) - - Task { - let analysis = try? await analyzer.analyze(image, configuration: configuration) - if image == self.image { - interaction.analysis = analysis - interaction.preferredInteractionTypes = .automatic - } - } - } - } -} diff --git a/iOSClient/Viewer/NCViewerMedia/NCViewerMedia.swift b/iOSClient/Viewer/NCViewerMedia/NCViewerMedia.swift deleted file mode 100644 index 0aeb8f94e7..0000000000 --- a/iOSClient/Viewer/NCViewerMedia/NCViewerMedia.swift +++ /dev/null @@ -1,652 +0,0 @@ -// SPDX-FileCopyrightText: Nextcloud GmbH -// SPDX-FileCopyrightText: 2020 Marino Faggiana -// SPDX-License-Identifier: GPL-3.0-or-later - -import UIKit -import NextcloudKit -import EasyTipView -import SwiftUI -import MobileVLCKit -import Alamofire -import LucidBanner - -public protocol NCViewerMediaViewDelegate: AnyObject { - func didOpenDetail() - func didCloseDetail() -} - -class NCViewerMedia: UIViewController { - @IBOutlet weak var detailViewTopConstraint: NSLayoutConstraint! - @IBOutlet weak var imageViewTopConstraint: NSLayoutConstraint! - @IBOutlet weak var imageViewBottomConstraint: NSLayoutConstraint! - @IBOutlet weak var scrollView: UIScrollView! - @IBOutlet weak var imageVideoContainer: UIImageView! - @IBOutlet weak var statusViewImage: UIImageView! - @IBOutlet weak var statusLabel: UILabel! - @IBOutlet weak var detailView: NCViewerMediaDetailView! - - private let player = VLCMediaPlayer() - private let appDelegate = (UIApplication.shared.delegate as? AppDelegate)! - let utilityFileSystem = NCUtilityFileSystem() - let utility = NCUtility() - let global = NCGlobal.shared - let database = NCManageDatabase.shared - let networking = NCNetworking.shared - weak var viewerMediaPage: NCViewerMediaPage? - var playerToolBar: NCPlayerToolBar? - var ncplayer: NCPlayer? - var image: UIImage? { - didSet { - if metadata.isImage { - analyzeCurrentImage() - } - } - } - var metadata: tableMetadata = tableMetadata() - var index: Int = 0 - var doubleTapGestureRecognizer: UITapGestureRecognizer = UITapGestureRecognizer() - var imageViewConstraint: CGFloat = 0 - var isDetailViewInitializze: Bool = false - weak var delegate: NCViewerMediaViewDelegate? - - private var allowOpeningDetails = true - private var tipView: EasyTipView? - - var sceneIdentifier: String { - (self.tabBarController as? NCMainTabBarController)?.sceneIdentifier ?? "" - } - - internal var windowScene: UIWindowScene? { - SceneManager.shared.getWindowScene(controller: self.tabBarController as? NCMainTabBarController) - } - - // MARK: - View Life Cycle - - required init?(coder aDecoder: NSCoder) { - super.init(coder: aDecoder) - - doubleTapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(didDoubleTapWith(gestureRecognizer:))) - doubleTapGestureRecognizer.numberOfTapsRequired = 2 - } - - deinit { - print("deinit NCViewerMedia") - NotificationCenter.default.removeObserver(self, name: NSNotification.Name(rawValue: global.notificationCenterOpenMediaDetail), object: nil) - } - - override func viewDidLoad() { - super.viewDidLoad() - - scrollView.delegate = self - scrollView.maximumZoomScale = 4 - scrollView.minimumZoomScale = 1 - - view.addGestureRecognizer(doubleTapGestureRecognizer) - - if self.database.getMetadataLivePhoto(metadata: metadata) != nil { - statusViewImage.image = utility.loadImage(named: "livephoto", colors: [NCBrandColor.shared.iconImageColor2]) - statusLabel.text = "LIVE" - } else { - statusViewImage.image = nil - statusLabel.text = "" - } - - if metadata.isAudioOrVideo { - playerToolBar = Bundle.main.loadNibNamed("NCPlayerToolBar", owner: self, options: nil)?.first as? NCPlayerToolBar - if let playerToolBar = playerToolBar { - view.addSubview(playerToolBar) - playerToolBar.translatesAutoresizingMaskIntoConstraints = false - playerToolBar.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor).isActive = true - playerToolBar.bottomAnchor.constraint(equalTo: view.bottomAnchor).isActive = true - playerToolBar.leadingAnchor.constraint(equalTo: view.leadingAnchor).isActive = true - playerToolBar.trailingAnchor.constraint(equalTo: view.trailingAnchor).isActive = true - } - - self.ncplayer = NCPlayer(imageVideoContainer: self.imageVideoContainer, playerToolBar: self.playerToolBar, metadata: self.metadata, viewerMediaPage: self.viewerMediaPage) - } - - detailViewTopConstraint.constant = 0 - detailView.hide() - - self.image = nil - self.imageVideoContainer.image = nil - - Task {@MainActor in - await loadImage() - } - } - - override func viewWillAppear(_ animated: Bool) { - super.viewWillAppear(animated) - - if #available(iOS 18.0, *) { - tabBarController?.setTabBarHidden(true, animated: true) - } else { - tabBarController?.tabBar.isHidden = true - } - - viewerMediaPage?.navigationItem.setBidiSafeTitle(metadata.fileNameView) - - if metadata.isImage, let viewerMediaPage = self.viewerMediaPage { - if viewerMediaPage.modifiedOcId.contains(metadata.ocId) { - viewerMediaPage.modifiedOcId.removeAll(where: { $0 == metadata.ocId }) - Task {@MainActor in - await loadImage() - } - } - } - } - - override func viewDidAppear(_ animated: Bool) { - super.viewDidAppear(animated) - - Task { - await NCNetworking.shared.transferDispatcher.addDelegate(self) - } - - viewerMediaPage?.clearCommandCenter() - - if metadata.isAudioOrVideo { - if let ncplayer = self.ncplayer { - if ncplayer.url == nil { - NCActivityIndicator.shared.startActivity(backgroundView: self.view, style: .medium) - self.networking.getVideoUrl(metadata: metadata) { url, autoplay, error in - NCActivityIndicator.shared.stop() - if error == .success, let url = url { - ncplayer.openAVPlayer(url: url, autoplay: autoplay) - } else { - Task { @MainActor in - guard let metadata = await self.database.setMetadataSessionInWaitDownloadAsync(ocId: self.metadata.ocId, - session: self.networking.sessionDownload, - selector: "") else { - return - } - var downloadRequest: DownloadRequest? - let (banner, token) = showHudBanner(windowScene: self.windowScene, - title: "_download_in_progress_", - stage: .button) { - if let request = downloadRequest { - request.cancel() - } - } - - let results = await self.networking.downloadFile(metadata: metadata) { request in - downloadRequest = request - } progressHandler: { progress in - Task {@MainActor in - banner?.update( - payload: LucidBannerPayload.Update(progress: progress.fractionCompleted), - for: token - ) - } - } - - if let banner { - banner.dismiss() - } - - if results.nkError == .success { - if self.utilityFileSystem.fileProviderStorageExists(self.metadata) { - let url = URL(fileURLWithPath: self.utilityFileSystem.getDirectoryProviderStorageOcId(self.metadata.ocId, fileName: self.metadata.fileNameView, userId: self.metadata.userId, urlBase: self.metadata.urlBase)) - ncplayer.openAVPlayer(url: url, autoplay: autoplay) - } - } - } - } - } - } else { - var position: Float = 0 - if let result = self.database.getVideo(metadata: metadata), let resultPosition = result.position { - position = resultPosition - } - ncplayer.restartAVPlayer(position: position, pauseAfterPlay: true) - } - } - } else if metadata.isImage { - DispatchQueue.main.asyncAfter(deadline: .now() + 1) { - self.showTip() - } - } - - NotificationCenter.default.addObserver(self, selector: #selector(openDetail(_:)), name: NSNotification.Name(rawValue: global.notificationCenterOpenMediaDetail), object: nil) - } - - override func viewWillDisappear(_ animated: Bool) { - super.viewWillDisappear(animated) - - dismissTip() - } - - override func viewDidDisappear(_ animated: Bool) { - super.viewDidDisappear(animated) - - Task { - await NCNetworking.shared.transferDispatcher.removeDelegate(self) - } - - if let ncplayer, ncplayer.isPlaying() { - ncplayer.playerPause() - } - } - - override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { - super.viewWillTransition(to: size, with: coordinator) - - let wasShownDetail = detailView.isShown - - if UIDevice.current.orientation.isValidInterfaceOrientation { - if wasShownDetail { - closeDetail(animate: false) - } - dismissTip() - - coordinator.animate(alongsideTransition: { _ in - // back to the original size - if self.scrollView.zoomScale != self.scrollView.minimumZoomScale { - self.scrollView.zoom(to: CGRect(x: 0, y: 0, width: self.scrollView.bounds.width, height: self.scrollView.bounds.height), animated: false) - self.view.layoutIfNeeded() - } - }, completion: { _ in - if wasShownDetail { - self.openDetail(animate: true) - } - }) - } - } - - // MARK: - Image - - @MainActor - func loadImage() async { - guard let metadata = self.database.getMetadataFromOcId(metadata.ocId) else { return } - self.metadata = metadata - let fileNamePath = utilityFileSystem.getDirectoryProviderStorageOcId(metadata.ocId, - fileName: metadata.fileNameView, - userId: metadata.userId, - urlBase: metadata.urlBase) - let fileNameExtension = (metadata.fileNameView as NSString).pathExtension.uppercased() - - if metadata.isLivePhoto, - self.networking.isOnline, - let metadata = self.database.getMetadataLivePhoto(metadata: metadata), - !utilityFileSystem.fileProviderStorageExists(metadata) { - Task { - if let metadata = await self.database.setMetadataSessionInWaitDownloadAsync(ocId: metadata.ocId, - session: self.networking.sessionDownload, - selector: "") { - await self.networking.downloadFile(metadata: metadata) - } - } - } - - if metadata.isImage, fileNameExtension == "GIF" || fileNameExtension == "SVG", !utilityFileSystem.fileProviderStorageExists(metadata) { - await downloadImage() - } - - if metadata.isVideo && !metadata.hasPreview { - utility.createImageFileFrom(metadata: metadata) - let image = utility.getImage(ocId: metadata.ocId, etag: metadata.etag, ext: global.previewExt1024, userId: metadata.userId, urlBase: metadata.urlBase) - self.image = image - self.imageVideoContainer.image = self.image - return - } else if metadata.isAudio { - let image = utility.loadImage(named: "waveform", colors: [NCBrandColor.shared.iconImageColor2]) - self.image = image - self.imageVideoContainer.image = self.image - return - } else if metadata.isImage { - if fileNameExtension == "GIF" { - if !NCUtility().existsImage(ocId: metadata.ocId, etag: metadata.etag, ext: global.previewExt1024, userId: metadata.userId, urlBase: metadata.urlBase) { - utility.createImageFileFrom(metadata: metadata) - } - if let image = UIImage.animatedImage(withAnimatedGIFURL: URL(fileURLWithPath: fileNamePath)) { - self.image = image - self.imageVideoContainer.image = self.image - } else { - self.image = self.utility.loadImage(named: "photo.badge.arrow.down", colors: [NCBrandColor.shared.iconImageColor2]) - self.imageVideoContainer.image = self.image - } - return - } else if fileNameExtension == "SVG" { - do { - let fileNamePathPNG = utilityFileSystem.replaceExtension(fileNamePath: fileNamePath, with: "png") - if FileManager.default.fileExists(atPath: fileNamePathPNG) { - let data = try Data(contentsOf: URL(fileURLWithPath: fileNamePathPNG)) - self.image = UIImage(data: data) - self.imageVideoContainer.image = self.image - } else { - let svgData = try Data(contentsOf: URL(fileURLWithPath: fileNamePath)) - if let image = try await NCSVGRenderer().renderSVGToUIImage(svgData: svgData, size: CGSize(width: 1024, height: 1024)), - let data = image.pngData() { - self.image = image - self.imageVideoContainer.image = self.image - try data.write(to: URL(fileURLWithPath: fileNamePathPNG)) - utility.createImageFileFrom(data: data, metadata: metadata) - } - } - return - } catch { - print("Unsupported image format: \(error.localizedDescription)") - self.image = self.utility.loadImage(named: "photo", colors: [NCBrandColor.shared.iconImageColor2]) - self.imageVideoContainer.image = self.image - } - return - } else if let image = UIImage(contentsOfFile: fileNamePath) { - self.image = image - self.imageVideoContainer.image = self.image - return - } - } - - if let image = UIImage(contentsOfFile: utilityFileSystem.getDirectoryProviderStorageImageOcId(metadata.ocId, - etag: metadata.etag, - ext: global.previewExt1024, - userId: metadata.userId, - urlBase: metadata.urlBase)) { - self.image = image - self.imageVideoContainer.image = self.image - } else { - NextcloudKit.shared.downloadPreview(fileId: metadata.fileId, - etag: metadata.etag, - account: metadata.account, - options: NKRequestOptions(queue: .main)) { task in - Task { - let identifier = await NCNetworking.shared.networkingTasks.createIdentifier(account: metadata.account, - path: metadata.fileId, - name: "DownloadPreview") - await NCNetworking.shared.networkingTasks.track(identifier: identifier, task: task) - } - } completion: { _, _, _, _, responseData, error in - if error == .success, let data = responseData?.data { - let image = UIImage(data: data) - self.image = image - self.imageVideoContainer.image = self.image - } else { - self.image = self.utility.loadImage(named: "photo", colors: [NCBrandColor.shared.iconImageColor2]) - self.imageVideoContainer.image = self.image - } - } - } - } - - private func downloadImage(withSelector selector: String = "") async { - if let metadata = await self.database.setMetadataSessionInWaitDownloadAsync(ocId: metadata.ocId, - session: self.networking.sessionDownload, - selector: selector) { - await self.networking.downloadFile(metadata: metadata) { _ in - self.allowOpeningDetails = false - } taskHandler: { _ in } - self.allowOpeningDetails = true - } - } - - // MARK: - Live Photo - - func playLivePhoto(filePath: String) { - updateViewConstraints() - statusViewImage.isHidden = true - statusLabel.isHidden = true - - player.media = VLCMedia(url: URL(fileURLWithPath: filePath)) - player.drawable = imageVideoContainer - player.play() - } - - func stopLivePhoto() { - player.stop() - - statusViewImage.isHidden = false - statusLabel.isHidden = false - } - - // MARK: - Gesture - - @objc func didDoubleTapWith(gestureRecognizer: UITapGestureRecognizer) { - guard metadata.isImage, !detailView.isShown else { return } - let pointInView = gestureRecognizer.location(in: self.imageVideoContainer) - var newZoomScale = self.scrollView.maximumZoomScale - - if self.scrollView.zoomScale >= newZoomScale || abs(self.scrollView.zoomScale - newZoomScale) <= 0.01 { - newZoomScale = self.scrollView.minimumZoomScale - } - - let width = self.scrollView.bounds.width / newZoomScale - let height = self.scrollView.bounds.height / newZoomScale - let originX = pointInView.x - (width / 2.0) - let originY = pointInView.y - (height / 2.0) - let rectToZoomTo = CGRect(x: originX, y: originY, width: width, height: height) - self.scrollView.zoom(to: rectToZoomTo, animated: true) - } - - @objc func didPanWith(gestureRecognizer: UIPanGestureRecognizer) { - guard metadata.isImage else { return } - let currentLocation = gestureRecognizer.translation(in: self.view) - - switch gestureRecognizer.state { - case .ended: - if detailView.isShown { - self.imageViewTopConstraint.constant = -imageViewConstraint - self.imageViewBottomConstraint.constant = imageViewConstraint - } else { - self.imageViewTopConstraint.constant = 0 - self.imageViewBottomConstraint.constant = 0 - } - - case .changed: - imageViewTopConstraint.constant = (currentLocation.y - imageViewConstraint) - imageViewBottomConstraint.constant = -(currentLocation.y - imageViewConstraint) - - // DISMISS VIEW - if detailView.isHidden && (currentLocation.y > 20) { - - viewerMediaPage?.navigationController?.popViewController(animated: true) - gestureRecognizer.state = .ended - } - - // CLOSE DETAIL - if !detailView.isHidden && (currentLocation.y > 20) { - - self.closeDetail() - gestureRecognizer.state = .ended - } - - // OPEN DETAIL - if detailView.isHidden && (currentLocation.y < -20) { - - self.openDetail() - gestureRecognizer.state = .ended - } - - default: - break - } - } -} - -extension NCViewerMedia { - @objc func openDetail(_ notification: NSNotification) { - if let userInfo = notification.userInfo as NSDictionary?, let ocId = userInfo["ocId"] as? String, ocId == metadata.ocId { - allowOpeningDetails = true - openDetail() - } - } - - func toggleDetail() { - detailView.isShown ? closeDetail() : openDetail() - } - - private func openDetail(animate: Bool = true) { - if !allowOpeningDetails { return } - - delegate?.didOpenDetail() - self.dismissTip() - - UIView.animate(withDuration: 0.3) { - self.scrollView.setZoomScale(1.0, animated: false) - - self.statusLabel.isHidden = true - self.statusViewImage.isHidden = true - } - - self.utility.getExif(metadata: self.metadata) { exif in - self.view.layoutIfNeeded() - - self.showDetailView(exif: exif) - - if let image = self.imageVideoContainer.image { - let ratioW = self.imageVideoContainer.frame.width / image.size.width - let ratioH = self.imageVideoContainer.frame.height / image.size.height - let ratio = min(ratioW, ratioH) - let imageHeight = image.size.height * ratio - var imageContainerHeight = self.imageVideoContainer.frame.height * ratio - let height = max(imageHeight, imageContainerHeight) - self.imageViewConstraint = self.detailView.frame.height - ((self.view.frame.height - height) / 2) + self.view.safeAreaInsets.bottom - - if self.imageViewConstraint < 0 { self.imageViewConstraint = 0 } - - self.imageViewConstraint = min(self.imageViewConstraint, self.detailView.frame.height + 30) - imageContainerHeight = self.imageViewConstraint.truncatingRemainder(dividingBy: 1000) - } - - UIView.animate(withDuration: animate ? 0.3 : 0) { - self.imageViewTopConstraint.constant = -self.imageViewConstraint - self.imageViewBottomConstraint.constant = self.imageViewConstraint - self.detailViewTopConstraint.constant = self.detailView.frame.height - self.view.layoutIfNeeded() - } - - self.scrollView.pinchGestureRecognizer?.isEnabled = false - } - } - - func closeDetail(animate: Bool = true) { - delegate?.didCloseDetail() - self.detailView.hide() - imageViewConstraint = 0 - - statusLabel.isHidden = false - statusViewImage.isHidden = false - - UIView.animate(withDuration: animate ? 0.3 : 0) { - self.imageViewTopConstraint.constant = 0 - self.imageViewBottomConstraint.constant = 0 - self.detailViewTopConstraint.constant = 0 - self.view.layoutIfNeeded() - } - - scrollView.pinchGestureRecognizer?.isEnabled = true - } - - private func showDetailView(exif: ExifData) { - self.detailView.show( - metadata: self.metadata, - image: self.image, - exif: exif, - ncplayer: self.ncplayer, - delegate: self) - } - - func reloadDetail() { - if self.detailView.isShown { - utility.getExif(metadata: metadata) { exif in - self.showDetailView(exif: exif) - } - } - } -} - -extension NCViewerMedia: UIScrollViewDelegate { - func viewForZooming(in scrollView: UIScrollView) -> UIView? { - return imageVideoContainer - } - - func scrollViewDidZoom(_ scrollView: UIScrollView) { - if scrollView.zoomScale > 1 { - if let image = imageVideoContainer.image { - let ratioW = imageVideoContainer.frame.width / image.size.width - let ratioH = imageVideoContainer.frame.height / image.size.height - let ratio = ratioW < ratioH ? ratioW : ratioH - let newWidth = image.size.width * ratio - let newHeight = image.size.height * ratio - let conditionLeft = newWidth * scrollView.zoomScale > imageVideoContainer.frame.width - let left = 0.5 * (conditionLeft ? newWidth - imageVideoContainer.frame.width : (scrollView.frame.width - scrollView.contentSize.width)) - let conditioTop = newHeight * scrollView.zoomScale > imageVideoContainer.frame.height - - let top = 0.5 * (conditioTop ? newHeight - imageVideoContainer.frame.height : (scrollView.frame.height - scrollView.contentSize.height)) - - scrollView.contentInset = UIEdgeInsets(top: top, left: left, bottom: top, right: left) - } - } else { - scrollView.contentInset = .zero - } - } -} - -extension NCViewerMedia: NCViewerMediaDetailViewDelegate { - func downloadFullResolution() { - Task { - await downloadImage(withSelector: global.selectorOpenDetail) - } - } -} - -extension NCViewerMedia: EasyTipViewDelegate { - func showTip() { - if !self.database.tipExists(global.tipMediaDetailView) { - var preferences = EasyTipView.Preferences() - preferences.drawing.foregroundColor = .white - preferences.drawing.backgroundColor = .lightGray - preferences.drawing.textAlignment = .left - preferences.drawing.arrowPosition = .bottom - preferences.drawing.cornerRadius = 10 - - preferences.animating.dismissTransform = CGAffineTransform(translationX: 0, y: -15) - preferences.animating.showInitialTransform = CGAffineTransform(translationX: 0, y: -15) - preferences.animating.showInitialAlpha = 0 - preferences.animating.showDuration = 0.5 - preferences.animating.dismissDuration = 0 - - if tipView == nil, let view = detailView { - tipView = EasyTipView(text: NSLocalizedString("_tip_open_mediadetail_", comment: ""), preferences: preferences, delegate: self) - tipView?.show(forView: view) - } - } - } - - func easyTipViewDidTap(_ tipView: EasyTipView) { - self.database.addTip(global.tipMediaDetailView) - } - - func easyTipViewDidDismiss(_ tipView: EasyTipView) { } - - func dismissTip() { - if !self.database.tipExists(global.tipMediaDetailView) { - self.database.addTip(global.tipMediaDetailView) - } - tipView?.dismiss() - tipView = nil - } -} - -extension NCViewerMedia: NCTransferDelegate { - func transferReloadData(serverUrl: String?) { } - - func transferReloadDataSource(serverUrl: String?, requestData: Bool, status: Int?) { } - - func transferProgressDidUpdate(progress: Float, totalBytes: Int64, totalBytesExpected: Int64, fileName: String, serverUrl: String) { } - - func transferChange(status: String, - account: String, - fileName: String, - serverUrl: String, - selector: String?, - ocId: String, - destination: String?, - error: NKError) { - if status == self.global.networkingStatusDownloaded { - DispatchQueue.main.async { - self.closeDetail() - } - } - } -} diff --git a/iOSClient/Viewer/NCViewerMedia/NCViewerMediaDetailView.swift b/iOSClient/Viewer/NCViewerMedia/NCViewerMediaDetailView.swift deleted file mode 100644 index 0cf1201651..0000000000 --- a/iOSClient/Viewer/NCViewerMedia/NCViewerMediaDetailView.swift +++ /dev/null @@ -1,233 +0,0 @@ -// SPDX-FileCopyrightText: Nextcloud GmbH -// SPDX-FileCopyrightText: 2020 Marino Faggiana -// SPDX-License-Identifier: GPL-3.0-or-later - -import UIKit -import MapKit -import NextcloudKit - -public protocol NCViewerMediaDetailViewDelegate: AnyObject { - func downloadFullResolution() -} - -class NCViewerMediaDetailView: UIView { - @IBOutlet weak var mapContainer: UIView! - @IBOutlet weak var outerMapContainer: UIView! - @IBOutlet weak var dayLabel: UILabel! - @IBOutlet weak var dateLabel: UILabel! - @IBOutlet weak var noDateLabel: UILabel! - @IBOutlet weak var timeLabel: UILabel! - @IBOutlet weak var nameLabel: UILabel! - @IBOutlet weak var modelLabel: UILabel! - @IBOutlet weak var deviceContainer: UIView! - @IBOutlet weak var outerContainer: UIView! - @IBOutlet weak var lensLabel: UILabel! - @IBOutlet weak var megaPixelLabel: UILabel! - @IBOutlet weak var megaPixelLabelDivider: UILabel! - @IBOutlet weak var resolutionLabel: UILabel! - @IBOutlet weak var resolutionLabelDivider: UILabel! - @IBOutlet weak var sizeLabel: UILabel! - @IBOutlet weak var extensionLabel: UILabel! - @IBOutlet weak var livePhotoImageView: UIImageView! - @IBOutlet weak var isoLabel: UILabel! - @IBOutlet weak var lensSizeLabel: UILabel! - @IBOutlet weak var exposureValueLabel: UILabel! - @IBOutlet weak var apertureLabel: UILabel! - @IBOutlet weak var shutterSpeedLabel: UILabel! - @IBOutlet weak var locationLabel: UILabel! - @IBOutlet weak var downloadImageButton: UIButton! - @IBOutlet weak var downloadImageLabel: UILabel! - @IBOutlet weak var downloadImageButtonContainer: UIStackView! - @IBOutlet weak var dateContainer: UIView! - @IBOutlet weak var lensInfoStackViewLeadingConstraint: NSLayoutConstraint! - @IBOutlet weak var lensInfoStackViewTrailingConstraint: NSLayoutConstraint! - @IBOutlet weak var lensInfoLeadingFakePadding: UILabel! - @IBOutlet weak var lensInfoTrailingFakePadding: UILabel! - - private var metadata: tableMetadata? - private var mapView: MKMapView? - private var ncplayer: NCPlayer? - weak var delegate: NCViewerMediaDetailViewDelegate? - let utilityFileSystem = NCUtilityFileSystem() - - private var exif: ExifData? - - var isShown: Bool { - return !self.isHidden - } - - deinit { - print("deinit NCViewerMediaDetailView") - - self.mapView?.removeFromSuperview() - self.mapView = nil - } - - func show(metadata: tableMetadata, - image: UIImage?, - exif: ExifData, - ncplayer: NCPlayer?, - delegate: NCViewerMediaDetailViewDelegate?) { - - self.metadata = metadata - self.exif = exif - self.ncplayer = ncplayer - self.delegate = delegate - - outerMapContainer.isHidden = true - downloadImageButtonContainer.isHidden = true - - if let latitude = exif.latitude, let longitude = exif.longitude, NCNetworking.shared.isOnline { - // We hide the map view on phones in landscape (aka compact height), since there is too little space to fit all of it. - mapContainer.isHidden = traitCollection.verticalSizeClass == .compact - - outerMapContainer.isHidden = false - let annotation = MKPointAnnotation() - annotation.coordinate = CLLocationCoordinate2D(latitude: latitude, longitude: longitude) - let region = MKCoordinateRegion(center: annotation.coordinate, latitudinalMeters: 500, longitudinalMeters: 500) - - if mapView == nil, mapView?.region.center.latitude != latitude, mapView?.region.center.longitude != longitude { - let mapView = MKMapView() - self.mapView = mapView - mapContainer.subviews.forEach { $0.removeFromSuperview() } - self.mapContainer.addSubview(mapView) - mapView.translatesAutoresizingMaskIntoConstraints = false - NSLayoutConstraint.activate([ - mapView.topAnchor.constraint(equalTo: self.mapContainer.topAnchor), - mapView.bottomAnchor.constraint(equalTo: self.mapContainer.bottomAnchor), - mapView.leadingAnchor.constraint(equalTo: self.mapContainer.leadingAnchor), - mapView.trailingAnchor.constraint(equalTo: self.mapContainer.trailingAnchor) - ]) - - mapView.isZoomEnabled = true - mapView.isScrollEnabled = false - mapView.isUserInteractionEnabled = false - mapView.addAnnotation(annotation) - - mapView.setRegion(region, animated: false) - } - } - - if let make = exif.make, let model = exif.model, let lensModel = exif.lensModel { - modelLabel.text = "\(make) \(model)" - lensLabel.text = lensModel - .replacingOccurrences(of: make, with: "") - .replacingOccurrences(of: model, with: "") - .replacingOccurrences(of: "f/", with: "ƒ").trimmingCharacters(in: .whitespacesAndNewlines).firstUppercased - } else { - modelLabel.text = NSLocalizedString("_no_camera_information_", comment: "") - lensLabel.text = NSLocalizedString("_no_lens_information_", comment: "") - } - - nameLabel.text = (metadata.fileNameView as NSString).deletingPathExtension - sizeLabel.text = utilityFileSystem.transformedSize(metadata.size) - - if let shutterSpeedApex = exif.shutterSpeedApex { - prepareLensInfoViewsForData() - shutterSpeedLabel.text = "1/\(Int(pow(2, shutterSpeedApex))) s" - } - - if let iso = exif.iso { - prepareLensInfoViewsForData() - isoLabel.text = "ISO \(iso)" - } - - if let apertureValue = exif.apertureValue { - apertureLabel.text = "ƒ\(apertureValue)" - } - - if let exposureValue = exif.exposureValue { - exposureValueLabel.text = "\(exposureValue) ev" - } - - if let lensLength = exif.lensLength { - lensSizeLabel.text = "\(lensLength) mm" - } - - if let date = exif.date { - dateContainer.isHidden = false - noDateLabel.isHidden = true - - let formatter = DateFormatter() - - formatter.dateFormat = "EEEE" - let dayString = formatter.string(from: date as Date) - dayLabel.text = dayString - - formatter.dateFormat = "d MMM yyyy" - let dateString = formatter.string(from: date as Date) - dateLabel.text = dateString - - formatter.dateFormat = "HH:mm" - let timeString = formatter.string(from: date as Date) - timeLabel.text = timeString - } else { - noDateLabel.text = NSLocalizedString("_no_date_information_", comment: "") - } - - if let height = exif.height, let width = exif.width { - megaPixelLabel.isHidden = false - megaPixelLabelDivider.isHidden = false - resolutionLabel.isHidden = false - resolutionLabelDivider.isHidden = false - - resolutionLabel.text = "\(width) x \(height)" - - let megaPixels: Double = Double(width * height) / 1000000 - megaPixelLabel.text = megaPixels < 1 ? String(format: "%.1f MP", megaPixels) : "\(Int(megaPixels)) MP" - } - - extensionLabel.text = metadata.fileExtension.uppercased() - - if exif.location?.isEmpty == false { - locationLabel.text = exif.location - } - - if metadata.isLivePhoto { - livePhotoImageView.isHidden = false - } - - if metadata.isImage && !utilityFileSystem.fileProviderStorageExists(metadata) && metadata.session.isEmpty { - downloadImageButton.setTitle(NSLocalizedString("_try_download_full_resolution_", comment: ""), for: .normal) - downloadImageLabel.text = NSLocalizedString("_full_resolution_image_info_", comment: "") - downloadImageButtonContainer.isHidden = false - } - - self.isHidden = false - layoutIfNeeded() - } - - func hide() { - self.isHidden = true - } - - private func prepareLensInfoViewsForData() { - lensInfoLeadingFakePadding.isHidden = true - lensInfoTrailingFakePadding.isHidden = true - lensInfoStackViewLeadingConstraint.constant = 5 - lensInfoStackViewTrailingConstraint.constant = 5 - } - - // MARK: - Action - - @IBAction func touchLocation(_ sender: Any) { - guard let latitude = exif?.latitude, let longitude = exif?.longitude else { return } - - let latitudeDeg: CLLocationDegrees = latitude - let longitudeDeg: CLLocationDegrees = longitude - - let coordinates = CLLocationCoordinate2DMake(latitudeDeg, longitudeDeg) - let placemark = MKPlacemark(coordinate: coordinates, addressDictionary: nil) - let mapItem = MKMapItem(placemark: placemark) - - if let location = exif?.location { - mapItem.name = location - } - - mapItem.openInMaps() - } - - @IBAction func touchDownload(_ sender: Any) { - delegate?.downloadFullResolution() - } -} diff --git a/iOSClient/Viewer/NCViewerMedia/NCViewerMediaPage.storyboard b/iOSClient/Viewer/NCViewerMedia/NCViewerMediaPage.storyboard deleted file mode 100644 index 0e982c9edd..0000000000 --- a/iOSClient/Viewer/NCViewerMedia/NCViewerMediaPage.storyboard +++ /dev/null @@ -1,599 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/iOSClient/Viewer/NCViewerMedia/NCViewerMediaPage.swift b/iOSClient/Viewer/NCViewerMedia/NCViewerMediaPage.swift deleted file mode 100644 index ada4b6cab2..0000000000 --- a/iOSClient/Viewer/NCViewerMedia/NCViewerMediaPage.swift +++ /dev/null @@ -1,658 +0,0 @@ -// SPDX-FileCopyrightText: Nextcloud GmbH -// SPDX-FileCopyrightText: 2020 Marino Faggiana -// SPDX-License-Identifier: GPL-3.0-or-later - -import UIKit -import NextcloudKit -import MediaPlayer - -enum ScreenMode { - case full, normal -} - -var viewerMediaScreenMode: ScreenMode = .normal - -class NCViewerMediaPage: UIViewController { - @IBOutlet weak var progressView: UIProgressView! - - // Parameters - var ocIds: [String] = [] - var currentIndex: Int = 0 - var delegateViewController: UIViewController? - - var modifiedOcId: [String] = [] - var nextIndex: Int? - var panGestureRecognizer: UIPanGestureRecognizer! - var singleTapGestureRecognizer: UITapGestureRecognizer! - var longtapGestureRecognizer: UILongPressGestureRecognizer! - var playCommand: Any? - var pauseCommand: Any? - var skipForwardCommand: Any? - var skipBackwardCommand: Any? - var nextTrackCommand: Any? - var previousTrackCommand: Any? - let utilityFileSystem = NCUtilityFileSystem() - let global = NCGlobal.shared - let database = NCManageDatabase.shared - - // This prevents the scroll views to scroll when you drag and drop files/images/subjects (from this or other apps) - // https://forums.developer.apple.com/forums/thread/89396 and https://forums.developer.apple.com/forums/thread/115736 - var preventScrollOnDragAndDrop = true - - var timerAutoHide: Timer? - private var timerAutoHideSeconds: Double = 4 - - private lazy var moreNavigationItem = UIBarButtonItem( - image: NCImageCache.shared.getImageButtonMore(), - primaryAction: nil, - menu: UIMenu(title: "", children: [ - UIDeferredMenuElement.uncached { [self] completion in - if let menu = NCContextMenuViewer(metadata: currentViewController.metadata, controller: self.tabBarController as? NCMainTabBarController, webView: false, sender: self).viewMenu() { - completion(menu.children) - } - } - ])) - - private lazy var imageDetailNavigationItem = UIBarButtonItem(image: NCUtility().loadImage(named: "info.circle", colors: [NCBrandColor.shared.iconImageColor]), style: .plain, target: self, action: #selector(toggleDetail(_:))) - - // swiftlint:disable force_cast - var pageViewController: UIPageViewController { - return self.children[0] as! UIPageViewController - } - - var currentViewController: NCViewerMedia { - return self.pageViewController.viewControllers![0] as! NCViewerMedia - } - // swiftlint:enable force_cast - - private var hideStatusBar: Bool = false { - didSet { - setNeedsStatusBarAppearanceUpdate() - } - } - - var sceneIdentifier: String { - (self.tabBarController as? NCMainTabBarController)?.sceneIdentifier ?? "" - } - - // MARK: - View Life Cycle - - override init(nibName nibNameOrNil: String?, bundle nibBundleOrNil: Bundle?) { - super.init(nibName: nibNameOrNil, bundle: nibBundleOrNil) - - viewerMediaScreenMode = .normal - } - - required init?(coder: NSCoder) { - super.init(coder: coder) - - viewerMediaScreenMode = .normal - } - - override func viewDidLoad() { - super.viewDidLoad() - - let metadata = database.getMetadataFromOcId(ocIds[currentIndex])! - var items: [UIBarButtonItem] = [] - - singleTapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(didSingleTapWith(gestureRecognizer:))) - panGestureRecognizer = UIPanGestureRecognizer(target: self, action: #selector(didPanWith(gestureRecognizer:))) - longtapGestureRecognizer = UILongPressGestureRecognizer() - longtapGestureRecognizer.delaysTouchesBegan = true - longtapGestureRecognizer.minimumPressDuration = 0.3 - longtapGestureRecognizer.delegate = self - longtapGestureRecognizer.addTarget(self, action: #selector(didLongpressGestureEvent(gestureRecognizer:))) - - pageViewController.delegate = self - pageViewController.dataSource = self - pageViewController.view.addGestureRecognizer(panGestureRecognizer) - pageViewController.view.addGestureRecognizer(singleTapGestureRecognizer) - pageViewController.view.addGestureRecognizer(longtapGestureRecognizer) - - progressView.tintColor = NCBrandColor.shared.getElement(account: metadata.account) - progressView.trackTintColor = .clear - progressView.progress = 0 - - let viewerMedia = getViewerMedia(index: currentIndex, metadata: metadata) - pageViewController.setViewControllers([viewerMedia], direction: .forward, animated: true, completion: nil) - - NotificationCenter.default.addObserver(self, selector: #selector(pageViewController.enableSwipeGesture), name: NSNotification.Name(rawValue: NCGlobal.shared.notificationCenterEnableSwipeGesture), object: nil) - NotificationCenter.default.addObserver(self, selector: #selector(pageViewController.disableSwipeGesture), name: NSNotification.Name(rawValue: NCGlobal.shared.notificationCenterDisableSwipeGesture), object: nil) - NotificationCenter.default.addObserver(self, selector: #selector(applicationDidBecomeActive(_:)), name: UIApplication.didBecomeActiveNotification, object: nil) - - if currentViewController.metadata.isImage { - items.append(imageDetailNavigationItem) - } - items.append(moreNavigationItem) - - let group = UIBarButtonItemGroup( - barButtonItems: items, - representativeItem: nil - ) - navigationItem.trailingItemGroups = [group] - - for view in self.pageViewController.view.subviews { - if let scrollView = view as? UIScrollView { - scrollView.delegate = self - } - } - } - - deinit { - timerAutoHide?.invalidate() - timerAutoHide = nil - - NotificationCenter.default.removeObserver(self, name: NSNotification.Name(rawValue: NCGlobal.shared.notificationCenterEnableSwipeGesture), object: nil) - NotificationCenter.default.removeObserver(self, name: NSNotification.Name(rawValue: NCGlobal.shared.notificationCenterDisableSwipeGesture), object: nil) - - NotificationCenter.default.removeObserver(self, name: UIApplication.didBecomeActiveNotification, object: nil) - } - - override func viewWillAppear(_ animated: Bool) { - super.viewWillAppear(animated) - - changeScreenMode(mode: viewerMediaScreenMode) - - if #available(iOS 18.0, *) { - self.tabBarController?.setTabBarHidden(true, animated: true) - } else { - self.tabBarController?.tabBar.isHidden = true - } - } - - override func viewDidAppear(_ animated: Bool) { - super.viewDidAppear(animated) - - Task { - await NCNetworking.shared.transferDispatcher.addDelegate(self) - } - - startTimerAutoHide() - } - - override func viewWillDisappear(_ animated: Bool) { - super.viewWillDisappear(animated) - - changeScreenMode(mode: .normal) - - if #available(iOS 18.0, *) { - self.tabBarController?.setTabBarHidden(false, animated: true) - } else { - self.tabBarController?.tabBar.isHidden = false - } - } - - override func viewDidDisappear(_ animated: Bool) { - super.viewDidDisappear(animated) - - Task { - await NCNetworking.shared.transferDispatcher.removeDelegate(self) - } - - currentViewController.ncplayer?.playerStop() - timerAutoHide?.invalidate() - clearCommandCenter() - } - - override var preferredStatusBarStyle: UIStatusBarStyle { - if viewerMediaScreenMode == .normal { - return .default - } else { - return .lightContent - } - } - - override var prefersHomeIndicatorAutoHidden: Bool { - return viewerMediaScreenMode == .full - } - - override var prefersStatusBarHidden: Bool { - return hideStatusBar - } - - func getViewerMedia(index: Int, metadata: tableMetadata) -> NCViewerMedia { - // swiftlint:disable force_cast - let viewerMedia = UIStoryboard(name: "NCViewerMediaPage", bundle: nil).instantiateViewController(withIdentifier: "NCViewerMedia") as! NCViewerMedia - // swiftlint:enable force_cast - - viewerMedia.index = index - viewerMedia.metadata = metadata - viewerMedia.viewerMediaPage = self - viewerMedia.delegate = self - - singleTapGestureRecognizer.require(toFail: viewerMedia.doubleTapGestureRecognizer) - - return viewerMedia - } - - @objc private func toggleDetail(_ sender: Any?) { - currentViewController.toggleDetail() - } - - func changeScreenMode(mode: ScreenMode) { - let metadata = currentViewController.metadata - let fullscreen = currentViewController.playerToolBar?.isFullscreen ?? false - - if mode == .normal { - - if fullscreen { - navigationController?.setNavigationBarHidden(true, animated: true) - hideStatusBar = true - progressView.isHidden = true - } else { - navigationController?.setNavigationBarHidden(false, animated: true) - hideStatusBar = false - progressView.isHidden = false - } - - if metadata.isAudioOrVideo { - navigationController?.setNavigationBarAppearance(textColor: .white, backgroundColor: .black) - currentViewController.playerToolBar?.show() - view.backgroundColor = .black - moreNavigationItem.image = NCImageCache.shared.getImageButtonMore(colors: [.white]) - } else { - navigationController?.setNavigationBarAppearance() - view.backgroundColor = .systemBackground - moreNavigationItem.image = NCImageCache.shared.getImageButtonMore() - } - - } else if !currentViewController.detailView.isShown { - - navigationController?.setNavigationBarHidden(true, animated: true) - hideStatusBar = true - progressView.isHidden = true - - if metadata.isVideo { - currentViewController.playerToolBar?.hide() - } - - view.backgroundColor = .black - } - - if fullscreen { - pageViewController.disableSwipeGesture() - } else { - pageViewController.enableSwipeGesture() - } - - viewerMediaScreenMode = mode - print("Screen mode: \(viewerMediaScreenMode)") - - startTimerAutoHide() - setNeedsStatusBarAppearanceUpdate() - setNeedsUpdateOfHomeIndicatorAutoHidden() - currentViewController.reloadDetail() - } - - @objc func startTimerAutoHide() { - timerAutoHide?.invalidate() - timerAutoHide = Timer.scheduledTimer(timeInterval: timerAutoHideSeconds, target: self, selector: #selector(autoHide), userInfo: nil, repeats: true) - } - - @objc func autoHide() { - let metadata = currentViewController.metadata - if metadata.isVideo, viewerMediaScreenMode == .normal { - changeScreenMode(mode: .full) - } - } - - // MARK: - NotificationCenter - - @objc func applicationDidBecomeActive(_ notification: NSNotification) { - progressView.progress = 0 - changeScreenMode(mode: .normal) - } - - // MARK: - Command Center - - func updateCommandCenter(ncplayer: NCPlayer, title: String) { - var nowPlayingInfo = [String: Any]() - - UIApplication.shared.beginReceivingRemoteControlEvents() - - // Add handler for Play Command - MPRemoteCommandCenter.shared().playCommand.isEnabled = true - playCommand = MPRemoteCommandCenter.shared().playCommand.addTarget { _ in - - if !ncplayer.isPlaying() { - ncplayer.playerPlay() - return .success - } - return .commandFailed - } - - // Add handler for Pause Command - MPRemoteCommandCenter.shared().pauseCommand.isEnabled = true - pauseCommand = MPRemoteCommandCenter.shared().pauseCommand.addTarget { _ in - - if ncplayer.isPlaying() { - ncplayer.playerPause() - return .success - } - return .commandFailed - } - - // >> - MPRemoteCommandCenter.shared().skipForwardCommand.isEnabled = true - skipForwardCommand = MPRemoteCommandCenter.shared().skipForwardCommand.addTarget { event in - - let seconds = Int32((event as? MPSkipIntervalCommandEvent)?.interval ?? 0) - ncplayer.player.jumpForward(seconds) - return.success - } - - // << - MPRemoteCommandCenter.shared().skipBackwardCommand.isEnabled = true - skipBackwardCommand = MPRemoteCommandCenter.shared().skipBackwardCommand.addTarget { event in - - let seconds = Int32((event as? MPSkipIntervalCommandEvent)?.interval ?? 0) - ncplayer.player.jumpBackward(seconds) - return.success - } - - nowPlayingInfo[MPMediaItemPropertyTitle] = title - if let image = currentViewController.image { - nowPlayingInfo[MPMediaItemPropertyArtwork] = MPMediaItemArtwork(boundsSize: image.size) { _ in - return image - } - } - MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo - } - - func clearCommandCenter() { - - UIApplication.shared.endReceivingRemoteControlEvents() - MPNowPlayingInfoCenter.default().nowPlayingInfo = nil - - MPRemoteCommandCenter.shared().playCommand.isEnabled = false - MPRemoteCommandCenter.shared().pauseCommand.isEnabled = false - MPRemoteCommandCenter.shared().skipForwardCommand.isEnabled = false - MPRemoteCommandCenter.shared().skipBackwardCommand.isEnabled = false - MPRemoteCommandCenter.shared().nextTrackCommand.isEnabled = false - MPRemoteCommandCenter.shared().previousTrackCommand.isEnabled = false - - if let playCommand = playCommand { - MPRemoteCommandCenter.shared().playCommand.removeTarget(playCommand) - self.playCommand = nil - } - if let pauseCommand = pauseCommand { - MPRemoteCommandCenter.shared().pauseCommand.removeTarget(pauseCommand) - self.pauseCommand = nil - } - if let skipForwardCommand = skipForwardCommand { - MPRemoteCommandCenter.shared().skipForwardCommand.removeTarget(skipForwardCommand) - self.skipForwardCommand = nil - } - if let skipBackwardCommand = skipBackwardCommand { - MPRemoteCommandCenter.shared().skipBackwardCommand.removeTarget(skipBackwardCommand) - self.skipBackwardCommand = nil - } - if let nextTrackCommand = nextTrackCommand { - MPRemoteCommandCenter.shared().nextTrackCommand.removeTarget(nextTrackCommand) - self.nextTrackCommand = nil - } - if let previousTrackCommand = previousTrackCommand { - MPRemoteCommandCenter.shared().previousTrackCommand.removeTarget(previousTrackCommand) - self.previousTrackCommand = nil - } - } -} - -// MARK: - UIPageViewController Delegate Datasource - -extension NCViewerMediaPage: UIPageViewControllerDelegate, UIPageViewControllerDataSource { - - func pageViewController(_ pageViewController: UIPageViewController, viewControllerBefore viewController: UIViewController) -> UIViewController? { - guard currentIndex > 0, - let metadata = database.getMetadataFromOcId(ocIds[currentIndex - 1]) else { return nil } - - let viewerMedia = getViewerMedia(index: currentIndex - 1, metadata: metadata) - return viewerMedia - } - - func pageViewController(_ pageViewController: UIPageViewController, viewControllerAfter viewController: UIViewController) -> UIViewController? { - guard currentIndex < ocIds.count - 1, - let metadata = database.getMetadataFromOcId(ocIds[currentIndex + 1]) else { return nil } - - let viewerMedia = getViewerMedia(index: currentIndex + 1, metadata: metadata) - return viewerMedia - } - - // START TRANSITION - func pageViewController(_ pageViewController: UIPageViewController, willTransitionTo pendingViewControllers: [UIViewController]) { - - guard let nextViewController = pendingViewControllers.first as? NCViewerMedia else { - return - } - var items: [UIBarButtonItem] = [] - - nextIndex = nextViewController.index - - if nextViewController.metadata.isImage { - items.append(imageDetailNavigationItem) - } - items.append(moreNavigationItem) - - let group = UIBarButtonItemGroup( - barButtonItems: items, - representativeItem: nil - ) - navigationItem.trailingItemGroups = [group] - - if nextViewController.detailView.isShown { - changeScreenMode(mode: .normal) - } - } - - // END TRANSITION - func pageViewController(_ pageViewController: UIPageViewController, didFinishAnimating finished: Bool, previousViewControllers: [UIViewController], transitionCompleted completed: Bool) { - - if completed && nextIndex != nil { - previousViewControllers.forEach { viewController in - let viewerMedia = viewController as? NCViewerMedia - viewerMedia?.ncplayer?.playerStop() - viewerMedia?.closeDetail() - } - currentIndex = nextIndex! - } - - changeScreenMode(mode: viewerMediaScreenMode) - startTimerAutoHide() - - self.nextIndex = nil - } -} - -// MARK: - UIGestureRecognizerDelegate - -extension NCViewerMediaPage: UIGestureRecognizerDelegate { - - func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { - if let gestureRecognizer = gestureRecognizer as? UIPanGestureRecognizer { - let velocity = gestureRecognizer.velocity(in: self.view) - - var velocityCheck: Bool = false - - if UIDevice.current.orientation.isLandscape { - velocityCheck = velocity.x < 0 - } else { - velocityCheck = velocity.y < 0 - } - if velocityCheck { - return false - } - } - - return true - } - - func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool { - - if otherGestureRecognizer == currentViewController.scrollView.panGestureRecognizer { - if self.currentViewController.scrollView.contentOffset.y == 0 { - return true - } - } - - return false - } - - @objc func didPanWith(gestureRecognizer: UIPanGestureRecognizer) { - currentViewController.didPanWith(gestureRecognizer: gestureRecognizer) - } - - @objc func didSingleTapWith(gestureRecognizer: UITapGestureRecognizer) { - if currentViewController.detailView.isShown { return } - - if viewerMediaScreenMode == .full { - changeScreenMode(mode: .normal) - } else { - changeScreenMode(mode: .full) - } - } - - // MARK: - Live Photo - @objc func didLongpressGestureEvent(gestureRecognizer: UITapGestureRecognizer) { - if !currentViewController.metadata.isLivePhoto || currentViewController.detailView.isShown { return } - - if gestureRecognizer.state == .began { - if let metadataLive = NCManageDatabase.shared.getMetadataLivePhoto(metadata: currentViewController.metadata), - utilityFileSystem.fileProviderStorageExists(metadataLive) { - AudioServicesPlaySystemSound(1519) // peek feedback - currentViewController.playLivePhoto(filePath: utilityFileSystem.getDirectoryProviderStorageOcId( - metadataLive.ocId, - fileName: metadataLive.fileName, - userId: metadataLive.userId, - urlBase: metadataLive.urlBase)) - } - } else if gestureRecognizer.state == .ended { - currentViewController.stopLivePhoto() - } - } -} - -extension UIPageViewController { - @objc func enableSwipeGesture() { - for view in self.view.subviews { - if let subView = view as? UIScrollView { - subView.isScrollEnabled = true - } - } - } - - @objc func disableSwipeGesture() { - for view in self.view.subviews { - if let subView = view as? UIScrollView { - subView.isScrollEnabled = false - } - } - } -} - -extension NCViewerMediaPage: NCViewerMediaViewDelegate { - func didOpenDetail() { - changeScreenMode(mode: .normal) - imageDetailNavigationItem.image = NCUtility().loadImage(named: "info.circle.fill") - } - - func didCloseDetail() { - imageDetailNavigationItem.image = NCUtility().loadImage(named: "info.circle") - } -} - -extension NCViewerMediaPage: UIScrollViewDelegate { - - func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { - preventScrollOnDragAndDrop = false - } - - func scrollViewDidScroll(_ scrollView: UIScrollView) { - if preventScrollOnDragAndDrop { - scrollView.setContentOffset(CGPoint(x: view.frame.width + 10, y: 0), animated: false) - } - } - - func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) { - if !decelerate { - preventScrollOnDragAndDrop = true - } - } - - func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { - preventScrollOnDragAndDrop = true - } -} - -extension NCViewerMediaPage: NCTransferDelegate { - func transferReloadData(serverUrl: String?) { } - - func transferReloadDataSource(serverUrl: String?, requestData: Bool, status: Int?) { } - - func transferChange(status: String, - account: String, - fileName: String, - serverUrl: String, - selector: String?, - ocId: String, - destination: String?, - error: NKError) { - Task {@MainActor in - switch status { - // DELETE - case NCGlobal.shared.networkingStatusDelete: - if error == .success, - ocId == self.currentViewController.metadata.ocId { - if let ncplayer = self.currentViewController.ncplayer, ncplayer.isPlaying() { - ncplayer.playerPause() - } - self.navigationController?.popViewController(animated: true) - } - // DOWNLOAD - case self.global.networkingStatusDownloaded: - guard ocId == self.currentViewController.metadata.ocId, - let metadata = await NCManageDatabase.shared.getMetadataFromOcIdAsync(ocId) else { - return - } - self.progressView.progress = 0 - - if metadata.isAudioOrVideo, let ncplayer = self.currentViewController.ncplayer { - let url = URL(fileURLWithPath: self.utilityFileSystem.getDirectoryProviderStorageOcId(metadata.ocId, - fileName: metadata.fileNameView, - userId: metadata.userId, - urlBase: metadata.urlBase)) - if ncplayer.isPlaying() { - ncplayer.playerPause() - DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { - ncplayer.openAVPlayer(url: url) - ncplayer.playerPlay() - } - } else { - ncplayer.openAVPlayer(url: url) - } - } else if metadata.isImage { - await self.currentViewController.loadImage() - } - // UPLOAD - case self.global.networkingStatusUploaded: - guard error == .success else { return } - if self.currentViewController.metadata.ocId == ocId { - await self.currentViewController.loadImage() - } else { - self.modifiedOcId.append(ocId) - } - default: - break - } - } - } - - func transferProgressDidUpdate(progress: Float, totalBytes: Int64, totalBytesExpected: Int64, fileName: String, serverUrl: String) { - DispatchQueue.main.async { - if progress == 1 { - self.progressView.progress = 0 - } else { - self.progressView.progress = progress - } - } - } -} diff --git a/iOSClient/Viewer/NCViewerMedia/Views/NCImageZoomView.swift b/iOSClient/Viewer/NCViewerMedia/Views/NCImageZoomView.swift new file mode 100644 index 0000000000..d17886bf8e --- /dev/null +++ b/iOSClient/Viewer/NCViewerMedia/Views/NCImageZoomView.swift @@ -0,0 +1,435 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Marino Faggiana +// SPDX-License-Identifier: GPL-3.0-or-later + +import SwiftUI +import UIKit +import VisionKit + +// MARK: - Image Zoom View + +/// UIKit-backed image zoom view. +/// +/// This view uses `UIScrollView` because it provides native, smooth pinch-to-zoom +/// and pan behavior, which is more reliable than SwiftUI `MagnifyGesture` when +/// hosted inside a paging container. +struct NCImageZoomView: UIViewRepresentable { + let image: UIImage + let backgroundStyle: NCViewerBackgroundStyle + let allowsImageAnalysis: Bool + + private let minimumZoomScale: CGFloat = 1 + private let maximumZoomScale: CGFloat = 5 + private let doubleTapZoomScale: CGFloat = 2.5 + + /// Creates an image zoom view. + /// + /// - Parameters: + /// - image: Image rendered inside the zoomable scroll view. + /// - backgroundStyle: Viewer background style. + init( + image: UIImage, + backgroundStyle: NCViewerBackgroundStyle = .system, + allowsImageAnalysis: Bool = true + ) { + self.image = image + self.backgroundStyle = backgroundStyle + self.allowsImageAnalysis = allowsImageAnalysis + } + + // MARK: - UIViewRepresentable + + func makeUIView(context: Context) -> NCZoomScrollView { + let scrollView = NCZoomScrollView() + + scrollView.delegate = context.coordinator + scrollView.backgroundColor = .ncViewerBackground(backgroundStyle) + scrollView.minimumZoomScale = minimumZoomScale + scrollView.maximumZoomScale = maximumZoomScale + scrollView.zoomScale = minimumZoomScale + scrollView.bouncesZoom = true + scrollView.bounces = true + scrollView.alwaysBounceVertical = false + scrollView.alwaysBounceHorizontal = false + scrollView.showsVerticalScrollIndicator = false + scrollView.showsHorizontalScrollIndicator = false + scrollView.contentInsetAdjustmentBehavior = .never + scrollView.clipsToBounds = true + + let imageView = UIImageView(frame: .zero) + imageView.image = image + imageView.backgroundColor = .ncViewerBackground(backgroundStyle) + imageView.contentMode = .scaleAspectFit + imageView.isUserInteractionEnabled = true + imageView.clipsToBounds = true + + scrollView.addSubview(imageView) + + context.coordinator.scrollView = scrollView + context.coordinator.imageView = imageView + context.coordinator.currentImage = image + context.coordinator.backgroundStyle = backgroundStyle + context.coordinator.minimumZoomScale = minimumZoomScale + context.coordinator.maximumZoomScale = maximumZoomScale + context.coordinator.doubleTapZoomScale = doubleTapZoomScale + + if allowsImageAnalysis { + analyzeImageIfAvailable( + image: image, + imageView: imageView, + coordinator: context.coordinator + ) + } + + scrollView.onLayoutSubviews = { [weak coordinator = context.coordinator] in + coordinator?.layoutImageViewResettingOnBoundsChange() + } + + let doubleTapGesture = UITapGestureRecognizer( + target: context.coordinator, + action: #selector(Coordinator.handleDoubleTap(_:)) + ) + doubleTapGesture.numberOfTapsRequired = 2 + scrollView.addGestureRecognizer(doubleTapGesture) + + return scrollView + } + + func updateUIView( + _ scrollView: NCZoomScrollView, + context: Context + ) { + guard let imageView = context.coordinator.imageView else { + return + } + + context.coordinator.backgroundStyle = backgroundStyle + context.coordinator.minimumZoomScale = minimumZoomScale + context.coordinator.maximumZoomScale = maximumZoomScale + context.coordinator.doubleTapZoomScale = doubleTapZoomScale + + scrollView.backgroundColor = .ncViewerBackground(backgroundStyle) + scrollView.minimumZoomScale = minimumZoomScale + scrollView.maximumZoomScale = maximumZoomScale + imageView.backgroundColor = .ncViewerBackground(backgroundStyle) + + let imageChanged = context.coordinator.currentImage !== image + + if imageChanged { + context.coordinator.currentImage = image + context.coordinator.resetBoundsTracking() + + scrollView.setZoomScale(minimumZoomScale, animated: false) + scrollView.contentOffset = .zero + scrollView.contentInset = .zero + + imageView.image = image + context.coordinator.layoutImageViewResettingZoom() + + if allowsImageAnalysis { + analyzeImageIfAvailable( + image: image, + imageView: imageView, + coordinator: context.coordinator + ) + } else { + removeImageAnalysisInteractions(from: imageView) + } + } else { + context.coordinator.layoutImageViewResettingOnBoundsChange() + } + } + + func makeCoordinator() -> Coordinator { + Coordinator() + } + + // MARK: - Scroll View + + final class NCZoomScrollView: UIScrollView { + var onLayoutSubviews: (() -> Void)? + + override func layoutSubviews() { + super.layoutSubviews() + onLayoutSubviews?() + } + } + + // MARK: - Coordinator + + final class Coordinator: NSObject, UIScrollViewDelegate { + weak var scrollView: UIScrollView? + weak var imageView: UIImageView? + var currentImage: UIImage? + var backgroundStyle: NCViewerBackgroundStyle = .system + + var minimumZoomScale: CGFloat = 1 + var maximumZoomScale: CGFloat = 5 + var doubleTapZoomScale: CGFloat = 2.5 + + private var lastBoundsSize: CGSize = .zero + + // MARK: - UIScrollViewDelegate + + func viewForZooming(in scrollView: UIScrollView) -> UIView? { + imageView + } + + func scrollViewDidZoom(_ scrollView: UIScrollView) { + centerImageView() + } + + // MARK: - Layout + + /// Resets cached bounds tracking so the next layout pass refits the image. + func resetBoundsTracking() { + lastBoundsSize = .zero + } + + /// Lays out the image view and resets zoom to the fitted image. + func layoutImageViewResettingZoom() { + guard let scrollView, + let imageView, + let image = imageView.image else { + return + } + + let boundsSize = scrollView.bounds.size + + guard isValidLayout( + imageSize: image.size, + boundsSize: boundsSize + ) else { + return + } + + let fittedSize = fittedImageSize( + imageSize: image.size, + containerSize: boundsSize + ) + + scrollView.setZoomScale(minimumZoomScale, animated: false) + scrollView.contentInset = .zero + scrollView.contentOffset = .zero + + imageView.frame = CGRect( + origin: .zero, + size: fittedSize + ) + + scrollView.contentSize = fittedSize + lastBoundsSize = boundsSize + + centerImageView() + } + + /// Lays out the image view when the container size changes. + /// + /// The zoom is reset on bounds changes because rotation, iPad resizing, + /// and Stage Manager can otherwise leave stale offsets or invalid content sizes. + func layoutImageViewResettingOnBoundsChange() { + guard let scrollView, + let imageView, + let image = imageView.image else { + return + } + + let boundsSize = scrollView.bounds.size + + guard isValidLayout( + imageSize: image.size, + boundsSize: boundsSize + ) else { + return + } + + guard boundsSize != lastBoundsSize else { + centerImageView() + return + } + + let fittedSize = fittedImageSize( + imageSize: image.size, + containerSize: boundsSize + ) + + scrollView.setZoomScale(minimumZoomScale, animated: false) + scrollView.contentInset = .zero + scrollView.contentOffset = .zero + + imageView.frame = CGRect( + origin: .zero, + size: fittedSize + ) + + scrollView.contentSize = fittedSize + lastBoundsSize = boundsSize + + centerImageView() + } + + /// Centers the image view inside the scroll view when the image is smaller than the viewport. + private func centerImageView() { + guard let scrollView, + let imageView else { + return + } + + let boundsSize = scrollView.bounds.size + let frameSize = imageView.frame.size + + let horizontalInset = max((boundsSize.width - frameSize.width) * 0.5, 0) + let verticalInset = max((boundsSize.height - frameSize.height) * 0.5, 0) + + let newInset = UIEdgeInsets( + top: verticalInset, + left: horizontalInset, + bottom: verticalInset, + right: horizontalInset + ) + + if scrollView.contentInset != newInset { + scrollView.contentInset = newInset + } + } + + /// Returns whether the current image and container sizes can be used for layout. + private func isValidLayout( + imageSize: CGSize, + boundsSize: CGSize + ) -> Bool { + imageSize.width > 0 && + imageSize.height > 0 && + boundsSize.width > 0 && + boundsSize.height > 0 + } + + /// Returns the aspect-fit size of an image inside a container. + private func fittedImageSize( + imageSize: CGSize, + containerSize: CGSize + ) -> CGSize { + let widthRatio = containerSize.width / imageSize.width + let heightRatio = containerSize.height / imageSize.height + let ratio = min(widthRatio, heightRatio) + + return CGSize( + width: imageSize.width * ratio, + height: imageSize.height * ratio + ) + } + + // MARK: - Gestures + + /// Handles double tap zoom and reset. + @objc + func handleDoubleTap(_ gesture: UITapGestureRecognizer) { + guard let scrollView, + let imageView else { + return + } + + if scrollView.zoomScale > minimumZoomScale + 0.01 { + scrollView.setZoomScale(minimumZoomScale, animated: true) + return + } + + let point = gesture.location(in: imageView) + let targetScale = min(doubleTapZoomScale, maximumZoomScale) + + let zoomRect = zoomRect( + for: scrollView, + scale: targetScale, + center: point + ) + + scrollView.zoom(to: zoomRect, animated: true) + } + + /// Builds the zoom rect used by double tap. + private func zoomRect( + for scrollView: UIScrollView, + scale: CGFloat, + center: CGPoint + ) -> CGRect { + let size = CGSize( + width: scrollView.bounds.width / scale, + height: scrollView.bounds.height / scale + ) + + return CGRect( + x: center.x - size.width * 0.5, + y: center.y - size.height * 0.5, + width: size.width, + height: size.height + ) + } + } + + // MARK: - Image Analysis + + /// Adds VisionKit image analysis to the displayed image when supported. + /// + /// Existing analysis interactions are removed before installing a new one, + /// so stale analysis results are not reused after an image change. + /// + /// - Parameters: + /// - image: Image to analyze. + /// - imageView: Image view that renders the image. + /// - coordinator: Coordinator used to validate that the image is still current. + @MainActor + private func analyzeImageIfAvailable( + image: UIImage, + imageView: UIImageView, + coordinator: Coordinator + ) { + guard ImageAnalyzer.isSupported else { + return + } + + imageView.interactions + .compactMap { $0 as? ImageAnalysisInteraction } + .forEach { imageView.removeInteraction($0) } + + let interaction = ImageAnalysisInteraction() + interaction.preferredInteractionTypes = [] + interaction.analysis = nil + + imageView.addInteraction(interaction) + + let analyzer = ImageAnalyzer() + let configuration = ImageAnalyzer.Configuration([ + .text, + .machineReadableCode, + .visualLookUp + ]) + + Task { @MainActor in + let analysis = try? await analyzer.analyze( + image, + configuration: configuration + ) + + guard coordinator.currentImage === image else { + return + } + + guard imageView.image === image else { + return + } + + interaction.analysis = analysis + interaction.preferredInteractionTypes = .automatic + } + } + + /// Removes VisionKit image analysis interactions from the image view. + /// + /// - Parameter imageView: Image view from which analysis interactions should be removed. + @MainActor + private func removeImageAnalysisInteractions(from imageView: UIImageView) { + imageView.interactions + .compactMap { $0 as? ImageAnalysisInteraction } + .forEach { imageView.removeInteraction($0) } + } +} diff --git a/iOSClient/Viewer/NCViewerMedia/Views/NCMediaViewerDetailView.swift b/iOSClient/Viewer/NCViewerMedia/Views/NCMediaViewerDetailView.swift new file mode 100644 index 0000000000..750fb3404f --- /dev/null +++ b/iOSClient/Viewer/NCViewerMedia/Views/NCMediaViewerDetailView.swift @@ -0,0 +1,330 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Marino Faggiana +// SPDX-License-Identifier: GPL-3.0-or-later + +import SwiftUI +import MapKit +import NextcloudKit + +// MARK: - Media Viewer Detail View + +/// SwiftUI detail panel for media viewer metadata. +/// +/// It renders file information, optional EXIF information, and optional location data. +struct NCMediaViewerDetailView: View { + let metadata: tableMetadata + let exif: ExifData + + private let utilityFileSystem = NCUtilityFileSystem() + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 18) { + dateSection + fileSection + cameraSection + lensSection + exposureSection + locationSection + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 16) + .padding(.vertical, 20) + } + .scrollContentBackground(.hidden) + .background(Color.ncViewerBackground(.system)) + .presentationBackground(Color.ncViewerBackground(.system)) + } + + // MARK: - Sections + + @ViewBuilder + private var dateSection: some View { + if let date = exif.date as Date? { + VStack(alignment: .leading, spacing: 4) { + Text(dayString(from: date)) + .font(.headline) + + HStack(spacing: 8) { + Text(dateString(from: date)) + Text(timeString(from: date)) + } + .font(.subheadline) + .foregroundStyle(.secondary) + } + } else { + Text(NSLocalizedString("_no_date_information_", comment: "")) + .font(.headline) + .foregroundStyle(.secondary) + } + } + + private var fileSection: some View { + VStack(alignment: .leading, spacing: 8) { + Text(fileNameWithoutExtension) + .font(.title3.weight(.semibold)) + .lineLimit(2) + + HStack(spacing: 8) { + if let megapixelsText { + detailBadge(megapixelsText) + } + + if let resolutionText { + detailBadge(resolutionText) + } + + detailBadge(utilityFileSystem.transformedSize(metadata.size)) + + if !metadata.fileExtension.isEmpty { + detailBadge(metadata.fileExtension.uppercased()) + } + + if metadata.isLivePhoto { + Image(systemName: "livephoto") + .font(.subheadline) + .foregroundStyle(.secondary) + } + } + } + } + + private var cameraSection: some View { + VStack(alignment: .leading, spacing: 4) { + Text(cameraText) + .font(.headline) + + Text(lensText) + .font(.subheadline) + .foregroundStyle(.secondary) + } + } + + @ViewBuilder + private var lensSection: some View { + let values = lensValues + + if !values.isEmpty { + LazyVGrid( + columns: [ + GridItem(.adaptive(minimum: 90), spacing: 8) + ], + alignment: .leading, + spacing: 8 + ) { + ForEach(values, id: \.self) { value in + detailBadge(value) + } + } + } + } + + @ViewBuilder + private var exposureSection: some View { + let values = exposureValues + + if !values.isEmpty { + VStack(alignment: .leading, spacing: 8) { + Text("EXIF") + .font(.headline) + + LazyVGrid( + columns: [ + GridItem(.adaptive(minimum: 90), spacing: 8) + ], + alignment: .leading, + spacing: 8 + ) { + ForEach(values, id: \.self) { value in + detailBadge(value) + } + } + } + } + } + + @ViewBuilder + private var locationSection: some View { + if let latitude = exif.latitude, + let longitude = exif.longitude, + NCNetworking.shared.isOnline { + let coordinate = CLLocationCoordinate2D( + latitude: latitude, + longitude: longitude + ) + + VStack(alignment: .leading, spacing: 10) { + if let location = exif.location, !location.isEmpty { + Button { + openMaps( + coordinate: coordinate, + name: location + ) + } label: { + HStack(spacing: 8) { + Image(systemName: "mappin.and.ellipse") + Text(location) + .lineLimit(2) + } + } + .buttonStyle(.plain) + .foregroundStyle(.primary) + } + + Map( + initialPosition: .region( + MKCoordinateRegion( + center: coordinate, + latitudinalMeters: 500, + longitudinalMeters: 500 + ) + ) + ) { + Marker("", coordinate: coordinate) + } + .frame(height: 180) + .clipShape(RoundedRectangle(cornerRadius: 16)) + .allowsHitTesting(false) + } + } else if let location = exif.location, !location.isEmpty { + HStack(spacing: 8) { + Image(systemName: "mappin.and.ellipse") + Text(location) + } + .font(.subheadline) + .foregroundStyle(.secondary) + } + } + + // MARK: - Small Views + + private func detailBadge(_ text: String) -> some View { + Text(text) + .font(.footnote) + .foregroundStyle(.primary) + .padding(.horizontal, 10) + .padding(.vertical, 6) + .background(.secondary.opacity(0.12)) + .clipShape(Capsule()) + } + + // MARK: - Computed Values + + private var fileNameWithoutExtension: String { + (metadata.fileNameView as NSString).deletingPathExtension + } + + private var cameraText: String { + guard let make = exif.make, + let model = exif.model else { + return NSLocalizedString("_no_camera_information_", comment: "") + } + + return "\(make) \(model)" + } + + private var lensText: String { + guard let make = exif.make, + let model = exif.model, + let lensModel = exif.lensModel else { + return NSLocalizedString("_no_lens_information_", comment: "") + } + + return lensModel + .replacingOccurrences(of: make, with: "") + .replacingOccurrences(of: model, with: "") + .replacingOccurrences(of: "f/", with: "ƒ") + .trimmingCharacters(in: .whitespacesAndNewlines) + .firstUppercased + } + + private var resolutionText: String? { + guard let width = exif.width, + let height = exif.height else { + return nil + } + + return "\(width) x \(height)" + } + + private var megapixelsText: String? { + guard let width = exif.width, + let height = exif.height else { + return nil + } + + let megapixels = Double(width * height) / 1_000_000 + + return megapixels < 1 + ? String(format: "%.1f MP", megapixels) + : "\(Int(megapixels)) MP" + } + + private var lensValues: [String] { + var values: [String] = [] + + if let lensLength = exif.lensLength { + values.append("\(lensLength) mm") + } + + if let apertureValue = exif.apertureValue { + values.append("ƒ\(apertureValue)") + } + + return values + } + + private var exposureValues: [String] { + var values: [String] = [] + + if let shutterSpeedApex = exif.shutterSpeedApex { + values.append("1/\(Int(pow(2, shutterSpeedApex))) s") + } + + if let iso = exif.iso { + values.append("ISO \(iso)") + } + + if let exposureValue = exif.exposureValue { + values.append("\(exposureValue) ev") + } + + return values + } + + // MARK: - Formatters + + private func dayString(from date: Date) -> String { + let formatter = DateFormatter() + formatter.dateFormat = "EEEE" + return formatter.string(from: date) + } + + private func dateString(from date: Date) -> String { + let formatter = DateFormatter() + formatter.dateFormat = "d MMM yyyy" + return formatter.string(from: date) + } + + private func timeString(from date: Date) -> String { + let formatter = DateFormatter() + formatter.dateFormat = "HH:mm" + return formatter.string(from: date) + } + + // MARK: - Actions + + private func openMaps( + coordinate: CLLocationCoordinate2D, + name: String? + ) { + let placemark = MKPlacemark( + coordinate: coordinate, + addressDictionary: nil + ) + + let mapItem = MKMapItem(placemark: placemark) + mapItem.name = name + mapItem.openInMaps() + } +} diff --git a/iOSClient/Viewer/NCViewerMedia/Views/NCMediaViewerPageView.swift b/iOSClient/Viewer/NCViewerMedia/Views/NCMediaViewerPageView.swift new file mode 100644 index 0000000000..b2a123666d --- /dev/null +++ b/iOSClient/Viewer/NCViewerMedia/Views/NCMediaViewerPageView.swift @@ -0,0 +1,500 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Marino Faggiana +// SPDX-License-Identifier: GPL-3.0-or-later + +import SwiftUI +import NextcloudKit + +// MARK: - Media Viewer Page View + +/// Renders a single media viewer page. +/// +/// This view is pure rendering logic. +/// It does not load metadata, check local files, read Realm, or start downloads. +struct NCMediaViewerPageView: View { + + // MARK: - Rendered Kind + + private enum NCMediaViewerRenderedKind { + case image + case video + case audio + } + + // MARK: - Properties + + let page: NCMediaViewerPageModel + let isChromeHidden: Bool + let onToggleChrome: () -> Void + let isSelected: Bool + + let canGoPrevious: Bool + let canGoNext: Bool + let shouldAutoPlay: Bool + let onPreviousPage: (_ shouldAutoPlay: Bool) -> Void + let onNextPage: (_ shouldAutoPlay: Bool) -> Void + let onClose: (_ ocId: String?) -> Void + let onAutoPlayConsumed: () -> Void + + let contextMenuController: NCMainTabBarController? + let navigationBar: UINavigationBar? + + // MARK: - Body + + var body: some View { + ZStack { + Color.ncViewerBackground(backgroundStyle) + .ignoresSafeArea() + + switch page.state { + case .idle, + .loadingMetadata, + .checkingLocalFile: + Color.ncViewerBackground(backgroundStyle) + .ignoresSafeArea() + + case .metadataMissing: + metadataMissingView + + case .image(let previewURL, let localURL, let livePhotoURL, _): + imageStateView( + previewURL: previewURL, + localURL: localURL, + livePhotoURL: livePhotoURL + ) + + case .video(let previewURL): + videoStateView(previewURL: previewURL) + + case .downloading(let previewURL, let progress): + downloadingStateView( + previewURL: previewURL, + progress: progress + ) + + case .ready(let localURL, let previewURL): + readyStateView( + localURL: localURL, + previewURL: previewURL + ) + + case .deleted: + deletedView + .frame(maxWidth: .infinity, maxHeight: .infinity) + .contentShape(Rectangle()) + .gesture(chromeToggleGesture()) + + case .failed(let previewURL, let message): + failedStateView( + previewURL: previewURL, + message: message + ) + } + } + .background(Color.ncViewerBackground(backgroundStyle)) + .ignoresSafeArea() + } + + // MARK: - Computed Properties + + private var backgroundStyle: NCViewerBackgroundStyle { + if isChromeHidden { + return .black + } + + guard let metadata = page.metadata else { + return .system + } + + switch metadata.classFile { + case NKTypeClassFile.audio.rawValue, + NKTypeClassFile.video.rawValue: + return .black + + default: + return ncViewerBackgroundStyle(for: metadata) + } + } + + /// Returns whether this page should consume an auto-play request. + /// + /// Auto-play is valid only for the currently selected page. + /// Neighbor pages can be prefetched and rendered, but they must not start playback + /// or consume a pending auto-play request. + private var effectiveShouldAutoPlay: Bool { + isSelected && shouldAutoPlay + } + + /// Moves to the previous page using the coordinator callback. + /// + /// - Parameter requestedAutoPlay: Whether the hosted content requests auto-play on the target page. + private func goToPreviousPage(_ requestedAutoPlay: Bool) { + guard canGoPrevious else { + return + } + + onPreviousPage( + isSelected && requestedAutoPlay + ) + } + + /// Moves to the next page using the coordinator callback. + /// + /// - Parameter requestedAutoPlay: Whether the hosted content requests auto-play on the target page. + private func goToNextPage(_ requestedAutoPlay: Bool) { + guard canGoNext else { + return + } + + onNextPage( + isSelected && requestedAutoPlay + ) + } + + /// Consumes the pending auto-play request only when this page is selected. + private func consumeAutoPlayIfNeeded() { + guard isSelected else { + return + } + + onAutoPlayConsumed() + } + + /// Moves to the previous page from video-specific controls or VLC swipe. + /// + /// Boundary validation is delegated to the paging coordinator so callbacks coming + /// from the UIKit-only VLC controller do not depend on potentially stale SwiftUI + /// `canGoPrevious` values captured when VLC was presented. + private func goToPreviousPageFromVideo() { + onPreviousPage(false) + } + + /// Moves to the next page from video-specific controls or VLC swipe. + /// + /// Boundary validation is delegated to the paging coordinator so callbacks coming + /// from the UIKit-only VLC controller do not depend on potentially stale SwiftUI + /// `canGoNext` values captured when VLC was presented. + private func goToNextPageFromVideo() { + onNextPage(false) + } + + // MARK: - State Views + + private var metadataMissingView: some View { + VStack(spacing: 12) { + Image(systemName: "photo.badge.exclamationmark") + .font(.system(size: 44, weight: .regular)) + + Text("Media not available") + .font(.headline) + } + .foregroundStyle(primaryForegroundStyle) + .multilineTextAlignment(.center) + .padding() + } + + private var deletedView: some View { + VStack(spacing: 12) { + Image(systemName: "trash") + .font(.system(size: 44, weight: .regular)) + + Text("Media no longer available") + .font(.headline) + + Text("This item has been deleted.") + .font(.caption) + .foregroundStyle(secondaryForegroundStyle) + } + .foregroundStyle(primaryForegroundStyle) + .multilineTextAlignment(.center) + .padding(24) + } + + @ViewBuilder + private func imageStateView( + previewURL: URL?, + localURL: URL?, + livePhotoURL: URL? + ) -> some View { + if previewURL != nil || localURL != nil { + imageContentView( + previewURL: previewURL, + localURL: localURL, + livePhotoURL: livePhotoURL, + backgroundStyle: backgroundStyle + ) + } else { + Color.ncViewerBackground(backgroundStyle) + .ignoresSafeArea() + } + } + + @ViewBuilder + private func videoStateView(previewURL: URL?) -> some View { + if let metadata = page.metadata { + NCVideoViewerContentView( + metadata: metadata, + localURL: nil, + previewURL: previewURL, + isSelected: isSelected, + contextMenuController: contextMenuController, + navigationBar: navigationBar, + canGoPrevious: canGoPrevious, + canGoNext: canGoNext, + onPreviousPage: goToPreviousPageFromVideo, + onNextPage: goToNextPageFromVideo, + onClose: onClose + ) + .id("\(page.ocId)-remote") + .background(Color.ncViewerBackground(backgroundStyle)) + } else { + metadataMissingView + } + } + + @ViewBuilder + private func downloadingStateView( + previewURL: URL?, + progress: Double? + ) -> some View { + if page.metadata?.classFile == NKTypeClassFile.video.rawValue, + isSelected { + videoStateView(previewURL: previewURL) + } else if let previewURL { + previewOnlyView(previewURL: previewURL) + } else { + Color.ncViewerBackground(backgroundStyle) + .ignoresSafeArea() + } + } + + @ViewBuilder + private func readyStateView( + localURL: URL, + previewURL: URL? + ) -> some View { + if let metadata = page.metadata { + switch mediaKind(for: metadata) { + case .image: + imageContentView( + previewURL: previewURL, + localURL: localURL, + livePhotoURL: nil, + backgroundStyle: backgroundStyle + ) + + case .video: + NCVideoViewerContentView( + metadata: metadata, + localURL: localURL, + previewURL: previewURL, + isSelected: isSelected, + contextMenuController: contextMenuController, + navigationBar: navigationBar, + canGoPrevious: canGoPrevious, + canGoNext: canGoNext, + onPreviousPage: goToPreviousPageFromVideo, + onNextPage: goToNextPageFromVideo, + onClose: onClose + ) + .id("\(page.ocId)-local-\(localURL.absoluteString)") + .background(Color.ncViewerBackground(backgroundStyle)) + + case .audio: + NCAudioViewerContentView( + metadata: metadata, + localURL: localURL, + canGoPrevious: canGoPrevious, + canGoNext: canGoNext, + shouldAutoPlay: effectiveShouldAutoPlay, + onPrevious: goToPreviousPage, + onNext: goToNextPage, + onAutoPlayConsumed: consumeAutoPlayIfNeeded + ) + .background(Color.black) + } + } else { + metadataMissingView + } + } + + @ViewBuilder + private func failedStateView( + previewURL: URL?, + message: String + ) -> some View { + ZStack { + if let previewURL { + previewOnlyView(previewURL: previewURL) + } else { + Color.ncViewerBackground(backgroundStyle) + .ignoresSafeArea() + } + + failedOverlay( + fileName: displayFileName(from: page.metadata), + message: message + ) + } + } + + @ViewBuilder + private func imageContentView( + previewURL: URL?, + localURL: URL?, + livePhotoURL: URL?, + backgroundStyle: NCViewerBackgroundStyle + ) -> some View { + if page.metadata?.isLivePhoto == true { + NCLivePhotoViewerContentView( + identifier: page.ocId, + previewURL: previewURL, + fullURL: localURL, + videoURL: livePhotoURL, + backgroundStyle: backgroundStyle, + topOverlayInset: livePhotoTopOverlayInset + ) + .background(Color.ncViewerBackground(backgroundStyle)) + .contentShape(Rectangle()) + .gesture(chromeToggleGesture()) + } else { + NCImageViewerContentView( + identifier: page.ocId, + previewURL: previewURL, + fullURL: localURL, + backgroundStyle: backgroundStyle + ) + .contentShape(Rectangle()) + .gesture(chromeToggleGesture()) + } + } + + @ViewBuilder + private func previewOnlyView(previewURL: URL) -> some View { + NCImageViewerContentView( + identifier: page.ocId, + previewURL: previewURL, + fullURL: nil, + backgroundStyle: backgroundStyle + ) + .contentShape(Rectangle()) + .gesture(chromeToggleGesture()) + } + + private func failedOverlay(fileName: String?, message: String) -> some View { + VStack(spacing: 12) { + Image(systemName: "icloud.slash") + .font(.system(size: 44, weight: .regular)) + + Text("Download failed") + .font(.headline) + + if let fileName, !fileName.isEmpty { + Text(fileName) + .font(.footnote) + .foregroundStyle(.white.opacity(0.65)) + .lineLimit(1) + .truncationMode(.middle) + } + + if !message.isEmpty { + Text(message) + .font(.caption) + .foregroundStyle(.white.opacity(0.55)) + .multilineTextAlignment(.center) + } + } + .foregroundStyle(.white) + .multilineTextAlignment(.center) + .padding(16) + .background(.black.opacity(0.45)) + .clipShape(RoundedRectangle(cornerRadius: 16)) + .padding() + } + + /// Returns the tap gesture used to toggle the viewer chrome. + /// + /// Double tap is ignored here so image zoom can keep using it. + private func chromeToggleGesture() -> some Gesture { + TapGesture(count: 2) + .exclusively( + before: TapGesture(count: 1) + ) + .onEnded { value in + switch value { + case .first: + break + + case .second: + onToggleChrome() + } + } + } + + // MARK: - Appearance Helpers + + private var primaryForegroundStyle: Color { + switch backgroundStyle { + case .black: + return .white.opacity(0.85) + + case .system, + .white, + .custom: + return .primary + } + } + + private var secondaryForegroundStyle: Color { + switch backgroundStyle { + case .black: + return .white.opacity(0.85) + + case .system, + .white, + .custom: + return .secondary + } + } + + // MARK: - Helpers + + private var livePhotoTopOverlayInset: CGFloat { + let windowScene = UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + .first { $0.activationState == .foregroundActive } + + let window = windowScene?.windows.first { $0.isKeyWindow } + let safeTop = window?.safeAreaInsets.top ?? 0 + + return safeTop + 44 + 8 + } + + private func displayFileName(from metadata: tableMetadata?) -> String? { + guard let metadata else { + return nil + } + + if !metadata.fileNameView.isEmpty { + return metadata.fileNameView + } + + return metadata.fileName + } + + private func mediaKind(for metadata: tableMetadata) -> NCMediaViewerRenderedKind { + switch metadata.classFile { + case NKTypeClassFile.image.rawValue: + return .image + + case NKTypeClassFile.video.rawValue: + return .video + + case NKTypeClassFile.audio.rawValue: + return .audio + + default: + return .image + } + } +} diff --git a/iOSClient/Viewer/NCViewerMedia/Views/NCMediaViewerPagingView.swift b/iOSClient/Viewer/NCViewerMedia/Views/NCMediaViewerPagingView.swift new file mode 100644 index 0000000000..01c38a0d57 --- /dev/null +++ b/iOSClient/Viewer/NCViewerMedia/Views/NCMediaViewerPagingView.swift @@ -0,0 +1,853 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Marino Faggiana +// SPDX-License-Identifier: GPL-3.0-or-later + +import SwiftUI +import UIKit +import Combine +import NextcloudKit + +// MARK: - Media Viewer Paging View + +/// UIKit-backed horizontal paging view for the media viewer. +/// +/// This replaces SwiftUI `TabView(.page)` because `TabView` is not suitable for +/// very large virtualized media lists and can flicker when its page array changes. +/// +/// The paging view uses a `UICollectionView` with reusable cells. +/// Each cell hosts a SwiftUI `NCMediaViewerPageView`. +struct NCMediaViewerPagingView: UIViewRepresentable { + @ObservedObject var model: NCMediaViewerModel + let contextMenuController: NCMainTabBarController? + let navigationBar: UINavigationBar? + let onVisibleMetadataChanged: (_ metadata: tableMetadata?, _ backgroundColor: UIColor) -> Void + let onClose: (_ ocId: String?) -> Void + + // MARK: - UIViewRepresentable + + func makeUIView(context: Context) -> NCMediaViewerCollectionView { + let layout = UICollectionViewFlowLayout() + layout.scrollDirection = .horizontal + layout.minimumLineSpacing = 0 + layout.minimumInteritemSpacing = 0 + + let collectionView = NCMediaViewerCollectionView( + frame: .zero, + collectionViewLayout: layout + ) + + collectionView.backgroundColor = .black + collectionView.isPagingEnabled = true + collectionView.showsHorizontalScrollIndicator = false + collectionView.showsVerticalScrollIndicator = false + collectionView.alwaysBounceHorizontal = model.numberOfPages > 1 + collectionView.alwaysBounceVertical = false + collectionView.isScrollEnabled = model.numberOfPages > 1 + collectionView.contentInsetAdjustmentBehavior = .never + collectionView.dataSource = context.coordinator + collectionView.delegate = context.coordinator + + collectionView.register( + NCMediaViewerPagingCell.self, + forCellWithReuseIdentifier: NCMediaViewerPagingCell.reuseIdentifier + ) + + context.coordinator.collectionView = collectionView + + collectionView.onLayoutSubviews = { [weak coordinator = context.coordinator] in + coordinator?.updateLayoutAfterBoundsChangeIfNeeded() + } + + DispatchQueue.main.async { + context.coordinator.scrollToInitialIndexIfNeeded(animated: false) + context.coordinator.updateCollectionBackground() + context.coordinator.updateVisibleMetadataTitleForCurrentPage() + } + + return collectionView + } + + func updateUIView( + _ collectionView: NCMediaViewerCollectionView, + context: Context + ) { + context.coordinator.model = model + context.coordinator.navigationBar = navigationBar + context.coordinator.onVisibleMetadataChanged = onVisibleMetadataChanged + context.coordinator.onClose = onClose + context.coordinator.updateCollectionBackground() + + collectionView.isScrollEnabled = model.numberOfPages > 1 + collectionView.alwaysBounceHorizontal = model.numberOfPages > 1 + + if let layout = collectionView.collectionViewLayout as? UICollectionViewFlowLayout { + let itemSize = collectionView.bounds.size + + if itemSize.width > 0, + itemSize.height > 0, + layout.itemSize != itemSize { + context.coordinator.relayoutAndKeepCurrentIndex(size: itemSize) + } + } + + context.coordinator.refreshVisibleCells() + } + + func makeCoordinator() -> NCMediaViewerPagingCoordinator { + NCMediaViewerPagingCoordinator( + model: model, + contextMenuController: contextMenuController, + navigationBar: navigationBar, + onVisibleMetadataChanged: onVisibleMetadataChanged, + onClose: onClose + ) + } +} + +// MARK: - Media Viewer Collection View + +/// Collection view subclass used to detect bounds changes reliably. +/// +/// This is needed because rotation, iPad split view resizing, and floating window +/// resizing can change the collection view bounds without SwiftUI immediately +/// rebuilding the representable. +final class NCMediaViewerCollectionView: UICollectionView { + var onLayoutSubviews: (() -> Void)? + + override func layoutSubviews() { + super.layoutSubviews() + onLayoutSubviews?() + } +} + +// MARK: - Media Viewer Paging Coordinator + +/// Coordinator for the UIKit paging collection view. +/// +/// It acts as: +/// - collection view data source +/// - collection view delegate flow layout +@MainActor +final class NCMediaViewerPagingCoordinator: NSObject, + UICollectionViewDataSource, + UICollectionViewDelegateFlowLayout { + var model: NCMediaViewerModel + weak var collectionView: UICollectionView? + let contextMenuController: NCMainTabBarController? + weak var navigationBar: UINavigationBar? + var onVisibleMetadataChanged: (_ metadata: tableMetadata?, _ backgroundColor: UIColor) -> Void + var onClose: (_ ocId: String?) -> Void + + private var didScrollToInitialIndex = false + private var lastCollectionViewBoundsSize: CGSize = .zero + private var cancellable: AnyCancellable? + private var lastVisibleIndex: Int? + private var isUserPaging = false + private var isAdjustingLayout = false + + // MARK: - Init + + init( + model: NCMediaViewerModel, + contextMenuController: NCMainTabBarController?, + navigationBar: UINavigationBar?, + onVisibleMetadataChanged: @escaping (_ metadata: tableMetadata?, _ backgroundColor: UIColor) -> Void, + onClose: @escaping (_ ocId: String?) -> Void + ) { + self.model = model + self.contextMenuController = contextMenuController + self.navigationBar = navigationBar + self.onVisibleMetadataChanged = onVisibleMetadataChanged + self.onClose = onClose + + super.init() + + self.cancellable = model.$revision + .receive(on: RunLoop.main) + .sink { [weak self] _ in + self?.refreshVisibleCells() + self?.updateCollectionBackground() + self?.updateVisibleMetadataTitleForCurrentPage() + } + } + + // MARK: - Layout + + /// Updates the paging layout after bounds changes. + /// + /// This keeps the selected page centered after rotation, split view resizing, + /// or iPad floating window resizing. + func updateLayoutAfterBoundsChangeIfNeeded() { + guard let collectionView else { + return + } + + let boundsSize = collectionView.bounds.size + + guard boundsSize.width > 0, + boundsSize.height > 0 else { + return + } + + guard boundsSize != lastCollectionViewBoundsSize else { + return + } + + relayoutAndKeepCurrentIndex(size: boundsSize) + } + + /// Invalidates the paging layout while preserving the current selected page. + /// + /// During bounds changes, the collection view content offset can temporarily be + /// expressed using the old page width. This method prevents those intermediate + /// offsets from being interpreted as real page changes. + /// + /// - Parameter size: New page size to apply to the flow layout. + func relayoutAndKeepCurrentIndex(size: CGSize) { + guard let collectionView else { + return + } + + guard size.width > 0, + size.height > 0 else { + return + } + + lastCollectionViewBoundsSize = size + isAdjustingLayout = true + + let index = model.selectedIndex + + if let layout = collectionView.collectionViewLayout as? UICollectionViewFlowLayout { + layout.itemSize = size + layout.invalidateLayout() + } + + collectionView.performBatchUpdates(nil) { [weak self] _ in + guard let self else { + return + } + + self.scrollToIndex( + index, + animated: false + ) + + DispatchQueue.main.async { [weak self] in + self?.isAdjustingLayout = false + } + } + } + + // MARK: - Background + + /// Returns the UIKit background color for the given page. + /// + /// Audio and video use black because their player surfaces are dark. + /// Images use the viewer background style unless chrome is hidden. + private func backgroundColor(for page: NCMediaViewerPageModel?) -> UIColor { + guard !model.isChromeHidden else { + return .black + } + + guard let metadata = page?.metadata else { + return UIColor.ncViewerBackground(.system) + } + + switch metadata.classFile { + case NKTypeClassFile.audio.rawValue, + NKTypeClassFile.video.rawValue: + return .black + + default: + return UIColor.ncViewerBackground( + ncViewerBackgroundStyle(for: metadata) + ) + } + } + + /// Applies the current page background to the collection view. + func updateCollectionBackground(for index: Int? = nil) { + let pageIndex = index ?? model.selectedIndex + let page = model.pageModel(at: pageIndex) + let color = backgroundColor(for: page) + + collectionView?.backgroundColor = color + } + + /// Sends the metadata of the currently selected page to the hosting controller title view. + func updateVisibleMetadataTitleForCurrentPage() { + updateVisibleMetadataTitle(for: model.selectedIndex) + } + + /// Sends the metadata of the currently visible page to the hosting controller title view. + /// + /// - Parameter index: Page index currently closest to the collection view center. + private func updateVisibleMetadataTitle(for index: Int) { + guard index >= 0, + index < model.numberOfPages else { + return + } + + let page = model.pageModel(at: index) + + onVisibleMetadataChanged( + page?.metadata, + backgroundColor(for: page) + ) + } + + // MARK: - Initial Scroll + + /// Scrolls to the initial selected page once. + /// + /// - Parameter animated: Whether the scroll should be animated. + func scrollToInitialIndexIfNeeded(animated: Bool) { + guard !didScrollToInitialIndex else { + return + } + + guard model.numberOfPages > 0 else { + return + } + + guard let collectionView else { + return + } + + collectionView.layoutIfNeeded() + + let index = model.initialSelectedIndex + + guard index >= 0, + index < model.numberOfPages else { + return + } + + collectionView.scrollToItem( + at: IndexPath(item: index, section: 0), + at: .centeredHorizontally, + animated: animated + ) + + didScrollToInitialIndex = true + lastVisibleIndex = index + updateCollectionBackground(for: index) + updateVisibleMetadataTitle(for: index) + refreshVisibleCells() + } + + /// Scrolls to the current selected index. + /// + /// This is used after layout size changes, for example after rotation or + /// iPad window resizing. + /// + /// - Parameter animated: Whether the scroll should be animated. + func scrollToCurrentIndex(animated: Bool) { + scrollToIndex( + model.selectedIndex, + animated: animated + ) + } + + /// Scrolls to a specific page index without changing the selected model index. + /// + /// - Parameters: + /// - index: Page index to center. + /// - animated: Whether the scroll should be animated. + private func scrollToIndex( + _ index: Int, + animated: Bool + ) { + guard model.numberOfPages > 0 else { + return + } + + guard let collectionView else { + return + } + + collectionView.layoutIfNeeded() + + guard index >= 0, + index < model.numberOfPages else { + return + } + + collectionView.scrollToItem( + at: IndexPath(item: index, section: 0), + at: .centeredHorizontally, + animated: animated + ) + + lastVisibleIndex = index + updateCollectionBackground(for: index) + updateVisibleMetadataTitle(for: index) + refreshVisibleCells() + } + + // MARK: - Visible Cell Refresh + + /// Refreshes currently visible cells using the latest page models and selected index. + func refreshVisibleCells() { + guard let collectionView else { + return + } + + for cell in collectionView.visibleCells { + guard let cell = cell as? NCMediaViewerPagingCell, + let indexPath = collectionView.indexPath(for: cell), + let page = model.pageModel(at: indexPath.item) else { + continue + } + + configure( + cell: cell, + page: page + ) + } + } + + // MARK: - Page Navigation + + /// Moves to the previous or next page using the paging collection view. + /// + /// The target page becomes selected only after the scrolling animation finishes. + /// This keeps programmatic navigation consistent with manual swipe navigation. + /// + /// - Parameters: + /// - offset: Relative page offset. Use `-1` for previous and `1` for next. + /// - shouldAutoPlay: Whether the target page should autoplay after selection. + private func moveToPage( + offset: Int, + shouldAutoPlay: Bool + ) { + let targetIndex = model.selectedIndex + offset + + guard targetIndex >= 0, + targetIndex < model.numberOfPages else { + return + } + + guard let collectionView else { + return + } + + NotificationCenter.default.post( + name: .ncMediaViewerStopPlayback, + object: nil + ) + + if shouldAutoPlay { + model.requestAutoPlay(at: targetIndex) + } + + isUserPaging = true + lastVisibleIndex = targetIndex + + updateCollectionBackground(for: targetIndex) + updateVisibleMetadataTitle(for: targetIndex) + refreshVisibleCells() + + collectionView.scrollToItem( + at: IndexPath(item: targetIndex, section: 0), + at: .centeredHorizontally, + animated: true + ) + } + + /// Configures a paging cell with all callbacks required by the hosted SwiftUI page. + /// + /// - Parameters: + /// - cell: Cell to configure. + /// - page: Page model to render. + private func configure( + cell: NCMediaViewerPagingCell, + page: NCMediaViewerPageModel + ) { + let pageBackgroundColor = backgroundColor(for: page) + + cell.configure( + 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() + }, + onPreviousPage: { [weak self] shouldAutoPlay in + self?.moveToPage( + offset: -1, + shouldAutoPlay: shouldAutoPlay + ) + }, + onNextPage: { [weak self] shouldAutoPlay in + self?.moveToPage( + offset: 1, + shouldAutoPlay: shouldAutoPlay + ) + }, + onClose: { [weak self] ocId in + self?.onClose(ocId) + }, + onAutoPlayConsumed: { [weak model] in + model?.clearAutoPlayIfNeeded(for: page.index) + }, + contextMenuController: contextMenuController, + navigationBar: navigationBar + ) + } + + // MARK: - UICollectionViewDataSource + + func collectionView( + _ collectionView: UICollectionView, + numberOfItemsInSection section: Int + ) -> Int { + model.numberOfPages + } + + func collectionView( + _ collectionView: UICollectionView, + cellForItemAt indexPath: IndexPath + ) -> UICollectionViewCell { + let cell = collectionView.dequeueReusableCell( + withReuseIdentifier: NCMediaViewerPagingCell.reuseIdentifier, + for: indexPath + ) + + guard let pagingCell = cell as? NCMediaViewerPagingCell else { + return cell + } + + if let page = model.pageModel(at: indexPath.item) { + configure( + cell: pagingCell, + page: page + ) + } else { + pagingCell.configureEmpty( + backgroundColor: backgroundColor(for: nil) + ) + } + + return pagingCell + } + + // MARK: - UICollectionViewDelegateFlowLayout + + func collectionView( + _ collectionView: UICollectionView, + layout collectionViewLayout: UICollectionViewLayout, + sizeForItemAt indexPath: IndexPath + ) -> CGSize { + collectionView.bounds.size + } + + // MARK: - UIScrollViewDelegate + + func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { + isUserPaging = true + + NotificationCenter.default.post( + name: .ncMediaViewerStopPlayback, + object: nil + ) + + refreshVisibleCells() + } + + func scrollViewWillEndDragging( + _ scrollView: UIScrollView, + withVelocity velocity: CGPoint, + targetContentOffset: UnsafeMutablePointer + ) { + guard !isAdjustingLayout else { + return + } + + guard let index = pageIndex( + forContentOffsetX: targetContentOffset.pointee.x, + width: scrollView.bounds.width + ) else { + return + } + + guard lastVisibleIndex != index else { + return + } + + lastVisibleIndex = index + model.setSelectedIndex(index) + updateCollectionBackground(for: index) + updateVisibleMetadataTitle(for: index) + refreshVisibleCells() + } + + /// Returns the nearest page index for the current horizontal scroll position. + /// + /// - Parameter scrollView: Source scroll view. + /// - Returns: Rounded page index if it is inside the media range. + private func pageIndex(for scrollView: UIScrollView) -> Int? { + pageIndex( + forContentOffsetX: scrollView.contentOffset.x, + width: scrollView.bounds.width + ) + } + + /// Returns the nearest page index for the provided horizontal content offset. + /// + /// This is used to predict the final page before deceleration finishes. + /// + /// - Parameters: + /// - contentOffsetX: Horizontal content offset to evaluate. + /// - width: Current page width. + /// - Returns: Rounded page index if it is inside the media range. + private func pageIndex( + forContentOffsetX contentOffsetX: CGFloat, + width: CGFloat + ) -> Int? { + guard width > 0 else { + return nil + } + + let rawIndex = contentOffsetX / width + let index = Int(round(rawIndex)) + + guard index >= 0, + index < model.numberOfPages else { + return nil + } + + return index + } + + func scrollViewDidScroll(_ scrollView: UIScrollView) { + guard !isAdjustingLayout else { + return + } + + guard let index = pageIndex(for: scrollView) else { + return + } + + guard lastVisibleIndex != index else { + return + } + + lastVisibleIndex = index + model.setSelectedIndex(index) + updateCollectionBackground(for: index) + updateVisibleMetadataTitle(for: index) + refreshVisibleCells() + + Task { + await model.prefetchVisiblePageIfNeeded(index: index) + } + } + + func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { + updateSelectedIndexFromScrollView(scrollView) + } + + func scrollViewDidEndScrollingAnimation(_ scrollView: UIScrollView) { + updateSelectedIndexFromScrollView(scrollView) + } + + func scrollViewDidEndDragging( + _ scrollView: UIScrollView, + willDecelerate decelerate: Bool + ) { + if !decelerate { + updateSelectedIndexFromScrollView(scrollView) + } + } + + /// Updates the selected page index after paging has settled. + /// + /// This is the only place where a finished swipe becomes the real selected page. + /// During dragging, visible pages are tracked for background updates, but they are not considered selected. + /// + /// - Parameter scrollView: Source scroll view. + private func updateSelectedIndexFromScrollView(_ scrollView: UIScrollView) { + guard !isAdjustingLayout else { + return + } + + guard let index = pageIndex(for: scrollView) else { + return + } + + isUserPaging = false + lastVisibleIndex = index + + model.setSelectedIndex(index) + updateCollectionBackground(for: index) + updateVisibleMetadataTitle(for: index) + refreshVisibleCells() + + Task { + await model.displayPage(at: index) + } + } +} + +// MARK: - Media Viewer Paging Cell + +/// Collection view cell hosting one SwiftUI media viewer page. +final class NCMediaViewerPagingCell: UICollectionViewCell { + static let reuseIdentifier = "NCMediaViewerPagingCell" + + private var currentOcId: String? + private var hostingController: UIHostingController? + + // MARK: - Init + + override init(frame: CGRect) { + super.init(frame: frame) + + backgroundColor = .black + contentView.backgroundColor = .black + contentView.clipsToBounds = true + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + + backgroundColor = .black + contentView.backgroundColor = .black + contentView.clipsToBounds = true + } + + override func prepareForReuse() { + super.prepareForReuse() + + currentOcId = nil + + hostingController?.view.removeFromSuperview() + hostingController = nil + + backgroundColor = .black + contentView.backgroundColor = .black + } + + override func layoutSubviews() { + super.layoutSubviews() + + hostingController?.view.frame = contentView.bounds + } + + // MARK: - Configuration + + /// Configures the cell with a media viewer page. + /// + /// - Parameters: + /// - page: Page model to render. + /// - isSelected: Whether this cell represents the currently selected page. + /// - isChromeHidden: Whether viewer chrome is currently hidden. + /// - backgroundColor: Background color matching the currently rendered page. + /// - canGoPrevious: Whether the page can navigate to a previous item. + /// - canGoNext: Whether the page can navigate to a next item. + /// - shouldAutoPlay: Whether hosted audio content should start playback automatically. + /// - onToggleChrome: Callback used by image pages to show or hide chrome. + /// - onPreviousPage: Callback used by inline controls to move to previous page. + /// - onNextPage: Callback used by inline controls to move to next page. + /// - onClose: Callback used by fullscreen video controllers to close the media viewer with the current media ocId. + /// - onAutoPlayConsumed: Callback invoked after the hosted page consumes the auto-play request. + func configure( + 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, + onClose: @escaping (_ ocId: String?) -> Void, + onAutoPlayConsumed: @escaping () -> Void, + contextMenuController: NCMainTabBarController?, + navigationBar: UINavigationBar? + ) { + self.backgroundColor = backgroundColor + contentView.backgroundColor = backgroundColor + + let view = AnyView( + NCMediaViewerPageView( + page: page, + isChromeHidden: isChromeHidden, + onToggleChrome: onToggleChrome, + isSelected: isSelected, + canGoPrevious: canGoPrevious, + canGoNext: canGoNext, + shouldAutoPlay: shouldAutoPlay, + onPreviousPage: onPreviousPage, + onNextPage: onNextPage, + onClose: onClose, + onAutoPlayConsumed: onAutoPlayConsumed, + contextMenuController: contextMenuController, + navigationBar: navigationBar + ) + .id(page.ocId) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color(backgroundColor)) + .ignoresSafeArea() + ) + + if currentOcId != page.ocId { + hostingController?.view.removeFromSuperview() + hostingController = nil + currentOcId = page.ocId + } + + if let hostingController { + hostingController.rootView = view + hostingController.view.backgroundColor = backgroundColor + hostingController.view.frame = contentView.bounds + } else { + let hostingController = UIHostingController(rootView: view) + hostingController.view.backgroundColor = backgroundColor + hostingController.view.frame = contentView.bounds + hostingController.view.autoresizingMask = [ + .flexibleWidth, + .flexibleHeight + ] + + contentView.addSubview(hostingController.view) + self.hostingController = hostingController + } + } + + /// Configures the cell as an empty page. + /// + /// - Parameter backgroundColor: Background color to apply to the empty page. + func configureEmpty(backgroundColor: UIColor = .black) { + self.backgroundColor = backgroundColor + contentView.backgroundColor = backgroundColor + + currentOcId = nil + + hostingController?.view.removeFromSuperview() + hostingController = nil + + let view = AnyView( + Color(backgroundColor) + .ignoresSafeArea() + ) + + let hostingController = UIHostingController(rootView: view) + hostingController.view.backgroundColor = backgroundColor + hostingController.view.frame = contentView.bounds + hostingController.view.autoresizingMask = [ + .flexibleWidth, + .flexibleHeight + ] + + contentView.addSubview(hostingController.view) + self.hostingController = hostingController + } +} diff --git a/iOSClient/Viewer/NCViewerMedia/Views/NCViewerFloatingTitleView.swift b/iOSClient/Viewer/NCViewerMedia/Views/NCViewerFloatingTitleView.swift new file mode 100644 index 0000000000..ca731ec216 --- /dev/null +++ b/iOSClient/Viewer/NCViewerMedia/Views/NCViewerFloatingTitleView.swift @@ -0,0 +1,225 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Marino Faggiana +// SPDX-License-Identifier: GPL-3.0-or-later + +import UIKit + +/// Floating title view used by media viewer controllers. +/// +/// The view renders only primary and secondary text without any visual material, +/// background, glass, blur, or border decoration. +final class NCViewerFloatingTitleView: UIView { + private let primaryLabel = UILabel() + private let secondaryLabel = UILabel() + private let stackView = UIStackView() + private weak var navigationBar: UINavigationBar? + private var navigationBarConstraints: [NSLayoutConstraint] = [] + private var centerXConstraint: NSLayoutConstraint? + private var heightConstraint: NSLayoutConstraint? + + init() { + super.init(frame: .zero) + + configureView() + configureLabels() + configureStackView() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + /// Attaches the floating title view to the provided navigation bar. + /// + /// The title is installed as a navigation bar subview and can then align itself + /// against the real visible bar button containers. + /// + /// - Parameters: + /// - navigationBar: Navigation bar that owns the floating title view. + /// - widthMultiplier: Maximum title width relative to the navigation bar width. + /// - verticalOffset: Vertical adjustment applied to the navigation bar top edge. + func attach( + to navigationBar: UINavigationBar, + widthMultiplier: CGFloat = 0.36, + verticalOffset: CGFloat = 0 + ) { + if self.navigationBar !== navigationBar || superview !== navigationBar { + navigationBarConstraints.forEach { $0.isActive = false } + navigationBarConstraints.removeAll() + removeFromSuperview() + navigationBar.addSubview(self) + + let centerXConstraint = centerXAnchor.constraint(equalTo: navigationBar.centerXAnchor) + let heightConstraint = heightAnchor.constraint(equalToConstant: navigationItemHeight(in: navigationBar)) + self.centerXConstraint = centerXConstraint + self.heightConstraint = heightConstraint + + navigationBarConstraints = [ + centerXConstraint, + topAnchor.constraint(equalTo: navigationBar.topAnchor, constant: verticalOffset), + heightConstraint, + widthAnchor.constraint(lessThanOrEqualTo: navigationBar.widthAnchor, multiplier: widthMultiplier) + ] + NSLayoutConstraint.activate(navigationBarConstraints) + self.navigationBar = navigationBar + } + + navigationBar.bringSubviewToFront(self) + updateNavigationItemHeight() + updateHorizontalAlignment() + } + + /// Resets the horizontal title position to the navigation bar center. + func updateHorizontalAlignment() { + centerXConstraint?.constant = 0 + } + + /// Updates the title height using the visible navigation item height. + func updateNavigationItemHeight() { + guard let navigationBar else { + return + } + + heightConstraint?.constant = navigationItemHeight(in: navigationBar) + } + + /// Returns the best visible navigation item height for the provided navigation bar. + /// + /// - Parameter navigationBar: Navigation bar containing the title and bar button items. + /// - Returns: Height used by visible navigation items, falling back to `44` points. + private func navigationItemHeight(in navigationBar: UINavigationBar) -> CGFloat { + let heights = navigationBar.subviews.flatMap { subview in + navigationItemHeights( + from: subview, + in: navigationBar + ) + } + + return heights.max() ?? navigationBar.bounds.height + } + + /// Recursively collects visible navigation item heights from the navigation bar hierarchy. + /// + /// - Parameters: + /// - view: Current hierarchy node. + /// - navigationBar: Navigation bar used as coordinate target. + /// - Returns: Visible item heights in navigation bar coordinates. + private func navigationItemHeights( + from view: UIView, + in navigationBar: UINavigationBar + ) -> [CGFloat] { + guard view !== self, + !view.isDescendant(of: self), + !view.isHidden, + view.alpha > 0.01, + view.bounds.width > 0, + view.bounds.height > 0 else { + return [] + } + + let frame = view.convert(view.bounds, to: navigationBar) + let isVisibleNavigationFrame = frame.minY >= -1 && + frame.maxY <= navigationBar.bounds.height + 1 && + frame.height > 20 && + frame.width > 20 && + frame.width < navigationBar.bounds.width * 0.6 + + let childHeights = view.subviews.flatMap { subview in + navigationItemHeights( + from: subview, + in: navigationBar + ) + } + + if isVisibleNavigationFrame { + return childHeights + [frame.height] + } + + return childHeights + } + + /// Updates the visible title content. + /// + /// - Parameters: + /// - primaryText: Main title text displayed on the first line. + /// - secondaryText: Optional subtitle text displayed on the second line. + /// - textColor: Text color selected by the caller according to the current viewer background. + func update( + primaryText: String?, + secondaryText: String?, + textColor: UIColor + ) { + let normalizedPrimaryText = primaryText?.trimmingCharacters(in: .whitespacesAndNewlines) + let normalizedSecondaryText = secondaryText?.trimmingCharacters(in: .whitespacesAndNewlines) + + primaryLabel.text = normalizedPrimaryText + primaryLabel.textColor = textColor + secondaryLabel.text = normalizedSecondaryText + secondaryLabel.textColor = textColor.withAlphaComponent(0.82) + secondaryLabel.isHidden = normalizedSecondaryText?.isEmpty ?? true + isHidden = normalizedPrimaryText?.isEmpty ?? true + + accessibilityLabel = [normalizedPrimaryText, normalizedSecondaryText] + .compactMap { text in + guard let text, !text.isEmpty else { return nil } + return text + } + .joined(separator: ", ") + } + + /// Clears the visible title content. + func clear() { + update( + primaryText: nil, + secondaryText: nil, + textColor: .white + ) + } + + /// Configures the visual container. + private func configureView() { + translatesAutoresizingMaskIntoConstraints = false + backgroundColor = .clear + layoutMargins = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 0) + isAccessibilityElement = true + } + + /// Configures the primary and secondary labels. + private func configureLabels() { + primaryLabel.font = .preferredFont(forTextStyle: .subheadline) + primaryLabel.textColor = .white + primaryLabel.textAlignment = .center + primaryLabel.adjustsFontForContentSizeCategory = true + primaryLabel.lineBreakMode = .byTruncatingMiddle + primaryLabel.numberOfLines = 1 + + secondaryLabel.font = .preferredFont(forTextStyle: .caption2) + secondaryLabel.textColor = .white.withAlphaComponent(0.82) + secondaryLabel.textAlignment = .center + secondaryLabel.adjustsFontForContentSizeCategory = true + secondaryLabel.lineBreakMode = .byTruncatingTail + secondaryLabel.numberOfLines = 1 + } + + /// Configures the vertical label stack. + private func configureStackView() { + stackView.translatesAutoresizingMaskIntoConstraints = false + stackView.axis = .vertical + stackView.alignment = .center + stackView.distribution = .fill + stackView.spacing = 2 + + stackView.addArrangedSubview(primaryLabel) + stackView.addArrangedSubview(secondaryLabel) + addSubview(stackView) + + NSLayoutConstraint.activate([ + stackView.leadingAnchor.constraint(equalTo: layoutMarginsGuide.leadingAnchor), + stackView.trailingAnchor.constraint(equalTo: layoutMarginsGuide.trailingAnchor), + stackView.centerYAnchor.constraint(equalTo: centerYAnchor), + stackView.topAnchor.constraint(greaterThanOrEqualTo: layoutMarginsGuide.topAnchor), + stackView.bottomAnchor.constraint(lessThanOrEqualTo: layoutMarginsGuide.bottomAnchor) + ]) + } +} diff --git a/iOSClient/Viewer/NCViewerPDF/NCViewerPDF.swift b/iOSClient/Viewer/NCViewerPDF/NCViewerPDF.swift index 89e961ce8f..4c087cb51f 100644 --- a/iOSClient/Viewer/NCViewerPDF/NCViewerPDF.swift +++ b/iOSClient/Viewer/NCViewerPDF/NCViewerPDF.swift @@ -68,7 +68,11 @@ class NCViewerPDF: UIViewController, NCViewerPDFSearchDelegate { UIDeferredMenuElement.uncached { [self] completion in guard let metadata = self.metadata else { return } - if let menu = NCContextMenuViewer(metadata: metadata, controller: self.tabBarController as? NCMainTabBarController, webView: false, sender: self).viewMenu() { + if let menu = NCContextMenuViewer(metadata: metadata, + controller: self.tabBarController as? NCMainTabBarController, + viewController: self.tabBarController, + webView: false, + sender: self).viewMenu() { completion(menu.children) } } diff --git a/iOSClient/Viewer/NCViewerRichdocument/NCViewerRichDocument.swift b/iOSClient/Viewer/NCViewerRichdocument/NCViewerRichDocument.swift index d26410c757..0f7e6590c2 100644 --- a/iOSClient/Viewer/NCViewerRichdocument/NCViewerRichDocument.swift +++ b/iOSClient/Viewer/NCViewerRichdocument/NCViewerRichDocument.swift @@ -46,7 +46,11 @@ class NCViewerRichDocument: UIViewController, WKNavigationDelegate, WKScriptMess primaryAction: nil, menu: UIMenu(title: "", children: [ UIDeferredMenuElement.uncached { [self] completion in - if let menu = NCContextMenuViewer(metadata: self.metadata, controller: self.tabBarController as? NCMainTabBarController, webView: true, sender: self).viewMenu() { + if let menu = NCContextMenuViewer(metadata: self.metadata, + controller: self.tabBarController as? NCMainTabBarController, + viewController: self.tabBarController, + webView: true, + sender: self).viewMenu() { completion(menu.children) } } @@ -182,6 +186,7 @@ class NCViewerRichDocument: UIViewController, WKNavigationDelegate, WKScriptMess if message.body as? String == "share" { NCCreate().createShare(controller: self.controller, + viewController: self.controller, metadata: metadata, page: .sharing) } From af48bc0f3503e97bf3c3f652c74b9a026274ca53 Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Fri, 22 May 2026 17:38:32 +0200 Subject: [PATCH 02/54] Refactor media viewer comments Signed-off-by: Marino Faggiana --- .../Audio/NCAudioViewerContentView.swift | 49 +-- .../Image/NCImageViewerContentView.swift | 61 +--- .../Image/NCLivePhotoViewerContentView.swift | 26 +- .../AVPlayer/NCVideoAVPlayerPresenter.swift | 41 +-- .../NCVideoAVPlayerViewController.swift | 101 +----- .../NCVideoAVPlayerViewControls.swift | 2 - .../Content/Video/NCVideoControlsView.swift | 69 +---- .../Video/NCVideoPlaybackController.swift | 99 +----- .../Video/NCVideoViewerContentView.swift | 127 +------- .../Video/VLC/NCVideoVLCPresenter.swift | 39 +-- .../Video/VLC/NCVideoVLCViewController.swift | 152 +-------- .../Video/VLC/NCVideoVLCViewControls.swift | 78 +---- .../Helpers/NCViewerAppearance.swift | 29 -- .../Helpers/NCViewerTransitionSource.swift | 14 - .../NCNextcloudMediaViewerLoader.swift | 104 +------ .../Model - View/NCMediaViewerModel.swift | 292 +----------------- .../Model - View/NCMediaViewerView.swift | 13 - .../NCMediaViewerHostingController.swift | 59 +--- .../NCMediaViewerPresenter.swift | 86 +----- .../NCViewerMedia/Views/NCImageZoomView.swift | 44 +-- .../Views/NCMediaViewerDetailView.swift | 4 - .../Views/NCMediaViewerPageView.swift | 32 +- .../Views/NCMediaViewerPagingView.swift | 108 +------ .../Views/NCViewerFloatingTitleView.swift | 37 +-- 24 files changed, 64 insertions(+), 1602 deletions(-) diff --git a/iOSClient/Viewer/NCViewerMedia/Content/Audio/NCAudioViewerContentView.swift b/iOSClient/Viewer/NCViewerMedia/Content/Audio/NCAudioViewerContentView.swift index 73fb816f8c..7e8871661a 100644 --- a/iOSClient/Viewer/NCViewerMedia/Content/Audio/NCAudioViewerContentView.swift +++ b/iOSClient/Viewer/NCViewerMedia/Content/Audio/NCAudioViewerContentView.swift @@ -8,11 +8,6 @@ import NextcloudKit // MARK: - Audio Viewer View -/// Displays and plays a local audio file. -/// -/// The playback model is retrieved from `NCAudioViewerPlaybackRegistry` so the -/// underlying `AVPlayer` survives SwiftUI view rebuilds caused by rotation, -/// layout invalidation, or cell refreshes. struct NCAudioViewerContentView: View { let metadata: tableMetadata let localURL: URL @@ -163,7 +158,6 @@ struct NCAudioViewerContentView: View { return metadata.fileName } - /// Starts playback when this page receives an auto-play request. @MainActor private func consumeAutoPlayIfNeeded() { guard shouldAutoPlay else { @@ -194,11 +188,7 @@ struct NCAudioViewerContentView: View { // MARK: - Audio Viewer Playback Registry -/// Keeps audio playback models alive across SwiftUI view rebuilds. -/// -/// The media viewer can rebuild cells during rotation or layout changes. -/// This registry prevents the audio player from being destroyed just because -/// the SwiftUI page view was recreated. +// Keeps audio models alive across SwiftUI rebuilds. @MainActor final class NCAudioViewerPlaybackRegistry { static let shared = NCAudioViewerPlaybackRegistry() @@ -207,10 +197,6 @@ final class NCAudioViewerPlaybackRegistry { private init() { } - /// Returns a stable audio model for the given media item. - /// - /// - Parameter ocId: Stable Nextcloud media identifier. - /// - Returns: Existing or newly created audio playback model. func model(for ocId: String) -> NCAudioViewerModel { if let model = modelsByOcId[ocId] { return model @@ -221,11 +207,7 @@ final class NCAudioViewerPlaybackRegistry { return model } - /// Stops all cached audio models without removing them. - /// - /// SwiftUI pages may still hold `@StateObject` references to these models. - /// Removing them while views are alive can create duplicate playback models for - /// the same `ocId` after a later cell refresh or rebuild. + // Do not remove models while SwiftUI pages may still hold them. func stopAll() { modelsByOcId.values.forEach { $0.stop() } } @@ -233,10 +215,6 @@ final class NCAudioViewerPlaybackRegistry { // MARK: - Audio Viewer Model -/// Lightweight audio playback model backed by `AVPlayer`. -/// -/// The model observes playback time and item completion, exposes SwiftUI-friendly -/// state, and performs cleanup when playback is explicitly stopped. @MainActor final class NCAudioViewerModel: ObservableObject { @@ -257,11 +235,6 @@ final class NCAudioViewerModel: ObservableObject { // MARK: - Public API - /// Loads a local audio file. - /// - /// If the same URL is already loaded, the existing player is reused. - /// - /// - Parameter url: Local audio file URL. func load(url: URL) async { guard currentURL != url else { return @@ -304,7 +277,6 @@ final class NCAudioViewerModel: ObservableObject { addEndObserver(for: item, player: player) } - /// Starts audio playback. func play() { guard let player else { guard let loadedURL else { @@ -329,7 +301,6 @@ final class NCAudioViewerModel: ObservableObject { isPlaying = true } - /// Toggles audio playback. func togglePlayback() { if isPlaying { pause() @@ -338,12 +309,10 @@ final class NCAudioViewerModel: ObservableObject { } } - /// Toggles loop playback. func toggleLoop() { isLoopEnabled.toggle() } - /// Restarts playback from the beginning. func restart() { seek(to: 0) @@ -352,9 +321,6 @@ final class NCAudioViewerModel: ObservableObject { } } - /// Seeks to a specific playback time. - /// - /// - Parameter seconds: Target playback position in seconds. func seek(to seconds: Double) { guard let player else { return @@ -379,13 +345,11 @@ final class NCAudioViewerModel: ObservableObject { ) } - /// Pauses playback without releasing the player. func pause() { player?.pause() isPlaying = false } - /// Stops playback and releases the player. func stop() { if let player { player.pause() @@ -412,7 +376,6 @@ final class NCAudioViewerModel: ObservableObject { // MARK: - Private - /// Configures the audio session for media playback. private func configureAudioSession() { do { try AVAudioSession.sharedInstance().setCategory( @@ -432,9 +395,6 @@ final class NCAudioViewerModel: ObservableObject { } } - /// Adds a periodic time observer to update SwiftUI playback state. - /// - /// - Parameter player: Player to observe. private func addTimeObserver(to player: AVPlayer) { let interval = CMTime( seconds: 0.25, @@ -459,11 +419,6 @@ final class NCAudioViewerModel: ObservableObject { } } - /// Observes the end of playback and restarts the item when loop is enabled. - /// - /// - Parameters: - /// - item: Player item to observe. - /// - player: Player that owns the item. private func addEndObserver( for item: AVPlayerItem, player: AVPlayer diff --git a/iOSClient/Viewer/NCViewerMedia/Content/Image/NCImageViewerContentView.swift b/iOSClient/Viewer/NCViewerMedia/Content/Image/NCImageViewerContentView.swift index c1213adda7..f1b18ee943 100644 --- a/iOSClient/Viewer/NCViewerMedia/Content/Image/NCImageViewerContentView.swift +++ b/iOSClient/Viewer/NCViewerMedia/Content/Image/NCImageViewerContentView.swift @@ -7,13 +7,6 @@ import UIKit // MARK: - Image Viewer Content View -/// Displays an image page using an optional preview and an optional full-size image. -/// -/// The preview is decoded first when available. -/// The full image replaces the preview only after it has been decoded. -/// Animated GIF files are decoded as animated `UIImage` instances. -/// SVG files are rasterized into `UIImage` instances before rendering. -/// All decoded images are rendered through the same zoom pipeline. struct NCImageViewerContentView: View { let identifier: String let previewURL: URL? @@ -109,7 +102,7 @@ struct NCImageViewerContentView: View { // MARK: - Loading - /// Loads the best available image for the current URLs. + // Decode preview first, then replace it with the full image when ready. @MainActor private func loadBestAvailableImage() async { let expectedIdentifier = identifier @@ -186,18 +179,7 @@ struct NCImageViewerContentView: View { } } - /// Decodes and prepares a local standard image file for display. - /// - /// `UIImage(contentsOfFile:)` can return a lazy image whose bitmap is decoded only - /// when UIKit first draws it. Complex or large images can therefore produce a short - /// blank frame before becoming visible. - /// - /// This method synchronously prepares the image for display in a detached task - /// before publishing it to SwiftUI, so the viewer replaces the preview only when - /// the image is really ready. - /// - /// - Parameter url: Local file URL. - /// - Returns: Display-prepared image if possible. + // Prepare the full image before replacing the preview. private func decodeImageIfPossible(url: URL) async -> UIImage? { guard isValidLocalFile(url: url) else { return nil @@ -216,14 +198,6 @@ struct NCImageViewerContentView: View { }.value } - /// Decodes a local preview image file as quickly as possible. - /// - /// Preview images are intentionally not display-prepared here. - /// They are small temporary placeholders and should become visible before the - /// full image starts its heavier display preparation. - /// - /// - Parameter url: Local preview file URL. - /// - Returns: Preview image if possible. private func decodePreviewImageIfPossible(url: URL) async -> UIImage? { guard isValidLocalFile(url: url) else { return nil @@ -238,10 +212,6 @@ struct NCImageViewerContentView: View { }.value } - /// Decodes a local GIF file as an animated `UIImage`. - /// - /// - Parameter url: Local GIF file URL. - /// - Returns: Animated image if the GIF can be decoded. private func decodeGIFImageIfPossible(url: URL) async -> UIImage? { guard isValidLocalFile(url: url) else { return nil @@ -254,12 +224,7 @@ struct NCImageViewerContentView: View { }.value } - /// Decodes a local SVG file by rasterizing it into a `UIImage`. - /// - /// `NCSVGRenderer` is WKWebView-backed, so this method must run on the main actor. - /// - /// - Parameter url: Local SVG file URL. - /// - Returns: Rasterized SVG image if possible. + // SVG rendering uses WKWebView and must stay on the main actor. @MainActor private func decodeSVGImageIfPossible(url: URL) async -> UIImage? { guard isValidLocalFile(url: url) else { @@ -276,26 +241,14 @@ struct NCImageViewerContentView: View { ) } - /// Returns whether the URL points to a GIF file. - /// - /// - Parameter url: Optional file URL. - /// - Returns: True when the path extension is `gif`. private func isGIF(_ url: URL?) -> Bool { url?.pathExtension.lowercased() == "gif" } - /// Returns whether the URL points to an SVG file. - /// - /// - Parameter url: Optional file URL. - /// - Returns: True when the path extension is `svg`. private func isSVG(_ url: URL?) -> Bool { url?.pathExtension.lowercased() == "svg" } - /// Returns the proper decode failure message for a local image URL. - /// - /// - Parameter url: Local file URL. - /// - Returns: User-facing decode failure message. private func imageDecodeFailedMessage(for url: URL) -> String { if isGIF(url) { return "GIF file could not be decoded." @@ -308,10 +261,6 @@ struct NCImageViewerContentView: View { return "UIImage could not decode this file." } - /// Checks whether a local file exists and has a non-zero size. - /// - /// - Parameter url: Local file URL. - /// - Returns: True when the file exists and is not empty. private func isValidLocalFile(url: URL) -> Bool { let path = url.path @@ -328,10 +277,6 @@ struct NCImageViewerContentView: View { return true } - /// Returns whether VisionKit image analysis should be enabled for the current image. - /// - /// Image analysis is enabled only for normal static images. - /// GIF and SVG are excluded because they are rendered through special decoding paths. private var allowsImageAnalysis: Bool { let url = fullURL ?? previewURL diff --git a/iOSClient/Viewer/NCViewerMedia/Content/Image/NCLivePhotoViewerContentView.swift b/iOSClient/Viewer/NCViewerMedia/Content/Image/NCLivePhotoViewerContentView.swift index aa89c449e8..bcb5c6b33e 100644 --- a/iOSClient/Viewer/NCViewerMedia/Content/Image/NCLivePhotoViewerContentView.swift +++ b/iOSClient/Viewer/NCViewerMedia/Content/Image/NCLivePhotoViewerContentView.swift @@ -10,12 +10,6 @@ import NextcloudKit // MARK: - Live Photo Viewer Content View -/// Displays a Live Photo using a paired full image file and video file. -/// -/// The still image is rendered through `NCImageViewerContentView`, so preview, -/// full image replacement, zoom, and pan keep the same behavior as normal images. -/// The `PHLivePhotoView` is mounted only during playback and is dismantled as soon -/// as playback ends, the page changes, or the view disappears. struct NCLivePhotoViewerContentView: View { let identifier: String let previewURL: URL? @@ -108,7 +102,6 @@ struct NCLivePhotoViewerContentView: View { ) } - /// Badge shown below the navigation bar on the leading side. (color) private var livePhotoBadgeBackground: Color { switch backgroundStyle { case .black: @@ -145,7 +138,6 @@ struct NCLivePhotoViewerContentView: View { } } - /// Badge shown below the navigation bar on the leading side. private var livePhotoBadge: some View { GeometryReader { proxy in let isLandscape = proxy.size.width > proxy.size.height @@ -226,10 +218,7 @@ struct NCLivePhotoViewerContentView: View { // MARK: - Loading - /// Loads the Live Photo only when both full image and paired video resources are available. - /// - /// Missing resources are not treated as a visual failure because the viewer can - /// still render the still image through the normal image pipeline. + // Keep the still image visible when Live Photo resources are missing. @MainActor private func loadLivePhotoIfNeeded() async { if loadedTaskIdentifier != taskIdentifier { @@ -279,19 +268,12 @@ struct NCLivePhotoViewerContentView: View { livePhoto = loadedLivePhoto } - /// Stops the current Live Photo playback and removes the temporary playback view. @MainActor private func stopLivePhotoPlayback() { isPlayingLivePhoto = false } - /// Requests a `PHLivePhoto` from the provided photo and video resource URLs. - /// - /// The Photos framework can invoke the result handler more than once. - /// This wrapper waits for the non-degraded Live Photo and resumes the continuation only once. - /// - /// - Parameter resourceURLs: Local resource URLs required to build the Live Photo. - /// - Returns: A playable `PHLivePhoto` when the request succeeds, otherwise `nil`. + // Photos may call the handler more than once; resume only once. @MainActor private func requestLivePhoto(resourceURLs: [URL]) async -> PHLivePhoto? { guard resourceURLs.count >= 2 else { @@ -365,10 +347,6 @@ struct NCLivePhotoViewerContentView: View { // MARK: - Live Photo View Representable -/// UIKit wrapper for `PHLivePhotoView`. -/// -/// The wrapper starts Live Photo playback when it is mounted. -/// Playback is stopped explicitly when SwiftUI dismantles the UIKit view. private struct NCLivePhotoViewRepresentable: UIViewRepresentable { let livePhoto: PHLivePhoto let backgroundStyle: NCViewerBackgroundStyle diff --git a/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoAVPlayerPresenter.swift b/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoAVPlayerPresenter.swift index 148128e8c0..05c482c2f4 100644 --- a/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoAVPlayerPresenter.swift +++ b/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoAVPlayerPresenter.swift @@ -6,37 +6,16 @@ import UIKit import NextcloudKit // MARK: - AVPlayer Presenter - -/// Presents one UIKit-only AVPlayer viewer outside the SwiftUI paging hierarchy. -/// -/// This presenter guarantees that only one AVPlayer viewer is presented at a time. @MainActor enum NCVideoAVPlayerPresenter { // MARK: - State - private static weak var currentViewController: NCVideoAVPlayerViewController? private static var currentURL: URL? private static var isPresenting = false // MARK: - Public API - - /// Presents the AVPlayer viewer from the current top view controller. - /// - /// Repeated calls with the same URL are ignored to avoid multiple AVPlayer instances - /// during SwiftUI recomposition or device rotation. - /// - /// - Parameters: - /// - metadata: Video metadata used for logging and player title. - /// - url: Local or remote playable URL. - /// - previewURL: Optional local preview image URL shown until the first video frame is ready. - /// - userAgent: Optional HTTP User-Agent for remote playback. - /// - contextMenuController: Main tab bar controller used by context menu actions. - /// - canGoPrevious: Whether the previous-page gesture/action is currently available. - /// - canGoNext: Whether the next-page gesture/action is currently available. - /// - onPrevious: Callback invoked when AVPlayer receives a previous-page action. - /// - onNext: Callback invoked when AVPlayer receives a next-page action. - /// - onClose: Callback invoked with the current media ocId when AVPlayer closes the fullscreen media viewer. + // Presents or updates the single AVPlayer fullscreen controller. static func present( metadata: tableMetadata, url: URL, @@ -159,11 +138,6 @@ enum NCVideoAVPlayerPresenter { } } - /// Clears the current AVPlayer presentation state. - /// - /// Call this from `NCVideoAVPlayerViewController` when it closes. - /// - /// - Parameter viewController: AVPlayer view controller being closed. static func clearCurrent( _ viewController: NCVideoAVPlayerViewController ) { @@ -176,7 +150,6 @@ enum NCVideoAVPlayerPresenter { isPresenting = false } - /// Dismisses the current AVPlayer viewer if one is currently presented. static func dismissCurrent() { guard let currentViewController else { return @@ -187,19 +160,11 @@ enum NCVideoAVPlayerPresenter { } } - /// Dismisses the current AVPlayer viewer if one is currently presented. - /// - /// This short alias is used by video-page navigation callbacks before moving - /// the SwiftUI media viewer to the previous or next page. static func dismiss() { dismissCurrent() } // MARK: - Private - - /// Resolves the top-most visible view controller. - /// - /// - Returns: Top-most visible view controller, if available. private static func topViewController() -> UIViewController? { let windowScene = UIApplication.shared.connectedScenes .compactMap { $0 as? UIWindowScene } @@ -213,10 +178,6 @@ enum NCVideoAVPlayerPresenter { return visibleViewController(from: rootViewController) } - /// Recursively resolves the visible view controller. - /// - /// - Parameter viewController: Root or intermediate view controller. - /// - Returns: Top-most visible view controller. private static func visibleViewController( from viewController: UIViewController? ) -> UIViewController? { diff --git a/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoAVPlayerViewController.swift b/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoAVPlayerViewController.swift index 1206ca2463..984e5c1f86 100644 --- a/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoAVPlayerViewController.swift +++ b/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoAVPlayerViewController.swift @@ -10,10 +10,6 @@ import NextcloudKit // MARK: - AVPlayer Layer View -/// UIView backed directly by an AVPlayerLayer. -/// -/// This is the AVPlayer equivalent of VLC's drawable view: -/// the fullscreen controller owns one stable video surface and attaches the player to it. final class NCVideoAVPlayerLayerView: UIView { override static var layerClass: AnyClass { AVPlayerLayer.self @@ -35,11 +31,6 @@ final class NCVideoAVPlayerLayerView: UIView { // MARK: - AVPlayer View Controller -/// UIKit-only AVPlayer video controller. -/// -/// This controller is intentionally outside the SwiftUI paging hierarchy. -/// It owns one stable AVPlayerLayer-backed view, one AVPlayer, one optional PiP controller, -/// and one shared controls view. final class NCVideoAVPlayerViewController: UIViewController { // MARK: - Input @@ -259,16 +250,6 @@ final class NCVideoAVPlayerViewController: UIViewController { // MARK: - Public API - /// Updates the current AVPlayer input. - /// - /// If the URL changes, the current item is stopped and the new item is prepared. - /// The context menu is refreshed for the new metadata. - /// - /// - Parameters: - /// - metadata: Updated video metadata. - /// - url: Updated playable URL. - /// - userAgent: Optional HTTP User-Agent. - /// - contextMenuController: Updated context menu controller. func update( metadata: tableMetadata, url: URL, @@ -302,7 +283,6 @@ final class NCVideoAVPlayerViewController: UIViewController { // MARK: - Navigation - /// Configures the navigation bar items. private func configureNavigationItem() { title = nil navigationItem.title = nil @@ -321,7 +301,6 @@ final class NCVideoAVPlayerViewController: UIViewController { ] } - /// Configures the floating title view inside the navigation bar chrome. private func configureFloatingTitleViewIfNeeded() { guard let navigationBar = navigationController?.navigationBar else { return @@ -330,9 +309,6 @@ final class NCVideoAVPlayerViewController: UIViewController { floatingTitleView.attach(to: navigationBar) } - /// Updates the floating title view using the provided video metadata. - /// - /// - Parameter metadata: Video metadata used to build the visible title content. private func updateTitleLabel(metadata: tableMetadata) { let primaryTitle = metadata.fileNameView.isEmpty ? metadata.fileName @@ -345,23 +321,15 @@ final class NCVideoAVPlayerViewController: UIViewController { ) } - /// Builds the secondary floating title text for the provided metadata. - /// - /// - Parameter metadata: Video metadata used to derive the secondary title line. - /// - Returns: Secondary title text shown below the main title. private func floatingTitleSecondaryText(for metadata: tableMetadata) -> String? { floatingTitleDateFormatter.string(from: metadata.date as Date) } - /// Rebuilds the More menu using the current metadata. private func refreshMoreMenu() { moreNavigationItem.menu = makeMoreMenu() } - /// Builds the AVPlayer-specific More menu. - /// - /// The menu uses `sender: self`, so menu actions present from the visible - /// AVPlayer controller instead of the SwiftUI viewer underneath. + // Use this controller as sender so actions present above AVPlayer. private func makeMoreMenu() -> UIMenu { UIMenu(title: "", children: [ UIDeferredMenuElement.uncached { [weak self] completion in @@ -395,11 +363,6 @@ final class NCVideoAVPlayerViewController: UIViewController { presentDetailView(animated: true) } - /// Presents the media metadata detail panel for the current video. - /// - /// Video metadata usually has no EXIF payload, so the detail view receives an empty EXIF model. - /// - /// - Parameter animated: Whether presentation should be animated. private func presentDetailView(animated: Bool) { let detailView = NCMediaViewerDetailView( metadata: metadata, @@ -449,7 +412,6 @@ final class NCVideoAVPlayerViewController: UIViewController { // MARK: - Swipe Navigation - /// Configures swipe gestures for page navigation and close behavior. private func configureSwipeGestures() { let previousGesture = UISwipeGestureRecognizer( target: self, @@ -475,9 +437,6 @@ final class NCVideoAVPlayerViewController: UIViewController { view.addGestureRecognizer(closePanGesture) } - /// Handles page navigation and close swipe gestures. - /// - /// - Parameter gesture: Source swipe gesture recognizer. @objc private func handleSwipe(_ gesture: UISwipeGestureRecognizer) { guard gesture.state == .ended else { @@ -510,13 +469,7 @@ final class NCVideoAVPlayerViewController: UIViewController { } } - /// Handles downward pan gestures by closing the AVPlayer viewer. - /// - /// This mirrors the common media viewer drag-to-close behavior: a short downward - /// drag or a quick downward flick is enough, while horizontal paging still wins - /// when the gesture is mostly horizontal. - /// - /// - Parameter gesture: Source pan gesture recognizer. + // Close only when downward movement wins over horizontal paging. @objc private func handleClosePan(_ gesture: UIPanGestureRecognizer) { guard !isPictureInPictureActive else { @@ -553,7 +506,6 @@ final class NCVideoAVPlayerViewController: UIViewController { // MARK: - Gesture Handling - /// Configures a single tap gesture to toggle AVPlayer playback controls. private func configureTapGesture() { let tapGesture = UITapGestureRecognizer( target: self, @@ -565,12 +517,7 @@ final class NCVideoAVPlayerViewController: UIViewController { view.addGestureRecognizer(tapGesture) } - /// Handles single taps by toggling AVPlayer playback controls. - /// - /// Taps are ignored while playback is not running because controls and the - /// navigation bar must remain visible in prepared, paused, and stopped states. - /// - /// - Parameter gesture: Source tap gesture recognizer. + // Keep controls visible when playback is not running. @objc private func handleSingleTap(_ gesture: UITapGestureRecognizer) { guard !isPictureInPictureActive else { @@ -599,7 +546,6 @@ final class NCVideoAVPlayerViewController: UIViewController { // MARK: - Playback - /// Prepares AVPlayer playback without starting it automatically. private func start() { guard preparedURL != url else { updatePlayPauseButton() @@ -630,7 +576,6 @@ final class NCVideoAVPlayerViewController: UIViewController { ) } - /// Stops AVPlayer playback and releases resources. private func stop() { preparedURL = nil player.pause() @@ -644,7 +589,6 @@ final class NCVideoAVPlayerViewController: UIViewController { updateProgressControls() } - /// Creates the AVFoundation asset for the current URL. private func makeAsset() -> AVURLAsset { guard let userAgent, !userAgent.isEmpty, @@ -662,13 +606,11 @@ final class NCVideoAVPlayerViewController: UIViewController { ) } - /// Configures the visible AVPlayerLayer used by fullscreen playback. private func configurePlayerLayer() { playerContainerView.playerLayer.videoGravity = .resizeAspect playerContainerView.player = player } - /// Configures Picture in Picture from the visible AVPlayerLayer. private func configurePictureInPicture() { guard AVPictureInPictureController.isPictureInPictureSupported() else { controlsView.setTopActionsMode(.none) @@ -689,12 +631,10 @@ final class NCVideoAVPlayerViewController: UIViewController { controlsView.setTopActionsMode(.pictureInPicture) } - /// Updates Picture in Picture layout without changing playback state. private func updatePictureInPictureLayout() { playerContainerView.playerLayer.frame = playerContainerView.bounds } - /// Toggles Picture in Picture if available. func togglePictureInPicture() { guard let pictureInPictureController else { return @@ -707,7 +647,6 @@ final class NCVideoAVPlayerViewController: UIViewController { } } - /// Configures AVPlayer observers. private func configureObservers() { cleanupObservers() @@ -752,7 +691,6 @@ final class NCVideoAVPlayerViewController: UIViewController { } } - /// Releases AVPlayer observers owned by this controller. private func cleanupObservers() { itemStatusObservation?.invalidate() timeControlStatusObservation?.invalidate() @@ -771,7 +709,6 @@ final class NCVideoAVPlayerViewController: UIViewController { } } - /// Handles AVPlayer item status changes. private func handleCurrentItemStatusChange() { updateProgressControls() updatePlayPauseButton() @@ -788,7 +725,6 @@ final class NCVideoAVPlayerViewController: UIViewController { } } - /// Handles AVPlayer playback state changes. private func handleTimeControlStatusChange() { updatePlayPauseButton() @@ -805,7 +741,6 @@ final class NCVideoAVPlayerViewController: UIViewController { } } - /// Updates the fullscreen preview image shown before the first video frame is ready. private func updatePreviewImage() { guard let previewURL, previewURL.isFileURL else { @@ -819,7 +754,6 @@ final class NCVideoAVPlayerViewController: UIViewController { previewImageView.alpha = 1 } - /// Shows the preview image while the AVPlayer item is preparing. private func showPreviewImage() { guard previewImageView.image != nil else { previewImageView.isHidden = true @@ -831,7 +765,6 @@ final class NCVideoAVPlayerViewController: UIViewController { previewImageView.isHidden = false } - /// Hides the preview image after AVPlayer actually starts playback. private func hidePreviewImage() { guard !previewImageView.isHidden else { return @@ -842,22 +775,16 @@ final class NCVideoAVPlayerViewController: UIViewController { previewImageView.isHidden = true } - /// Handles playback reaching the end. private func handlePlaybackEnded() { updatePlayPauseButton() updateProgressControls() showControls(animated: true) } - /// Updates the shared controls top actions reference using the real navigation bar. private func updateControlsNavigationBar() { controlsView.setTopActionsNavigationBar(navigationController?.navigationBar) } - /// Returns whether a point is inside one of the visible controls areas. - /// - /// - Parameter location: Point in this controller's root view coordinate space. - /// - Returns: True when the point is inside center or bottom controls. internal func controlsHitFramesContain(_ location: CGPoint) -> Bool { let topActionsFrame = controlsView.topActionsView.convert( controlsView.topActionsView.bounds, @@ -877,7 +804,6 @@ final class NCVideoAVPlayerViewController: UIViewController { || bottomControlsFrame.contains(location) } - /// Configures the audio session for movie playback. private func configureAudioSession() { do { try AVAudioSession.sharedInstance().setCategory( @@ -897,14 +823,12 @@ final class NCVideoAVPlayerViewController: UIViewController { } } - /// Updates the shared controls play/pause state. internal func updatePlayPauseButton() { controlsView.updatePlayPauseButton( isPlaying: player.timeControlStatus == .playing ) } - /// Updates the shared controls progress state. internal func updateProgressControls() { let currentTime = player.currentTime().seconds let duration = player.currentItem?.duration.seconds ?? 0 @@ -930,7 +854,6 @@ final class NCVideoAVPlayerViewController: UIViewController { ) } - /// Updates whether seek controls are enabled. internal func updateSeekingState() { controlsView.setSeekingEnabled( player.currentItem?.duration.seconds.isFinite == true @@ -1028,12 +951,7 @@ extension NCVideoAVPlayerViewController: AVPictureInPictureControllerDelegate { extension NCVideoAVPlayerViewController: UIGestureRecognizerDelegate { - /// Allows tap gestures to coexist with AVPlayer's view and UIKit controls. - /// - /// - Parameters: - /// - gestureRecognizer: Gesture recognizer asking for simultaneous recognition. - /// - otherGestureRecognizer: Other gesture recognizer involved in the decision. - /// - Returns: True to avoid AVPlayer/touch handling from suppressing viewer gestures. + // Keep AVPlayer touches compatible with viewer gestures. func gestureRecognizer( _ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer @@ -1041,12 +959,7 @@ extension NCVideoAVPlayerViewController: UIGestureRecognizerDelegate { true } - /// Prevents the background tap recognizer from stealing touches that begin on controls. - /// - /// - Parameters: - /// - gestureRecognizer: Gesture recognizer asking whether it should receive the touch. - /// - touch: Source touch. - /// - Returns: False for visible playback controls, true otherwise. + // Do not let background taps steal control touches. func gestureRecognizer( _ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch @@ -1068,10 +981,6 @@ extension NCVideoAVPlayerViewController: UIGestureRecognizerDelegate { return true } - /// Allows the close pan to start only when the gesture is mainly downward. - /// - /// - Parameter gestureRecognizer: Gesture recognizer asking whether it should begin. - /// - Returns: True for non-pan gestures or downward-dominant pan gestures. func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { guard gestureRecognizer is UIPanGestureRecognizer else { return true diff --git a/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoAVPlayerViewControls.swift b/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoAVPlayerViewControls.swift index 5399627e8c..0f10744444 100644 --- a/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoAVPlayerViewControls.swift +++ b/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoAVPlayerViewControls.swift @@ -190,11 +190,9 @@ extension NCVideoAVPlayerViewController { extension NCVideoAVPlayerViewController: NCVideoControlsViewDelegate { func videoControlsDidTapSubtitle(_ controlsView: NCVideoControlsView) { - // AVPlayer does not expose VLC subtitle track controls. } func videoControlsDidTapAudio(_ controlsView: NCVideoControlsView) { - // AVPlayer does not expose VLC audio track controls. } func videoControlsDidTapSeekBackward(_ controlsView: NCVideoControlsView) { diff --git a/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoControlsView.swift b/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoControlsView.swift index 887d85b258..5f55c7dfed 100644 --- a/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoControlsView.swift +++ b/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoControlsView.swift @@ -7,10 +7,6 @@ import UIKit // MARK: - Video Controls View Delegate -/// Receives user actions from the shared video controls view. -/// -/// The controls view is playback-engine agnostic. -/// AVFoundation and VLC controllers translate these callbacks into their own player APIs. protocol NCVideoControlsViewDelegate: AnyObject { func videoControlsDidTapSeekBackward(_ controlsView: NCVideoControlsView) func videoControlsDidTapPlayPause(_ controlsView: NCVideoControlsView) @@ -27,45 +23,21 @@ protocol NCVideoControlsViewDelegate: AnyObject { } extension NCVideoControlsViewDelegate { - /// Handles the Picture in Picture action when implemented by a playback controller. - /// - /// - Parameter controlsView: Shared controls view that emitted the action. func videoControlsDidTapPictureInPicture(_ controlsView: NCVideoControlsView) { } - /// Handles the subtitle track action when implemented by a playback controller. - /// - /// - Parameter controlsView: Shared controls view that emitted the action. func videoControlsDidTapSubtitle(_ controlsView: NCVideoControlsView) { } - /// Handles the audio track action when implemented by a playback controller. - /// - /// - Parameter controlsView: Shared controls view that emitted the action. func videoControlsDidTapAudio(_ controlsView: NCVideoControlsView) { } - /// Handles the external subtitle import action when implemented by a playback controller. - /// - /// - Parameter controlsView: Shared controls view that emitted the action. func videoControlsDidTapAddExternalSubtitle(_ controlsView: NCVideoControlsView) { } - /// Handles subtitle track selection when implemented by a playback controller. - /// - /// - Parameters: - /// - controlsView: Shared controls view that emitted the action. - /// - index: VLC subtitle track index selected by the user. func videoControls(_ controlsView: NCVideoControlsView, didSelectSubtitleTrackIndex index: Int32) { } - /// Handles audio track selection when implemented by a playback controller. - /// - /// - Parameters: - /// - controlsView: Shared controls view that emitted the action. - /// - index: VLC audio track index selected by the user. func videoControls(_ controlsView: NCVideoControlsView, didSelectAudioTrackIndex index: Int32) { } } // MARK: - Video Controls Top Actions Mode -/// Describes the engine-specific actions rendered in the top controls area. - enum NCVideoControlsTopActionsMode: Equatable { case none case pictureInPicture @@ -74,7 +46,6 @@ enum NCVideoControlsTopActionsMode: Equatable { // MARK: - Video Track Menu Item -/// Represents a selectable VLC track rendered by the shared SwiftUI controls menu. struct NCVideoTrackMenuItem: Identifiable, Equatable { let index: Int32 let title: String @@ -87,11 +58,6 @@ struct NCVideoTrackMenuItem: Identifiable, Equatable { // MARK: - Video Controls View -/// Shared UIKit wrapper used by video engines. -/// -/// AVPlayer and VLC still receive a regular `UIView`, while the visual controls are rendered -/// by SwiftUI through an embedded hosting controller. This keeps playback integration stable -/// and makes the custom UI easy to preview and iterate. final class NCVideoControlsView: UIView { // MARK: - Public @@ -142,20 +108,11 @@ final class NCVideoControlsView: UIView { // MARK: - Public Updates - /// Updates the play/pause icon. - /// - /// - Parameter isPlaying: True when playback is currently active. func updatePlayPauseButton(isPlaying: Bool) { state.isPlaying = isPlaying updateHostedView() } - /// Updates slider and time labels. - /// - /// - Parameters: - /// - progress: Normalized playback progress between 0 and 1. - /// - elapsedText: Formatted elapsed time. - /// - remainingText: Formatted remaining time. func updateProgress( progress: Float, elapsedText: String, @@ -167,31 +124,19 @@ final class NCVideoControlsView: UIView { updateHostedView() } - /// Enables or disables seeking controls. - /// - /// - Parameter isEnabled: True when the current engine supports seeking. func setSeekingEnabled(_ isEnabled: Bool) { state.isSeekingEnabled = isEnabled updateHostedView() } - /// Shows or hides the Picture in Picture action. - /// - /// - Parameter isVisible: True when the current playback engine supports Picture in Picture. func setPictureInPictureVisible(_ isVisible: Bool) { setTopActionsMode(isVisible ? .pictureInPicture : .none) } - /// Shows or hides the VLC subtitle and audio track actions. - /// - /// - Parameter isVisible: True when the VLC playback engine should expose track controls. func setVLCTrackControlsVisible(_ isVisible: Bool) { setTopActionsMode(isVisible ? .vlcTracks : .none) } - /// Updates the engine-specific actions rendered in the top controls area. - /// - /// - Parameter mode: Top actions mode requested by the current playback engine. func setTopActionsMode(_ mode: NCVideoControlsTopActionsMode) { let didChangeMode = state.topActionsMode != mode var didResetTrackItems = false @@ -212,9 +157,6 @@ final class NCVideoControlsView: UIView { updateHostedView() } - /// Updates the subtitle track menu items rendered by the VLC controls. - /// - /// - Parameter items: Available subtitle tracks with selection state. func setSubtitleTrackMenuItems(_ items: [NCVideoTrackMenuItem]) { guard state.subtitleTrackItems != items else { return @@ -224,9 +166,6 @@ final class NCVideoControlsView: UIView { updateHostedView() } - /// Updates the audio track menu items rendered by the VLC controls. - /// - /// - Parameter items: Available audio tracks with selection state. func setAudioTrackMenuItems(_ items: [NCVideoTrackMenuItem]) { guard state.audioTrackItems != items else { return @@ -236,13 +175,7 @@ final class NCVideoControlsView: UIView { updateHostedView() } - /// Updates the navigation bar reference used by the top actions area. - /// - /// The controls view converts the real navigation bar frame into its own coordinate space - /// so top actions remain aligned below the actual viewer chrome across iPhone, iPad, - /// rotation, and compact/regular layouts. - /// - /// - Parameter navigationBar: Navigation bar used as vertical reference for top actions. + // Keeps top actions aligned below the real navigation bar. func setTopActionsNavigationBar(_ navigationBar: UINavigationBar?) { self.navigationBar = navigationBar updateTopActionsPosition() diff --git a/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoPlaybackController.swift b/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoPlaybackController.swift index 217c9258c1..95cd6297f1 100644 --- a/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoPlaybackController.swift +++ b/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoPlaybackController.swift @@ -8,39 +8,16 @@ import NextcloudKit // MARK: - Video Playback Engine -/// Describes the currently rendered video playback engine. -/// -/// The engine is owned by `NCVideoPlaybackController`. -/// Views only render the selected engine; they do not own AVFoundation playback resources. -/// VLC playback is rendered by a dedicated legacy-style UIKit VLC view. enum NCVideoPlaybackEngine { - /// No playable engine is currently ready. case loading - - /// Native AVFoundation playback using a resolved playable URL. - /// - /// The real fullscreen AVPlayer is owned by `NCVideoAVPlayerViewController`. case avFoundation(url: URL) - - /// VLC fallback playback using a resolved playable URL. - /// - /// The VLC player itself is owned by `NCVideoVLCViewerContentView`, not by this controller. case vlc(url: URL) - - /// Playback could not be prepared. case failed(message: String) } // MARK: - Video Playback Controller -/// Shared video playback controller used by the SwiftUI media viewer. -/// -/// This controller owns AVFoundation playback resources and resolves whether -/// a video should be rendered through AVFoundation or VLC. -/// -/// VLC is intentionally not owned here. The VLC renderer uses a legacy-style -/// UIKit controller with a stable `UIImageView` drawable, matching the old -/// media viewer behavior. +// Resolves AVFoundation playback or VLC fallback for video pages. @MainActor final class NCVideoPlaybackController: ObservableObject { static let shared = NCVideoPlaybackController() @@ -68,17 +45,6 @@ final class NCVideoPlaybackController: ObservableObject { // MARK: - Public API - /// Returns whether the requested metadata and URL already match the current video. - /// - /// This check is used for local videos, where the playable file URL is known before - /// loading. It prevents unnecessary reloads while still allowing the viewer to switch - /// from a remote URL to a newly available local file URL. - /// - /// - Parameters: - /// - ocId: Nextcloud file identifier. - /// - etag: Metadata ETag. - /// - url: Expected local or remote playable URL. - /// - Returns: True when the current loaded media matches the supplied identity and URL. func isCurrentVideo( ocId: String, etag: String, @@ -88,20 +54,7 @@ final class NCVideoPlaybackController: ObservableObject { currentEtag == etag && currentURL == url } - - /// Returns whether the requested metadata already matches the current video. - /// - /// This check is used for remote videos where the resolved playback URL is not - /// known before the resolver runs. It prevents SwiftUI rebuilds, such as rotation, - /// from resolving and loading the same remote video again. - /// - /// Local videos should use the URL-based overload so the viewer can still switch - /// from a remote URL to a newly available local file URL. - /// - /// - Parameters: - /// - ocId: Nextcloud file identifier. - /// - etag: Metadata ETag. - /// - Returns: True when the current loaded media matches the supplied metadata. + // Used for remote videos before the final playback URL is known. func isCurrentVideo( ocId: String, etag: String @@ -110,20 +63,7 @@ final class NCVideoPlaybackController: ObservableObject { currentEtag == etag && currentURL != nil } - - /// Loads a video URL if it is not already loaded. - /// - /// Calling this method again for the same `ocId`, `etag`, and URL is idempotent. - /// It does not stop, recreate, or restart the existing AV player. For VLC, - /// it keeps the same engine URL so the VLC view can reuse its own controller. - /// - /// - Parameters: - /// - metadata: Video metadata used as playback identity. - /// - url: Local or remote playable URL. - /// - fileName: Original metadata file name used to detect legacy formats. - /// - userAgent: Optional User-Agent used by VLC for remote playback. - /// - httpHeaders: Optional HTTP headers used by AVFoundation for remote playback. - /// - shouldAutoPlay: Whether playback should start automatically. + // Reuses the current player when the requested video is already loaded. func loadVideo( metadata: tableMetadata, url: URL, @@ -192,9 +132,6 @@ final class NCVideoPlaybackController: ObservableObject { ) } - /// Stops the current video only if the supplied page owns playback. - /// - /// - Parameter ocId: Page file identifier. func stopIfCurrent(ocId: String) { guard currentOcId == ocId else { return @@ -202,11 +139,7 @@ final class NCVideoPlaybackController: ObservableObject { stop() } - - /// Stops current playback state and releases AVFoundation resources. - /// - /// VLC playback is stopped by `NCVideoVLCViewerContentView` through - /// `.ncMediaViewerStopPlayback`, because the VLC player is owned by that view. + // Releases AVFoundation resources; VLC is owned by its view controller. func stop() { loadToken = UUID() @@ -230,7 +163,6 @@ final class NCVideoPlaybackController: ObservableObject { // MARK: - AVFoundation - /// Prepares an AVFoundation player item and observes its readiness. private func prepareAVFoundation( metadata: tableMetadata, url: URL, @@ -303,13 +235,6 @@ final class NCVideoPlaybackController: ObservableObject { } } - /// Selects AVFoundation as the active rendering engine. - /// - /// - Parameters: - /// - url: The resolved playable URL. - /// - player: Prepared AVFoundation player. - /// - shouldAutoPlay: Whether playback should start after AVFoundation becomes ready. - /// - token: Load token used to ignore stale callbacks. private func resolveWithAVFoundation( url: URL, player: AVPlayer, @@ -334,7 +259,7 @@ final class NCVideoPlaybackController: ObservableObject { ) } - /// Starts a timeout after which VLC is selected if AVFoundation is still loading. + // Fall back to VLC if AVFoundation does not become ready quickly. private func startFallbackTimeout( url: URL, token: UUID @@ -369,10 +294,6 @@ final class NCVideoPlaybackController: ObservableObject { // MARK: - VLC - /// Selects VLC as the active rendering engine. - /// - /// This does not create or own the VLC player. It only exposes the URL to - /// `NCVideoVLCViewerContentView`, which owns its legacy-style VLC controller. private func resolveWithVLC( url: URL, reason: String, @@ -407,7 +328,6 @@ final class NCVideoPlaybackController: ObservableObject { // MARK: - State Helpers - /// Returns whether the supplied media request is already loaded. private func isSameLoadedVideo( metadata: tableMetadata, url: URL @@ -417,7 +337,6 @@ final class NCVideoPlaybackController: ObservableObject { currentURL == url } - /// Returns whether a callback belongs to the current load request. private func isCurrentLoad( url: URL, token: UUID @@ -425,9 +344,6 @@ final class NCVideoPlaybackController: ObservableObject { loadToken == token && currentURL == url } - /// Resumes the current AV player if requested. - /// - /// VLC auto-play is handled by `NCVideoVLCViewerContentView`. private func resumeCurrentPlaybackIfNeeded(shouldAutoPlay: Bool) { guard shouldAutoPlay else { return @@ -446,7 +362,6 @@ final class NCVideoPlaybackController: ObservableObject { // MARK: - Private Helpers - /// Configures the audio session for video playback. private func configureAudioSession() { do { try AVAudioSession.sharedInstance().setCategory( @@ -466,7 +381,7 @@ final class NCVideoPlaybackController: ObservableObject { } } - /// Returns whether a video format should bypass AVFoundation and use VLC directly. + // Legacy formats go directly to VLC. private func shouldUseVLCWithoutAVFoundation( url: URL, fileName: String @@ -489,7 +404,6 @@ final class NCVideoPlaybackController: ObservableObject { return legacyVideoExtensions.contains(pathExtension) } - /// Resolves the best available video extension. private func resolvedVideoExtension( url: URL, fileName: String @@ -505,7 +419,6 @@ final class NCVideoPlaybackController: ObservableObject { return url.pathExtension.lowercased() } - /// Checks whether a local file exists and has a non-zero size. private func isValidLocalFile(url: URL) -> Bool { let path = url.path diff --git a/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoViewerContentView.swift b/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoViewerContentView.swift index 0314436c9f..780f2d095c 100644 --- a/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoViewerContentView.swift +++ b/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoViewerContentView.swift @@ -7,22 +7,6 @@ import NextcloudKit // MARK: - Video Viewer Content View -/// Displays a video using the shared video playback controller. -/// -/// This view does not own the AVPlayer directly. -/// AVFoundation playback is presented as a separate UIKit-only controller through -/// `NCVideoAVPlayerPresenter`, outside the SwiftUI paging hierarchy. -/// VLC playback is presented as a separate UIKit-only controller through -/// `NCVideoVLCPresenter`, outside the SwiftUI paging hierarchy. -/// -/// Loading rules: -/// - If a valid local URL is already available, it is used directly. -/// - The remote resolver is used only when no local URL is available. -/// - If the same video is already loaded, the existing player is reused. -/// - If the page is not selected, the view does not load a new video. -/// - AVFoundation is presented outside SwiftUI when selected. -/// - VLC is presented outside SwiftUI when selected. -/// - Real global stop events are handled through `.ncMediaViewerStopPlayback`. struct NCVideoViewerContentView: View { let metadata: tableMetadata let localURL: URL? @@ -169,9 +153,7 @@ struct NCVideoViewerContentView: View { stopPlaybackForDeselection() } .onDisappear { - // Do not stop or hide the player here. - // SwiftUI can call onDisappear during rotation or layout rebuilds. - // Real playback stops are driven by `.ncMediaViewerStopPlayback`. + // Ignore layout-driven disappear events. } } @@ -202,11 +184,6 @@ struct NCVideoViewerContentView: View { // MARK: - Loading - /// Stops fullscreen video playback when this video page is no longer selected. - /// - /// This is intentionally not done from `onDisappear`, because SwiftUI may call - /// `onDisappear` during rotation or layout rebuilds. A transition from selected - /// to not selected is instead a real page change. @MainActor private func stopPlaybackForDeselection() { presentedAVPlayerURL = nil @@ -223,12 +200,7 @@ struct NCVideoViewerContentView: View { return "\(metadata.ocId)|\(metadata.etag)|\(localIdentifier)" } - /// Loads or reveals the video only when this page is still selected and stable. - /// - /// This is the single entry point for selected video loading. - /// It is used by both `.task(id:)` and `isSelected` changes because SwiftUI may - /// create a video page before it becomes selected, and `.task(id:)` may not run - /// again when the same page later becomes selected. + // Single entry point for selected video loading. @MainActor private func loadVideoIfSelected() async { let expectedTaskIdentifier = taskIdentifier @@ -254,16 +226,7 @@ struct NCVideoViewerContentView: View { ) } - /// Waits briefly before allowing a selected video page to resolve or load playback. - /// - /// Fast swipe gestures can make intermediate video pages selected for a very short time. - /// This gate keeps those transient pages as preview-only without slowing image paging, - /// because it exists only inside the video viewer. - /// - /// - Parameters: - /// - expectedTaskIdentifier: Task identity captured before the delay. - /// - expectedLoadGeneration: Load generation captured before the delay. - /// - Returns: True if the page is still selected and still represents the same load request. + // Avoid loading transient pages during fast swipes. @MainActor private func waitForStableSelection( expectedTaskIdentifier: String, @@ -294,13 +257,6 @@ struct NCVideoViewerContentView: View { return isSelected } - /// Resolves the playable video URL and loads it into the shared playback controller. - /// - /// Local URLs are loaded directly and have priority over remote resolution. - /// - /// - Parameters: - /// - expectedTaskIdentifier: Task identity captured before starting async resolution. - /// - expectedLoadGeneration: Load generation captured before starting async resolution. @MainActor private func resolveAndLoadVideo( expectedTaskIdentifier: String, @@ -392,14 +348,6 @@ struct NCVideoViewerContentView: View { ) } - /// Loads a resolved video URL into the shared playback controller. - /// - /// - Parameters: - /// - url: Local or remote playable URL. - /// - autoplay: Whether the resolved URL requests autoplay. - /// - expectedTaskIdentifier: Task identity used to ignore stale async work. - /// - expectedLoadGeneration: Load generation used to ignore stale async work. - /// - source: Debug source label used in logs. @MainActor private func loadResolvedVideo( url: URL, @@ -457,12 +405,6 @@ struct NCVideoViewerContentView: View { ) } - /// Returns HTTP headers for remote video playback. - /// - /// Local file URLs do not need HTTP headers. - /// - /// - Parameter url: Resolved video URL. - /// - Returns: HTTP headers for AVFoundation remote playback. private func httpHeaders(for url: URL) -> [String: String] { guard !url.isFileURL else { return [:] @@ -480,15 +422,7 @@ struct NCVideoViewerContentView: View { // MARK: - Playback Selection - /// Returns whether this page already owns an active playback engine. - /// - /// Local videos require an exact URL match. - /// Remote videos can only be checked by metadata because the direct-download URL - /// is resolved lazily when the selected page loads. - /// - /// The playback engine must already be renderable. A loading or failed engine is - /// not considered reusable, otherwise a cached video page could remain stuck as a - /// plain preview when it becomes selected again. + // Loading or failed engines are not reusable. private func isCurrentPlaybackVideo() -> Bool { switch playback.engine { case .avFoundation, @@ -514,10 +448,7 @@ struct NCVideoViewerContentView: View { ) } - /// Reveals the current playback engine without changing the playback state. - /// - /// This is used when SwiftUI rebuilds the selected page, for example during - /// rotation. It must not call `play()` because the user may have paused the video. + // Reveal without changing play/pause state. @MainActor private func revealCurrentPlaybackIfNeeded() { switch playback.engine { @@ -533,9 +464,6 @@ struct NCVideoViewerContentView: View { } } - /// Presents the UIKit-only AVPlayer viewer when this page is selected. - /// - /// - Parameter url: Local or remote playable URL selected by AVFoundation probing. @MainActor private func presentAVPlayerIfSelected(url: URL) { guard isSelected else { @@ -562,7 +490,6 @@ struct NCVideoViewerContentView: View { ) } - /// Moves to the previous media item from the UIKit-only AVPlayer controller. @MainActor private func goToPreviousPageFromAVPlayer() { presentedAVPlayerURL = nil @@ -570,7 +497,6 @@ struct NCVideoViewerContentView: View { onPreviousPage?() } - /// Moves to the next media item from the UIKit-only AVPlayer controller. @MainActor private func goToNextPageFromAVPlayer() { presentedAVPlayerURL = nil @@ -578,9 +504,6 @@ struct NCVideoViewerContentView: View { onNextPage?() } - /// Closes the full media viewer from a fullscreen video controller. - /// - /// - Parameter ocId: Optional Nextcloud file identifier of the fullscreen video being closed. @MainActor private func closeFromFullscreenVideo(ocId: String?) { presentedAVPlayerURL = nil @@ -589,9 +512,6 @@ struct NCVideoViewerContentView: View { onClose?(ocId) } - /// Presents the UIKit-only VLC fallback viewer when this page is selected. - /// - /// - Parameter url: Local or remote playable URL. @MainActor private func presentVLCIfSelected(url: URL) { guard isSelected else { @@ -618,7 +538,6 @@ struct NCVideoViewerContentView: View { ) } - /// Moves to the previous media item from the UIKit-only VLC controller. @MainActor private func goToPreviousPageFromVLC() { presentedVLCURL = nil @@ -626,7 +545,6 @@ struct NCVideoViewerContentView: View { onPreviousPage?() } - /// Moves to the next media item from the UIKit-only VLC controller. @MainActor private func goToNextPageFromVLC() { presentedVLCURL = nil @@ -636,16 +554,7 @@ struct NCVideoViewerContentView: View { // MARK: - In-Flight Resolution Cache - /// Resolves a video URL through a shared in-flight task cache. - /// - /// SwiftUI can temporarily create multiple video page views for the same page while - /// the selected state transitions from prefetched preview to selected video state. - /// A shared task lets duplicated views await the same direct-link resolution instead - /// of starting duplicate requests or skipping resolution while the original view is - /// being cancelled. - /// - /// - Parameter taskIdentifier: Stable video task identity. - /// - Returns: Resolved video URL, autoplay preference, and Nextcloud error. + // Share direct-link resolution between duplicated SwiftUI page instances. @MainActor private func resolvedVideoURL( taskIdentifier: String @@ -668,10 +577,7 @@ struct NCVideoViewerContentView: View { // MARK: - Helpers - /// Delay used only for selected video pages before resolving or loading playback. - /// - /// This protects fast swipe gestures from starting remote resolution or VLC/AVPlayer - /// for transient video pages, without affecting image paging responsiveness. + // Prevent transient video pages from starting playback work. private static let videoSelectionSettleDelayNanoseconds: UInt64 = 150_000_000 private var resolvedFileName: String { @@ -685,11 +591,6 @@ struct NCVideoViewerContentView: View { // MARK: - Video Preview Placeholder -/// Displays a static, non-interactive preview for video pages. -/// -/// Video previews are shown only when a local preview image is already available. -/// When no preview is available, the view keeps a stable black background to avoid -/// extra icon-to-preview-to-player transitions. private struct NCVideoPreviewPlaceholderView: View { let previewURL: URL? @@ -719,19 +620,9 @@ private struct NCVideoPreviewPlaceholderView: View { // MARK: - Video URL Resolution -/// Resolves the playable URL for a video item. -/// -/// Resolution order: -/// - Explicit metadata URL. -/// - Local provider storage file. -/// - Nextcloud direct download URL. struct NCVideoURLResolver { private let utilityFileSystem = NCUtilityFileSystem() - /// Resolves the playable URL for a video metadata object. - /// - /// - Parameter metadata: Video metadata. - /// - Returns: Resolved video URL, autoplay preference, and Nextcloud error. func getVideoURL( metadata: tableMetadata ) async -> (url: URL?, autoplay: Bool, error: NKError) { @@ -769,10 +660,6 @@ struct NCVideoURLResolver { return await getDirectDownloadURL(metadata: metadata) } - /// Resolves a direct download URL from Nextcloud. - /// - /// - Parameter metadata: Video metadata. - /// - Returns: Direct download URL, autoplay preference, and Nextcloud error. private func getDirectDownloadURL( metadata: tableMetadata ) async -> (url: URL?, autoplay: Bool, error: NKError) { diff --git a/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCPresenter.swift b/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCPresenter.swift index 8b96cd497a..b31485c4c2 100644 --- a/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCPresenter.swift +++ b/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCPresenter.swift @@ -6,37 +6,16 @@ import UIKit import NextcloudKit // MARK: - VLC Presenter - -/// Presents one UIKit-only VLC fallback viewer outside the SwiftUI paging hierarchy. -/// -/// This presenter guarantees that only one VLC viewer is presented at a time. @MainActor enum NCVideoVLCPresenter { // MARK: - State - private static weak var currentViewController: NCVideoVLCViewController? private static var currentURL: URL? private static var isPresenting = false // MARK: - Public API - - /// Presents the VLC fallback viewer from the current top view controller. - /// - /// Repeated calls with the same URL are ignored to avoid multiple VLC instances - /// during SwiftUI recomposition or device rotation. - /// - /// - Parameters: - /// - metadata: Video metadata used for logging. - /// - url: Local or remote playable URL. - /// - previewURL: Optional local preview image URL shown until VLC starts rendering. - /// - userAgent: Optional HTTP User-Agent for remote playback. - /// - contextMenuController: Main tab bar controller used by context menu actions. - /// - canGoPrevious: Whether VLC can navigate to the previous media item. - /// - canGoNext: Whether VLC can navigate to the next media item. - /// - onPrevious: Callback invoked when VLC receives a right swipe. - /// - onNext: Callback invoked when VLC receives a left swipe. - /// - onClose: Callback invoked with the current media ocId when VLC closes the fullscreen media viewer. + // Presents or updates the single VLC fullscreen controller. static func present( metadata: tableMetadata, url: URL, @@ -158,11 +137,6 @@ enum NCVideoVLCPresenter { } } - /// Clears the current VLC presentation state. - /// - /// Call this from `NCVideoVLCViewController` when it closes. - /// - /// - Parameter viewController: VLC view controller being closed. static func clearCurrent( _ viewController: NCVideoVLCViewController ) { @@ -175,7 +149,6 @@ enum NCVideoVLCPresenter { isPresenting = false } - /// Dismisses the current VLC viewer if one is currently presented. static func dismissCurrent() { guard let currentViewController else { return @@ -186,17 +159,11 @@ enum NCVideoVLCPresenter { } } - /// Dismisses the current VLC viewer if one is currently presented. - /// - /// This short alias is used by video-page navigation callbacks before moving - /// the SwiftUI media viewer to the previous or next page. static func dismiss() { dismissCurrent() } // MARK: - Private - - /// Resolves the top-most visible view controller. private static func topViewController() -> UIViewController? { let windowScene = UIApplication.shared.connectedScenes .compactMap { $0 as? UIWindowScene } @@ -210,10 +177,6 @@ enum NCVideoVLCPresenter { return visibleViewController(from: rootViewController) } - /// Recursively resolves the visible view controller. - /// - /// - Parameter viewController: Root or intermediate view controller. - /// - Returns: Top-most visible view controller. private static func visibleViewController( from viewController: UIViewController? ) -> UIViewController? { diff --git a/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCViewController.swift b/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCViewController.swift index b55394a124..979f436765 100644 --- a/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCViewController.swift +++ b/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCViewController.swift @@ -11,10 +11,6 @@ import UniformTypeIdentifiers // MARK: - VLC View Controller -/// UIKit-only VLC video controller. -/// -/// This controller is intentionally outside the SwiftUI paging hierarchy. -/// It owns one stable drawable view, one VLCMediaPlayer, and one shared controls view. final class NCVideoVLCViewController: UIViewController { // MARK: - Input @@ -225,16 +221,6 @@ final class NCVideoVLCViewController: UIViewController { // MARK: - Public API - /// Updates the current VLC input. - /// - /// If the URL changes, the current media is stopped and the new media is prepared. - /// The context menu is refreshed for the new metadata. - /// - /// - Parameters: - /// - metadata: Updated video metadata. - /// - url: Updated playable URL. - /// - previewURL: Optional local preview image URL shown until VLC starts rendering. - /// - userAgent: Optional HTTP User-Agent. func update( metadata: tableMetadata, url: URL, @@ -268,7 +254,6 @@ final class NCVideoVLCViewController: UIViewController { // MARK: - Navigation - /// Configures the navigation bar items. private func configureNavigationItem() { title = nil navigationItem.title = nil @@ -287,7 +272,6 @@ final class NCVideoVLCViewController: UIViewController { ] } - /// Configures the floating title view inside the navigation bar chrome. private func configureFloatingTitleViewIfNeeded() { guard let navigationBar = navigationController?.navigationBar else { return @@ -296,9 +280,6 @@ final class NCVideoVLCViewController: UIViewController { floatingTitleView.attach(to: navigationBar) } - /// Updates the floating title view using the provided video metadata. - /// - /// - Parameter metadata: Video metadata used to build the visible title content. private func updateTitleLabel(metadata: tableMetadata) { let primaryTitle = metadata.fileNameView.isEmpty ? metadata.fileName @@ -311,23 +292,15 @@ final class NCVideoVLCViewController: UIViewController { ) } - /// Builds the secondary floating title text for the provided metadata. - /// - /// - Parameter metadata: Video metadata used to derive the secondary title line. - /// - Returns: Secondary title text shown below the main title. private func floatingTitleSecondaryText(for metadata: tableMetadata) -> String? { floatingTitleDateFormatter.string(from: metadata.date as Date) } - /// Rebuilds the More menu using the current metadata. private func refreshMoreMenu() { moreNavigationItem.menu = makeMoreMenu() } - /// Builds the VLC-specific More menu. - /// - /// The menu uses `sender: self`, so menu actions present from the visible - /// VLC controller instead of the SwiftUI viewer underneath. + // Use this controller as sender so actions present above VLC. private func makeMoreMenu() -> UIMenu { UIMenu(title: "", children: [ UIDeferredMenuElement.uncached { [weak self] completion in @@ -361,11 +334,6 @@ final class NCVideoVLCViewController: UIViewController { presentDetailView(animated: true) } - /// Presents the media metadata detail panel for the current video. - /// - /// Video metadata usually has no EXIF payload, so the detail view receives an empty EXIF model. - /// - /// - Parameter animated: Whether presentation should be animated. private func presentDetailView(animated: Bool) { let detailView = NCMediaViewerDetailView( metadata: metadata, @@ -417,7 +385,6 @@ final class NCVideoVLCViewController: UIViewController { // MARK: - Swipe Navigation - /// Configures UIKit swipe gestures for media navigation and viewer closing. private func configureSwipeGestures() { let swipeLeft = UISwipeGestureRecognizer( target: self, @@ -444,7 +411,6 @@ final class NCVideoVLCViewController: UIViewController { view.addGestureRecognizer(closePanGesture) } - /// Configures a single tap gesture to toggle VLC playback controls. private func configureTapGesture() { let tapGesture = UITapGestureRecognizer( target: self, @@ -456,12 +422,7 @@ final class NCVideoVLCViewController: UIViewController { view.addGestureRecognizer(tapGesture) } - /// Handles single taps by toggling the VLC playback controls. - /// - /// Taps are ignored while playback is not running because controls and the - /// navigation bar must remain visible in prepared, paused, and stopped states. - /// - /// - Parameter gesture: Source tap gesture recognizer. + // Keep controls visible when playback is not running. @objc private func handleSingleTap(_ gesture: UITapGestureRecognizer) { guard !shouldKeepControlsVisible else { @@ -484,14 +445,6 @@ final class NCVideoVLCViewController: UIViewController { } } - /// Handles horizontal VLC swipe gestures. - /// - /// Left moves to the next media item when available. - /// Right moves to the previous media item when available. - /// The controller itself does not know the media list; it only forwards the intent - /// through callbacks owned by the presenter/viewer layer. - /// - /// - Parameter gesture: Source swipe gesture recognizer. @objc private func handleSwipe(_ gesture: UISwipeGestureRecognizer) { guard !isScrubbing else { @@ -515,13 +468,7 @@ final class NCVideoVLCViewController: UIViewController { } } - /// Handles downward pan gestures by closing the VLC viewer. - /// - /// This mirrors the common media viewer drag-to-close behavior: a short downward - /// drag or a quick downward flick is enough, while horizontal paging still wins - /// when the gesture is mostly horizontal. - /// - /// - Parameter gesture: Source pan gesture recognizer. + // Close only when downward movement wins over horizontal paging. @objc private func handleClosePan(_ gesture: UIPanGestureRecognizer) { let translation = gesture.translation(in: view) @@ -554,7 +501,6 @@ final class NCVideoVLCViewController: UIViewController { // MARK: - Playback - /// Prepares VLC playback without starting it automatically. private func start() { attachDrawable() showPreviewImage() @@ -583,7 +529,6 @@ final class NCVideoVLCViewController: UIViewController { ) } - /// Stops VLC playback and releases resources. private func stop() { mediaPlayer.stop() mediaPlayer.media = nil @@ -596,7 +541,6 @@ final class NCVideoVLCViewController: UIViewController { clearVLCTrackMenuItems() } - /// Attaches the drawable view to VLC. private func attachDrawable() { guard drawableView.bounds.width > 0, drawableView.bounds.height > 0 else { @@ -609,7 +553,6 @@ final class NCVideoVLCViewController: UIViewController { } } - /// Handles VLC playback state changes. private func handleMediaPlayerStateChange() { updatePlayPauseButton() updateProgressControls() @@ -624,11 +567,7 @@ final class NCVideoVLCViewController: UIViewController { scheduleControlsHideIfNeededAfterPlaybackStart() } - /// Arms the controls auto-hide timer when VLC is confirmed to be playing. - /// - /// VLC state notifications and `isPlaying` may not become true at exactly the same - /// time. This helper is safe to call from both state and time callbacks because it - /// does not restart an already scheduled timer. + // Safe to call from both state and time callbacks. private func scheduleControlsHideIfNeededAfterPlaybackStart() { guard !shouldKeepControlsVisible else { return @@ -648,19 +587,16 @@ final class NCVideoVLCViewController: UIViewController { // MARK: - VLC Track Menus - /// Refreshes the SwiftUI track menus using the current VLC player state. func refreshVLCTrackMenuItems() { controlsView.setSubtitleTrackMenuItems(makeSubtitleTrackMenuItems()) controlsView.setAudioTrackMenuItems(makeAudioTrackMenuItems()) } - /// Clears the SwiftUI track menus while VLC has not exposed media tracks yet. func clearVLCTrackMenuItems() { controlsView.setSubtitleTrackMenuItems([]) controlsView.setAudioTrackMenuItems([]) } - /// Refreshes the SwiftUI track menus only when VLC is active enough to expose tracks. func refreshVLCTrackMenuItemsWhenPlayerIsActive() { switch mediaPlayer.state { case .opening, .buffering, .playing, .paused: @@ -670,9 +606,6 @@ final class NCVideoVLCViewController: UIViewController { } } - /// Selects a VLC subtitle track and persists the selection for the current metadata. - /// - /// - Parameter index: VLC subtitle track index selected by the user. func selectSubtitleTrack(index: Int32) { mediaPlayer.currentVideoSubTitleIndex = index NCManageDatabase.shared.addVideo( @@ -682,9 +615,6 @@ final class NCVideoVLCViewController: UIViewController { refreshVLCTrackMenuItems() } - /// Selects a VLC audio track and persists the selection for the current metadata. - /// - /// - Parameter index: VLC audio track index selected by the user. func selectAudioTrack(index: Int32) { mediaPlayer.currentAudioTrackIndex = index NCManageDatabase.shared.addVideo( @@ -694,7 +624,6 @@ final class NCVideoVLCViewController: UIViewController { refreshVLCTrackMenuItems() } - /// Presents a document picker that lets the user select an external subtitle file for VLC playback. func presentExternalSubtitlePicker() { let picker = UIDocumentPickerViewController( forOpeningContentTypes: [.item], @@ -705,10 +634,6 @@ final class NCVideoVLCViewController: UIViewController { present(picker, animated: true) } - /// Returns whether the selected file extension is supported as an external subtitle. - /// - /// - Parameter url: File URL selected by the user. - /// - Returns: True when VLC should try to load the file as an external subtitle. private func isSupportedExternalSubtitleURL(_ url: URL) -> Bool { let supportedExtensions: Set = [ "srt", @@ -721,9 +646,6 @@ final class NCVideoVLCViewController: UIViewController { return supportedExtensions.contains(url.pathExtension.lowercased()) } - /// Loads an external subtitle file into the current VLC media player. - /// - /// - Parameter url: Local subtitle file URL selected by the user. private func loadExternalSubtitle(url: URL) { guard isSupportedExternalSubtitleURL(url) else { nkLog( @@ -757,10 +679,7 @@ final class NCVideoVLCViewController: UIViewController { } } - /// Copies the selected subtitle to a stable temporary file that VLC can read. - /// - /// - Parameter url: Security-scoped or temporary document picker URL. - /// - Returns: Local temporary file URL used by VLC. + // Copy to a stable temporary file readable by VLC. private func copyExternalSubtitleToTemporaryDirectory(from url: URL) throws -> URL { let didStartAccessing = url.startAccessingSecurityScopedResource() defer { @@ -795,7 +714,6 @@ final class NCVideoVLCViewController: UIViewController { return destinationURL } - /// Refreshes VLC subtitle tracks after VLC has had time to register the external subtitle file. private func refreshExternalSubtitleTracksAfterLoad() { refreshVLCTrackMenuItems() @@ -805,9 +723,6 @@ final class NCVideoVLCViewController: UIViewController { } } - /// Builds subtitle menu items from VLC subtitle tracks. - /// - /// - Returns: Subtitle menu items rendered by the shared SwiftUI controls. private func makeSubtitleTrackMenuItems() -> [NCVideoTrackMenuItem] { makeTrackMenuItems( titles: mediaPlayer.videoSubTitlesNames, @@ -816,9 +731,6 @@ final class NCVideoVLCViewController: UIViewController { ) } - /// Builds audio menu items from VLC audio tracks. - /// - /// - Returns: Audio menu items rendered by the shared SwiftUI controls. private func makeAudioTrackMenuItems() -> [NCVideoTrackMenuItem] { makeTrackMenuItems( titles: mediaPlayer.audioTrackNames, @@ -827,9 +739,6 @@ final class NCVideoVLCViewController: UIViewController { ) } - /// Returns the persisted subtitle track index, falling back to VLC's current subtitle track index. - /// - /// - Returns: Current subtitle track index used to mark the selected menu item. private func currentSubtitleTrackIndex() -> Int? { if let data = NCManageDatabase.shared.getVideo(metadata: metadata), let currentVideoSubTitleIndex = data.currentVideoSubTitleIndex { @@ -839,9 +748,6 @@ final class NCVideoVLCViewController: UIViewController { return Int(mediaPlayer.currentVideoSubTitleIndex) } - /// Returns the persisted audio track index, falling back to VLC's current audio track index. - /// - /// - Returns: Current audio track index used to mark the selected menu item. private func currentAudioTrackIndex() -> Int? { if let data = NCManageDatabase.shared.getVideo(metadata: metadata), let currentAudioTrackIndex = data.currentAudioTrackIndex { @@ -851,13 +757,6 @@ final class NCVideoVLCViewController: UIViewController { return Int(mediaPlayer.currentAudioTrackIndex) } - /// Builds SwiftUI menu items from VLC track names and indexes. - /// - /// - Parameters: - /// - titles: VLC track titles. - /// - indexes: VLC track indexes. - /// - currentIndex: Currently selected VLC track index. - /// - Returns: Track menu items with selection state. private func makeTrackMenuItems( titles: [Any], indexes: [Any], @@ -877,12 +776,6 @@ final class NCVideoVLCViewController: UIViewController { } } - /// Normalizes a VLC track index to Int32. - /// - /// - Parameters: - /// - indexes: VLC track indexes returned by MobileVLCKit. - /// - index: Position to read. - /// - Returns: Normalized VLC track index, if available. private func normalizedTrackIndex( _ indexes: [Any], at index: Int @@ -905,7 +798,6 @@ final class NCVideoVLCViewController: UIViewController { // MARK: - Helpers - /// Updates the fullscreen preview image shown before VLC starts rendering video. private func updatePreviewImage() { guard let previewURL, previewURL.isFileURL else { @@ -919,7 +811,6 @@ final class NCVideoVLCViewController: UIViewController { previewImageView.alpha = 1 } - /// Shows the preview image while VLC prepares the first rendered frame. private func showPreviewImage() { guard previewImageView.image != nil else { previewImageView.isHidden = true @@ -931,7 +822,6 @@ final class NCVideoVLCViewController: UIViewController { previewImageView.isHidden = false } - /// Hides the preview image after VLC starts rendering playback. private func hidePreviewImage() { guard !previewImageView.isHidden else { return @@ -942,15 +832,10 @@ final class NCVideoVLCViewController: UIViewController { previewImageView.isHidden = true } - /// Updates the shared controls top actions reference using the real navigation bar. private func updateControlsNavigationBar() { controlsView.setTopActionsNavigationBar(navigationController?.navigationBar) } - /// Returns whether a point is inside one of the visible controls areas. - /// - /// - Parameter location: Point in this controller's root view coordinate space. - /// - Returns: True when the point is inside top action, center, or bottom controls. private func controlsHitFramesContain(_ location: CGPoint) -> Bool { let topActionsFrame = controlsView.topActionsView.convert( controlsView.topActionsView.bounds, @@ -970,7 +855,6 @@ final class NCVideoVLCViewController: UIViewController { || bottomControlsFrame.contains(location) } - /// Configures the audio session for movie playback. private func configureAudioSession() { do { try AVAudioSession.sharedInstance().setCategory( @@ -1015,12 +899,7 @@ extension NCVideoVLCViewController: VLCMediaPlayerDelegate { // MARK: - Gesture Delegate extension NCVideoVLCViewController: UIGestureRecognizerDelegate { - /// Allows tap and swipe gestures to coexist with VLC's drawable view and UIKit controls. - /// - /// - Parameters: - /// - gestureRecognizer: Gesture recognizer asking for simultaneous recognition. - /// - otherGestureRecognizer: Other gesture recognizer involved in the decision. - /// - Returns: True to avoid VLC/touch handling from suppressing viewer gestures. + // Keep VLC drawable touches compatible with viewer gestures. func gestureRecognizer( _ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer @@ -1028,12 +907,7 @@ extension NCVideoVLCViewController: UIGestureRecognizerDelegate { true } - /// Prevents the background tap recognizer from stealing touches that begin on controls. - /// - /// - Parameters: - /// - gestureRecognizer: Gesture recognizer asking whether it should receive the touch. - /// - touch: Source touch. - /// - Returns: False for visible playback controls, true otherwise. + // Do not let background taps steal control touches. func gestureRecognizer( _ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch @@ -1051,10 +925,6 @@ extension NCVideoVLCViewController: UIGestureRecognizerDelegate { return true } - /// Allows the close pan to start only when the gesture is mainly downward. - /// - /// - Parameter gestureRecognizer: Gesture recognizer asking whether it should begin. - /// - Returns: True for non-pan gestures or downward-dominant pan gestures. func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { guard gestureRecognizer is UIPanGestureRecognizer else { return true @@ -1077,11 +947,6 @@ extension NCVideoVLCViewController: UIGestureRecognizerDelegate { // MARK: - Document Picker Delegate extension NCVideoVLCViewController: UIDocumentPickerDelegate { - /// Handles the selected external subtitle file and attaches it to the VLC player. - /// - /// - Parameters: - /// - controller: Document picker controller. - /// - urls: Selected file URLs. func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) { guard let url = urls.first else { return @@ -1091,9 +956,6 @@ extension NCVideoVLCViewController: UIDocumentPickerDelegate { showControls(animated: true) } - /// Handles document picker cancellation. - /// - /// - Parameter controller: Document picker controller. func documentPickerWasCancelled(_ controller: UIDocumentPickerViewController) { showControls(animated: true) } diff --git a/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCViewControls.swift b/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCViewControls.swift index ca03b5930c..9794211d05 100644 --- a/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCViewControls.swift +++ b/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCViewControls.swift @@ -4,7 +4,6 @@ import MobileVLCKit // MARK: - Playback Controls extension NCVideoVLCViewController { - /// Seeks ten seconds backward in the current VLC media. @objc func seekBackwardTapped() { showControls(animated: true) @@ -12,7 +11,6 @@ extension NCVideoVLCViewController { seek(byMilliseconds: -10_000) } - /// Toggles VLC playback. @objc func playPauseTapped() { showControls(animated: true) @@ -29,7 +27,6 @@ extension NCVideoVLCViewController { updateProgressControls() } - /// Seeks ten seconds forward in the current VLC media. @objc func seekForwardTapped() { showControls(animated: true) @@ -37,9 +34,6 @@ extension NCVideoVLCViewController { seek(byMilliseconds: 10_000) } - /// Moves the current VLC playback time by a relative millisecond offset. - /// - /// - Parameter deltaMilliseconds: Relative seek offset in milliseconds. func seek(byMilliseconds deltaMilliseconds: Int32) { let duration = mediaPlayer.media?.length.intValue ?? 0 guard duration > 0 else { @@ -59,12 +53,10 @@ extension NCVideoVLCViewController { updateProgressControls() } - /// Updates the play/pause button icon from the current VLC playback state. func updatePlayPauseButton() { controlsView.updatePlayPauseButton(isPlaying: mediaPlayer.isPlaying) } - /// Starts periodic progress updates. func startProgressTimer() { stopProgressTimer() @@ -76,13 +68,11 @@ extension NCVideoVLCViewController { } } - /// Stops periodic progress updates. func stopProgressTimer() { progressTimer?.invalidate() progressTimer = nil } - /// Updates slider and time labels from the current VLC playback position. func updateProgressControls() { guard !isScrubbing else { return @@ -93,9 +83,6 @@ extension NCVideoVLCViewController { updatePlayPauseButton() } - /// Updates elapsed and remaining time labels. - /// - /// - Parameter position: Normalized playback position between 0 and 1. func updateProgressLabels(position: Float) { let duration = mediaPlayer.media?.length.intValue ?? 0 let elapsed = Int(Float(duration) * position) @@ -108,10 +95,6 @@ extension NCVideoVLCViewController { ) } - /// Formats milliseconds as a compact playback time. - /// - /// - Parameter milliseconds: Time value in milliseconds. - /// - Returns: Formatted time string. func formatPlaybackTime(milliseconds: Int) -> String { let totalSeconds = max(0, milliseconds / 1000) let hours = totalSeconds / 3600 @@ -127,11 +110,7 @@ extension NCVideoVLCViewController { } // MARK: - Controls Visibility - extension NCVideoVLCViewController { - /// Shows the VLC playback controls. - /// - /// - Parameter animated: Whether the visibility change should be animated. internal func showControls(animated: Bool) { setNavigationBarVisible( true, @@ -141,9 +120,6 @@ extension NCVideoVLCViewController { setControlsVisible(true, animated: animated) } - /// Hides the VLC playback controls. - /// - /// - Parameter animated: Whether the visibility change should be animated. internal func hideControls(animated: Bool) { guard !shouldKeepControlsVisible else { showControls(animated: false) @@ -160,11 +136,6 @@ extension NCVideoVLCViewController { setControlsVisible(false, animated: animated) } - /// Applies the current controls visibility to the control views. - /// - /// - Parameters: - /// - visible: Whether controls should be visible. - /// - animated: Whether the visibility change should be animated. internal func setControlsVisible(_ visible: Bool, animated: Bool) { let changes = { self.controlsView.alpha = visible ? 1 : 0 @@ -193,7 +164,6 @@ extension NCVideoVLCViewController { ) } - /// Schedules automatic hiding for the VLC playback controls. internal func scheduleControlsHide() { stopControlsHideTimer() @@ -220,7 +190,6 @@ extension NCVideoVLCViewController { } } - /// Stops the automatic controls hide timer. internal func stopControlsHideTimer() { controlsHideTimer?.invalidate() controlsHideTimer = nil @@ -228,108 +197,63 @@ extension NCVideoVLCViewController { } // MARK: - Shared Controls Delegate - extension NCVideoVLCViewController: NCVideoControlsViewDelegate { - /// Handles the shared controls backward seek action. - /// - /// - Parameter controlsView: Shared controls view that emitted the action. func videoControlsDidTapSeekBackward(_ controlsView: NCVideoControlsView) { seekBackwardTapped() } - /// Handles the shared controls play/pause action. - /// - /// - Parameter controlsView: Shared controls view that emitted the action. func videoControlsDidTapPlayPause(_ controlsView: NCVideoControlsView) { playPauseTapped() } - /// Handles the shared controls forward seek action. - /// - /// - Parameter controlsView: Shared controls view that emitted the action. func videoControlsDidTapSeekForward(_ controlsView: NCVideoControlsView) { seekForwardTapped() } - /// Handles the Picture in Picture action from the shared controls view. - /// - /// - Parameter controlsView: Shared controls view that emitted the action. + // VLC does not expose Picture in Picture controls. func videoControlsDidTapPictureInPicture(_ controlsView: NCVideoControlsView) { - // VLC does not expose Picture in Picture controls. } - /// Handles the beginning of slider scrubbing from the shared controls view. - /// - /// - Parameter controlsView: Shared controls view that emitted the action. func videoControlsDidBeginScrubbing(_ controlsView: NCVideoControlsView) { showControls(animated: true) stopControlsHideTimer() isScrubbing = true } - /// Handles the VLC subtitle track action from the shared controls view. - /// - /// - Parameter controlsView: Shared controls view that emitted the action. func videoControlsDidTapSubtitle(_ controlsView: NCVideoControlsView) { showControls(animated: true) stopControlsHideTimer() refreshVLCTrackMenuItemsWhenPlayerIsActive() } - /// Handles the VLC audio track action from the shared controls view. - /// - /// - Parameter controlsView: Shared controls view that emitted the action. func videoControlsDidTapAudio(_ controlsView: NCVideoControlsView) { showControls(animated: true) stopControlsHideTimer() refreshVLCTrackMenuItemsWhenPlayerIsActive() } - /// Handles the external subtitle import action from the shared controls view. - /// - /// - Parameter controlsView: Shared controls view that emitted the action. func videoControlsDidTapAddExternalSubtitle(_ controlsView: NCVideoControlsView) { showControls(animated: true) stopControlsHideTimer() presentExternalSubtitlePicker() } - /// Handles VLC subtitle track selection from the SwiftUI controls menu. - /// - /// - Parameters: - /// - controlsView: Shared controls view that emitted the action. - /// - index: VLC subtitle track index selected by the user. func videoControls(_ controlsView: NCVideoControlsView, didSelectSubtitleTrackIndex index: Int32) { showControls(animated: true) stopControlsHideTimer() selectSubtitleTrack(index: index) } - /// Handles VLC audio track selection from the SwiftUI controls menu. - /// - /// - Parameters: - /// - controlsView: Shared controls view that emitted the action. - /// - index: VLC audio track index selected by the user. func videoControls(_ controlsView: NCVideoControlsView, didSelectAudioTrackIndex index: Int32) { showControls(animated: true) stopControlsHideTimer() selectAudioTrack(index: index) } - /// Updates VLC time labels while scrubbing from the shared controls view. - /// - /// - Parameters: - /// - controlsView: Shared controls view that emitted the action. - /// - progress: Normalized target progress between 0 and 1. func videoControls(_ controlsView: NCVideoControlsView, didScrubTo progress: Float) { updateProgressLabels(position: progress) } - /// Applies the selected VLC playback position after scrubbing ends. - /// - /// - Parameters: - /// - controlsView: Shared controls view that emitted the action. - /// - progress: Normalized target progress between 0 and 1. func videoControlsDidEndScrubbing(_ controlsView: NCVideoControlsView, progress: Float) { mediaPlayer.position = progress isScrubbing = false diff --git a/iOSClient/Viewer/NCViewerMedia/Helpers/NCViewerAppearance.swift b/iOSClient/Viewer/NCViewerMedia/Helpers/NCViewerAppearance.swift index 18fa6ede54..7ba1e186c6 100644 --- a/iOSClient/Viewer/NCViewerMedia/Helpers/NCViewerAppearance.swift +++ b/iOSClient/Viewer/NCViewerMedia/Helpers/NCViewerAppearance.swift @@ -7,29 +7,15 @@ import UIKit import NextcloudKit // MARK: - Viewer Background Style - -/// Defines the background style used by viewer containers and media pages. enum NCViewerBackgroundStyle { - /// Uses the current system appearance. case system - - /// Always uses black, useful for video and cinema-style media viewers. case black - - /// Always uses white, useful for document-like viewers. case white - - /// Uses a custom UIKit color. case custom(UIColor) } // MARK: - UIColor Viewer Background - extension UIColor { - /// Returns the background color for a viewer background style. - /// - /// - Parameter style: Viewer background style. - /// - Returns: Resolved UIKit background color. static func ncViewerBackground(_ style: NCViewerBackgroundStyle = .system) -> UIColor { switch style { case .system: @@ -45,24 +31,14 @@ extension UIColor { } // MARK: - Color Viewer Background - extension Color { - /// Returns the background color for a viewer background style. - /// - /// - Parameter style: Viewer background style. - /// - Returns: Resolved SwiftUI background color. static func ncViewerBackground(_ style: NCViewerBackgroundStyle = .system) -> Color { Color(uiColor: .ncViewerBackground(style)) } } // MARK: - Color Viewer Progress Tint - extension Color { - /// Returns a readable progress tint color for a viewer background style. - /// - /// - Parameter style: Viewer background style. - /// - Returns: SwiftUI tint color suitable for loading indicators. static func ncViewerProgressTint(_ style: NCViewerBackgroundStyle = .system) -> Color { switch style { case .black: @@ -77,11 +53,6 @@ extension Color { } // MARK: - Viewer Background Resolution - -/// Returns the preferred viewer background style for a metadata item. -/// -/// - Parameter metadata: Optional detached metadata. -/// - Returns: Background style preferred for the media type. func ncViewerBackgroundStyle(for metadata: tableMetadata?) -> NCViewerBackgroundStyle { guard let metadata else { return .system diff --git a/iOSClient/Viewer/NCViewerMedia/Helpers/NCViewerTransitionSource.swift b/iOSClient/Viewer/NCViewerMedia/Helpers/NCViewerTransitionSource.swift index c95b459005..4da0cf91cb 100644 --- a/iOSClient/Viewer/NCViewerMedia/Helpers/NCViewerTransitionSource.swift +++ b/iOSClient/Viewer/NCViewerMedia/Helpers/NCViewerTransitionSource.swift @@ -5,27 +5,13 @@ import UIKit // MARK: - Viewer Transition Source - -/// Describes the visual source used to animate the media viewer presentation. -/// -/// The transition starts from the thumbnail currently visible in the source UI -/// and expands it to the final image frame inside the fullscreen viewer. struct NCViewerTransitionSource { - /// Image currently visible in the source cell. let image: UIImage - /// Thumbnail frame converted to window coordinates. let sourceFrame: CGRect - /// Corner radius used by the source thumbnail. let cornerRadius: CGFloat - /// Creates a media viewer transition source. - /// - /// - Parameters: - /// - image: Image currently visible in the source cell. - /// - sourceFrame: Thumbnail frame converted to window coordinates. - /// - cornerRadius: Corner radius used by the source thumbnail. init(image: UIImage, sourceFrame: CGRect, cornerRadius: CGFloat = 0) { self.image = image self.sourceFrame = sourceFrame diff --git a/iOSClient/Viewer/NCViewerMedia/Loading/NCNextcloudMediaViewerLoader.swift b/iOSClient/Viewer/NCViewerMedia/Loading/NCNextcloudMediaViewerLoader.swift index 64e98241f8..6601e27980 100644 --- a/iOSClient/Viewer/NCViewerMedia/Loading/NCNextcloudMediaViewerLoader.swift +++ b/iOSClient/Viewer/NCViewerMedia/Loading/NCNextcloudMediaViewerLoader.swift @@ -6,16 +6,6 @@ import Foundation import NextcloudKit // MARK: - Media Viewer Loader - -/// Concrete media viewer loader for the Nextcloud app. -/// -/// This object is responsible for: -/// - resolving detached metadata from `ocId` -/// - checking if the full media file exists locally -/// - returning or downloading a preview file -/// - downloading the full media file when needed -/// -/// It must always return detached `tableMetadata` objects. final class NCMediaViewerLoader: NCMediaViewerLoading, @unchecked Sendable { private let database = NCManageDatabase.shared private let global = NCGlobal.shared @@ -23,17 +13,6 @@ final class NCMediaViewerLoader: NCMediaViewerLoading, @unchecked Sendable { private let fileManager = FileManager.default // MARK: - NCMediaViewerLoading - - /// Resolves detached metadata from an `ocId`. - /// - /// The primary lookup uses the local Realm database. - /// If the metadata is not available locally, the numeric fileId is extracted - /// from the `ocId` and the file is resolved from the server. - /// - /// - Parameters: - /// - ocId: Nextcloud file identifier. - /// - account: Account used to scope the remote fileId lookup. - /// - Returns: Detached metadata if available. func metadata(for ocId: String, account: String, mediaSearch: Bool) async -> tableMetadata? { if let metadata = await database.getMetadataFromOcIdAsync(ocId) { return metadata @@ -59,14 +38,6 @@ final class NCMediaViewerLoader: NCMediaViewerLoading, @unchecked Sendable { return metadata } - /// Returns a local preview URL. - /// - /// This method first checks the local preview cache. If no preview exists, - /// it downloads one from the server and stores it using the existing app - /// preview cache pipeline. - /// - /// - Parameter metadata: Detached metadata for the media file. - /// - Returns: Local preview URL if available. func previewURL(for metadata: tableMetadata, index: Int) async -> URL? { let localPath = previewLocalPath(for: metadata) @@ -101,12 +72,6 @@ final class NCMediaViewerLoader: NCMediaViewerLoading, @unchecked Sendable { return URL(fileURLWithPath: localPath) } - /// Returns the local full media URL if the file is already available. - /// - /// This method never performs network requests. - /// - /// - Parameter metadata: Detached metadata for the media file. - /// - Returns: Local full media URL if available. func localMediaURL(for metadata: tableMetadata, index: Int) async -> URL? { let localPath = fullLocalPath(for: metadata) @@ -119,10 +84,6 @@ final class NCMediaViewerLoader: NCMediaViewerLoading, @unchecked Sendable { return URL(fileURLWithPath: localPath) } - /// Downloads the full media file if needed. - /// - /// - Parameter metadata: Detached metadata for the media file. - /// - Returns: Local full media URL after completion. func downloadMedia(for metadata: tableMetadata, index: Int) async throws -> URL { if let localURL = await localMediaURL(for: metadata, index: index) { nkLog(tag: NCGlobal.shared.logTagViewer, emoji: .debug, message: "FULL resolve \(index)", consoleOnly: true) @@ -161,12 +122,6 @@ final class NCMediaViewerLoader: NCMediaViewerLoading, @unchecked Sendable { throw NCMediaViewerLoaderError.localFileUnavailable } - /// Returns the local Live Photo paired media URL if available. - /// - /// - Parameters: - /// - metadata: Detached metadata for the main Live Photo image. - /// - index: Page index used for debug logs. - /// - Returns: Local paired Live Photo media URL if available. func localLivePhotoURL(for metadata: tableMetadata, index: Int) async -> URL? { guard metadata.isLivePhoto else { return nil @@ -188,15 +143,7 @@ final class NCMediaViewerLoader: NCMediaViewerLoading, @unchecked Sendable { return URL(fileURLWithPath: localPath) } - /// Downloads the Live Photo paired media if needed. - /// - /// This method is optional by design. If the paired media cannot be found or - /// downloaded, the viewer should continue to behave like a normal image viewer. - /// - /// - Parameters: - /// - metadata: Detached metadata for the main Live Photo image. - /// - index: Page index used for debug logs. - /// - Returns: Local paired Live Photo media URL if available. + // Live Photo fallback is optional; the image viewer can continue without it. func downloadLivePhotoMedia(for metadata: tableMetadata, index: Int) async -> URL? { guard metadata.isLivePhoto else { return nil @@ -250,11 +197,6 @@ final class NCMediaViewerLoader: NCMediaViewerLoading, @unchecked Sendable { } // MARK: - Private Helpers - - /// Builds the expected full local file path. - /// - /// - Parameter metadata: Detached metadata for the media file. - /// - Returns: Local full media file path. private func fullLocalPath(for metadata: tableMetadata) -> String { utilityFileSystem.getDirectoryProviderStorageOcId( metadata.ocId, @@ -264,10 +206,6 @@ final class NCMediaViewerLoader: NCMediaViewerLoading, @unchecked Sendable { ) } - /// Builds the expected local preview file path. - /// - /// - Parameter metadata: Detached metadata for the media file. - /// - Returns: Local preview file path. private func previewLocalPath(for metadata: tableMetadata) -> String { utilityFileSystem.getDirectoryProviderStorageImageOcId( metadata.ocId, @@ -278,10 +216,6 @@ final class NCMediaViewerLoader: NCMediaViewerLoading, @unchecked Sendable { ) } - /// Checks whether a local file exists and has a non-zero size. - /// - /// - Parameter path: Local file path. - /// - Returns: True when the file exists and is not empty. private func isValidLocalFile(path: String) -> Bool { guard !path.isEmpty else { return false @@ -301,9 +235,6 @@ final class NCMediaViewerLoader: NCMediaViewerLoading, @unchecked Sendable { } } -// MARK: - Loader Error - -/// Errors thrown by the media viewer loader. enum NCMediaViewerLoaderError: LocalizedError { case localFileUnavailable @@ -315,49 +246,16 @@ enum NCMediaViewerLoaderError: LocalizedError { } } -// MARK: - Media Viewer Loading - -/// Defines the loading operations required by the media viewer. protocol NCMediaViewerLoading: Sendable { - /// Resolves detached metadata from an `ocId`. - /// - /// - Parameter ocId: Nextcloud file identifier. - /// - Returns: Detached metadata if available. func metadata(for ocId: String, account: String, mediaSearch: Bool) async -> tableMetadata? - /// - Parameters: - /// - metadata: Detached metadata for the media file. - /// - index: Page index used for debug logs. - /// - Returns: Local full media URL if available. func localMediaURL(for metadata: tableMetadata, index: Int) async -> URL? - /// Returns a local preview URL. - /// - /// The implementation can return a cached preview or download one if needed. - /// - /// - Parameter metadata: Detached metadata for the media file. - /// - Returns: Local preview URL if available. func previewURL(for metadata: tableMetadata, index: Int) async -> URL? - /// Downloads the full media file if needed. - /// - /// - Parameter metadata: Detached metadata for the media file. - /// - Returns: Local full media URL after completion. func downloadMedia(for metadata: tableMetadata, index: Int) async throws -> URL - /// Returns the local Live Photo paired media URL if available. - /// - /// - Parameters: - /// - metadata: Detached metadata for the main Live Photo image. - /// - index: Page index used for debug logs. - /// - Returns: Local paired Live Photo media URL if available. func localLivePhotoURL(for metadata: tableMetadata, index: Int) async -> URL? - /// Downloads the Live Photo paired media if needed. - /// - /// - Parameters: - /// - metadata: Detached metadata for the main Live Photo image. - /// - index: Page index used for debug logs. - /// - Returns: Local paired Live Photo media URL if available. func downloadLivePhotoMedia(for metadata: tableMetadata, index: Int) async -> URL? } diff --git a/iOSClient/Viewer/NCViewerMedia/Model - View/NCMediaViewerModel.swift b/iOSClient/Viewer/NCViewerMedia/Model - View/NCMediaViewerModel.swift index e5ffeb67c9..a29637fa63 100644 --- a/iOSClient/Viewer/NCViewerMedia/Model - View/NCMediaViewerModel.swift +++ b/iOSClient/Viewer/NCViewerMedia/Model - View/NCMediaViewerModel.swift @@ -7,79 +7,28 @@ import NextcloudKit // MARK: - Page State -/// Represents the loading state of a media viewer page. -/// -/// The page metadata is stored in `NCMediaViewerPageModel.metadata`. -/// This state only describes the current loading/rendering phase. enum NCMediaViewerPageState { - /// The page exists but no loading operation has started yet. case idle - - /// The page is resolving its `tableMetadata` from `ocId`. case loadingMetadata - - /// The metadata could not be found anymore. case metadataMissing - - /// Metadata exists and the viewer is checking if the full media file is already local. case checkingLocalFile - - /// Image page state. - /// - /// The same image view remains mounted while the page moves from preview - /// to full image. This avoids flickering caused by replacing SwiftUI view branches. case image(previewURL: URL?, localURL: URL?, livePhotoURL: URL?, progress: Double?) - - /// Video page state. - /// - /// Videos can be played from a local file, metadata URL, or Nextcloud direct - /// download URL. The video viewer resolves the final playback URL by itself. case video(previewURL: URL?) - - /// Remote media state with an optional preview and optional download progress. - /// - /// For video/audio, this can also represent a remote-only state where a preview - /// is available but the full media file has not been downloaded. case downloading(previewURL: URL?, progress: Double?) - - /// Non-image media is locally available. case ready(localURL: URL, previewURL: URL?) - case deleted - - /// The page failed while resolving metadata, checking local content, or downloading. case failed(previewURL: URL?, message: String) } // MARK: - Page Model -/// Represents one page inside the media viewer. -/// -/// The model does not create one page for every media item upfront. -/// Pages are created lazily when requested by the UIKit pager. struct NCMediaViewerPageModel: Identifiable { - /// Stable identifier used by SwiftUI. let id: String - - /// Absolute index inside the full `ocIds` array. let index: Int - - /// Nextcloud file identifier. let ocId: String - - /// Detached metadata if already available. var metadata: tableMetadata? - - /// Current loading state of the page. var state: NCMediaViewerPageState - /// Creates a page model. - /// - /// - Parameters: - /// - index: Absolute index inside the full `ocIds` array. - /// - ocId: Nextcloud file identifier. - /// - metadata: Detached metadata if already available. - /// - state: Initial page state. init(index: Int, ocId: String, metadata: tableMetadata? = nil, state: NCMediaViewerPageState = .idle) { self.id = ocId self.index = index @@ -91,25 +40,10 @@ struct NCMediaViewerPageModel: Identifiable { // MARK: - Initial Model -/// Initial model used to open the media viewer. -/// -/// The viewer receives: -/// - the current `tableMetadata` -/// - the ordered list of media `ocId` values -/// -/// The current metadata must be detached before being passed here. struct NCMediaViewerInitialModel { - /// Metadata of the initially opened media. let currentMetadata: tableMetadata - - /// Ordered list of all media identifiers. let ocIds: [String] - /// Creates the initial model for the media viewer. - /// - /// - Parameters: - /// - currentMetadata: Detached metadata of the initially opened media. - /// - ocIds: Ordered list of image/audio/video ocIds. init( currentMetadata: tableMetadata, ocIds: [String] @@ -118,9 +52,6 @@ struct NCMediaViewerInitialModel { self.ocIds = ocIds } - /// Returns the ordered list of page identifiers. - /// - /// The current `ocId` is inserted only if missing. var normalizedOcIds: [String] { if ocIds.contains(currentMetadata.ocId) { return ocIds @@ -129,9 +60,6 @@ struct NCMediaViewerInitialModel { } } - /// Returns the initial selected index. - /// - /// If the current `ocId` is not found, the model starts from index zero. var initialSelectedIndex: Int { normalizedOcIds.firstIndex(of: currentMetadata.ocId) ?? 0 } @@ -139,21 +67,13 @@ struct NCMediaViewerInitialModel { // MARK: - Loading Task Kind -/// Describes which loader owns a running page task. private enum NCMediaViewerLoadingTaskKind { - /// Task started because the page became selected. case selected - - /// Task started by neighbor prefetch. case prefetch } // MARK: - Loading Task -/// Stores a running media viewer loading task. -/// -/// The identifier prevents an old cancelled task from removing a newer task -/// stored under the same `ocId`. private struct NCMediaViewerLoadingTask { let identifier: UUID let kind: NCMediaViewerLoadingTaskKind @@ -162,45 +82,15 @@ private struct NCMediaViewerLoadingTask { // MARK: - Media Viewer Model -/// Model for the media viewer. -/// -/// This model is optimized for very large media lists. -/// It stores the full ordered `ocIds` array, but creates page models lazily only -/// when the pager asks for them. -/// -/// Responsibilities: -/// - keep the current selected index -/// - expose page count -/// - create page models lazily -/// - resolve metadata lazily -/// - request preview URLs -/// - check local media availability -/// - start full media downloads through the loader only for selected pages -/// - prefetch nearby pages without downloading full media -/// - update page states -/// -/// It does not render UI and does not directly access Realm, FileManager, -/// or networking APIs. Those responsibilities belong to `NCMediaViewerLoading`. +// Coordinates media paging, loading, and prefetching. @MainActor final class NCMediaViewerModel: ObservableObject { // MARK: - Published State - /// Currently selected absolute index inside the full `ocIds` array. @Published private(set) var selectedIndex: Int - - /// Incremented when a cached page changes. - /// - /// The UIKit paging coordinator observes this value and refreshes visible cells. @Published private(set) var revision: Int = 0 - - /// Whether the viewer chrome is currently hidden. - /// - /// When hidden, the navigation bar is hidden and the viewer uses a black - /// background for a cleaner fullscreen media experience. @Published private(set) var isChromeHidden = false - - /// Page index that should auto-start playback after navigation. @Published private(set) var autoPlayTargetIndex: Int? // MARK: - Dependencies @@ -209,43 +99,32 @@ final class NCMediaViewerModel: ObservableObject { // MARK: - Source Context - /// Session used to resolve account-scoped metadata fallback lookups. private let session: NCSession.Session - private let mediaSearch: Bool // MARK: - Source Data - /// Full ordered media identifier list. private let ocIds: [String] // MARK: - Page Cache - /// Page state cache keyed by `ocId`. - /// - /// Pages are created lazily when the pager asks for a specific index. + // Lazy page cache keyed by ocId. private var cachedPagesByOcId: [String: NCMediaViewerPageModel] = [:] // MARK: - Running Tasks - /// Running selected or prefetch loading tasks keyed by `ocId`. private var loadingTasksByOcId: [String: NCMediaViewerLoadingTask] = [:] // MARK: - Public Read-Only Access - /// Total number of media pages. var numberOfPages: Int { ocIds.count } - /// Initial selected index. var initialSelectedIndex: Int { selectedIndex } - /// Current selected media ocId. - /// - /// - Returns: The ocId for the currently selected page if available. var selectedOcId: String? { guard ocIds.indices.contains(selectedIndex) else { return nil @@ -254,9 +133,6 @@ final class NCMediaViewerModel: ObservableObject { return ocIds[selectedIndex] } - /// Current selected page metadata. - /// - /// - Returns: Detached metadata for the currently selected page if available. var selectedMetadata: tableMetadata? { guard ocIds.indices.contains(selectedIndex) else { return nil @@ -266,9 +142,6 @@ final class NCMediaViewerModel: ObservableObject { return cachedPagesByOcId[ocId]?.metadata } - /// Requests automatic playback for a target page index. - /// - /// - Parameter index: Target page index. func requestAutoPlay(at index: Int) { guard ocIds.indices.contains(index) else { return @@ -278,9 +151,6 @@ final class NCMediaViewerModel: ObservableObject { revision &+= 1 } - /// Clears the automatic playback request if it matches the provided index. - /// - /// - Parameter index: Page index that consumed auto-play. func clearAutoPlayIfNeeded(for index: Int) { guard autoPlayTargetIndex == index else { return @@ -290,12 +160,6 @@ final class NCMediaViewerModel: ObservableObject { revision &+= 1 } - /// Marks a page as deleted without removing it from the viewer list. - /// - /// This is used for optimistic UI updates when a delete operation has been - /// requested but the transfer delegate has not confirmed it yet. - /// - /// - Parameter ocId: Deleted file identifier. @MainActor func markPageAsDeleted(ocId: String) { NotificationCenter.default.post( @@ -312,12 +176,6 @@ final class NCMediaViewerModel: ObservableObject { // MARK: - Init - /// Creates a media viewer model. - /// - /// - Parameters: - /// - initialModel: Initial viewer model containing current metadata and ordered ocIds. - /// - session: Current Nextcloud session used for account-scoped metadata fallback lookups. - /// - loader: Loader used to resolve metadata, local URLs, previews, and downloads. init( initialModel: NCMediaViewerInitialModel, session: NCSession.Session, @@ -340,13 +198,6 @@ final class NCMediaViewerModel: ObservableObject { cachedPagesByOcId[initialModel.currentMetadata.ocId] = currentPage } - /// Creates a media viewer model from the current metadata and ordered media identifiers. - /// - /// - Parameters: - /// - currentMetadata: Detached metadata of the initially opened media. - /// - ocIds: Ordered list of image/audio/video ocIds. - /// - session: Current Nextcloud session used for account-scoped metadata fallback lookups. - /// - loader: Loader used to resolve metadata, local URLs, previews, and downloads. convenience init( currentMetadata: tableMetadata, ocIds: [String], @@ -374,12 +225,6 @@ final class NCMediaViewerModel: ObservableObject { // MARK: - Public API - /// Returns the page model for an absolute index. - /// - /// If the page is not cached yet, a lightweight idle page is created and cached. - /// - /// - Parameter index: Absolute index inside the full `ocIds` array. - /// - Returns: Page model if the index exists. func pageModel(at index: Int) -> NCMediaViewerPageModel? { guard ocIds.indices.contains(index) else { return nil @@ -397,12 +242,6 @@ final class NCMediaViewerModel: ObservableObject { return page } - /// Handles page display from the UIKit pager. - /// - /// When a page becomes selected, a running prefetch task for that page is - /// cancelled and replaced by selected-page loading. - /// - /// - Parameter index: Absolute page index currently displayed. func displayPage(at index: Int) async { guard ocIds.indices.contains(index) else { return @@ -410,35 +249,19 @@ final class NCMediaViewerModel: ObservableObject { selectedIndex = index - // Start neighbor prefetch immediately. - // Do not wait for the selected page full download to finish. prefetchNeighborPages(around: index) - await loadPageIfNeeded(index: index) } - /// Returns the page model for the currently selected index. - /// - /// - Returns: Selected page model if available. func selectedPageModel() -> NCMediaViewerPageModel? { pageModel(at: selectedIndex) } - /// Loads the initially selected page if needed. func loadSelectedPageIfNeeded() async { - // Start neighbor prefetch immediately. - // This prepares adjacent previews while the selected page is loading. prefetchNeighborPages(around: selectedIndex) - await loadPageIfNeeded(index: selectedIndex) } - /// Loads a page if it still needs selected-page loading. - /// - /// Prefetched pages can already have a preview, but selected-page loading - /// must still run to check or download the full media file. - /// - /// - Parameter index: Absolute page index inside the full `ocIds` array. func loadPageIfNeeded(index: Int) async { guard ocIds.indices.contains(index) else { return @@ -476,9 +299,6 @@ final class NCMediaViewerModel: ObservableObject { clearLoadingTaskIfCurrent(ocId: ocId, identifier: identifier) } - /// Reloads a failed or missing page. - /// - /// - Parameter index: Absolute page index inside the full `ocIds` array. func reloadPage(index: Int) async { guard ocIds.indices.contains(index) else { return @@ -496,9 +316,6 @@ final class NCMediaViewerModel: ObservableObject { await loadPageIfNeeded(index: index) } - /// Cancels loading for a specific page. - /// - /// - Parameter index: Absolute page index inside the full `ocIds` array. func cancelLoading(index: Int) { guard ocIds.indices.contains(index) else { return @@ -510,9 +327,6 @@ final class NCMediaViewerModel: ObservableObject { loadingTasksByOcId[ocId] = nil } - /// Updates the selected index without starting full page loading. - /// - /// - Parameter index: Absolute page index inside the full `ocIds` array. func setSelectedIndex(_ index: Int) { guard ocIds.indices.contains(index) else { return @@ -525,12 +339,6 @@ final class NCMediaViewerModel: ObservableObject { selectedIndex = index } - /// Prefetches the currently visible page and its nearby pages. - /// - /// This method is used while the user scrolls. It warms the target area around - /// the current visible index without starting audio or video playback. - /// - /// - Parameter index: Current visible page index. func prefetchVisiblePageIfNeeded(index: Int) async { guard ocIds.indices.contains(index) else { return @@ -540,26 +348,12 @@ final class NCMediaViewerModel: ObservableObject { prefetchNeighborPages(around: index) } - /// Toggles the media viewer chrome visibility. - /// - /// The chrome includes the navigation bar and the preferred page background. func toggleChromeVisibility() { isChromeHidden.toggle() } // MARK: - Selected Page Loading - /// Loads metadata and media content for a selected or explicitly requested page. - /// - /// Loading order: - /// - Resolve metadata. - /// - Preserve any preview already stored in the current page state. - /// - If the full local file exists, resolve a preview if needed and show it immediately. - /// - Otherwise, resolve/show the preview. - /// - For non-local videos, stop here and let the video viewer resolve direct playback. - /// - For images and audio, download the full media file when needed. - /// - /// - Parameter index: Absolute page index inside the full `ocIds` array. private func loadPage(index: Int) async { guard ocIds.indices.contains(index) else { return @@ -692,14 +486,6 @@ final class NCMediaViewerModel: ObservableObject { // MARK: - Prefetch - /// Prefetches nearby pages around the selected index. - /// - /// The prefetch window is intentionally wider for smooth image navigation. - /// Video and audio remain lightweight because `loadPageForPrefetch(index:)` - /// only resolves metadata and preview state, without starting playback, - /// creating AVPlayer/VLC instances, or resolving direct video download URLs. - /// - /// - Parameter index: Current selected absolute index. private func prefetchNeighborPages(around index: Int) { let prefetchRadius = 5 @@ -719,9 +505,6 @@ final class NCMediaViewerModel: ObservableObject { } } - /// Prefetches one page if it has not started loading yet. - /// - /// - Parameter index: Absolute page index inside the full `ocIds` array. private func prefetchPageIfNeeded(index: Int) async { guard ocIds.indices.contains(index) else { return @@ -761,12 +544,6 @@ final class NCMediaViewerModel: ObservableObject { ) } - /// Loads a page for neighbor prefetch. - /// - /// Prefetch resolves metadata and preview only. - /// It never downloads the full media file and never starts playback. - /// - /// - Parameter index: Absolute page index inside the full `ocIds` array. private func loadPageForPrefetch(index: Int) async { guard ocIds.indices.contains(index) else { return @@ -780,7 +557,6 @@ final class NCMediaViewerModel: ObservableObject { ) let ocId = ocIds[index] - let metadata = await resolvedMetadata(for: ocId) guard !Task.isCancelled else { @@ -840,10 +616,6 @@ final class NCMediaViewerModel: ObservableObject { // MARK: - Page Updates - /// Resolves detached metadata for an `ocId`. - /// - /// - Parameter ocId: Nextcloud file identifier. - /// - Returns: Existing cached metadata or metadata loaded from the loader. private func resolvedMetadata(for ocId: String) async -> tableMetadata? { if let existingMetadata = cachedPagesByOcId[ocId]?.metadata { return existingMetadata @@ -852,34 +624,18 @@ final class NCMediaViewerModel: ObservableObject { return await loader.metadata(for: ocId, account: session.account, mediaSearch: mediaSearch) } - /// Returns the current state for an `ocId`. - /// - /// - Parameter ocId: Nextcloud file identifier. - /// - Returns: Page state. private func pageState(for ocId: String) -> NCMediaViewerPageState { cachedPagesByOcId[ocId]?.state ?? .idle } - /// Returns whether the metadata represents an audio file. - /// - /// - Parameter metadata: Detached metadata. - /// - Returns: True when the media is an audio file. private func isAudio(_ metadata: tableMetadata) -> Bool { metadata.classFile == NKTypeClassFile.audio.rawValue } - /// Returns whether the metadata represents a video. - /// - /// - Parameter metadata: Detached metadata. - /// - Returns: True when the media is a video. private func isVideo(_ metadata: tableMetadata) -> Bool { metadata.classFile == NKTypeClassFile.video.rawValue } - /// Returns the currently cached preview URL for a page, if any. - /// - /// - Parameter ocId: Page file identifier. - /// - Returns: Cached preview URL if the current page state contains one. private func currentPreviewURL(for ocId: String) -> URL? { guard let page = cachedPagesByOcId[ocId] else { return nil @@ -908,36 +664,18 @@ final class NCMediaViewerModel: ObservableObject { } } - /// Updates the metadata for a page. - /// - /// - Parameters: - /// - metadata: Detached metadata. - /// - ocId: Page file identifier. private func setMetadata(_ metadata: tableMetadata, for ocId: String) { updatePage(ocId: ocId) { page in page.metadata = metadata } } - /// Updates the state for a page. - /// - /// - Parameters: - /// - state: New page state. - /// - ocId: Page file identifier. private func setState(_ state: NCMediaViewerPageState, for ocId: String) { updatePage(ocId: ocId) { page in page.state = state } } - /// Sets the correct ready state for image and non-image media. - /// - /// - Parameters: - /// - metadata: Detached metadata. - /// - previewURL: Optional local preview URL. - /// - localURL: Local full media URL. - /// - ocId: Page file identifier. - /// - index: Page index used for debug logs. private func setReadyState( metadata: tableMetadata, previewURL: URL?, @@ -977,11 +715,6 @@ final class NCMediaViewerModel: ObservableObject { } } - /// Mutates a cached page and publishes a model revision. - /// - /// - Parameters: - /// - ocId: Page file identifier. - /// - mutation: Mutation applied to the page model. private func updatePage( ocId: String, mutation: (inout NCMediaViewerPageModel) -> Void @@ -1003,14 +736,6 @@ final class NCMediaViewerModel: ObservableObject { revision &+= 1 } - /// Clears a loading task only if it is still the current task for the page. - /// - /// This prevents an older cancelled task from removing a newer task stored - /// under the same `ocId`. - /// - /// - Parameters: - /// - ocId: Page file identifier. - /// - identifier: Task identifier to validate. private func clearLoadingTaskIfCurrent( ocId: String, identifier: UUID @@ -1022,10 +747,6 @@ final class NCMediaViewerModel: ObservableObject { loadingTasksByOcId[ocId] = nil } - /// Returns whether the metadata represents an image. - /// - /// - Parameter metadata: Detached metadata. - /// - Returns: True when the media is an image. private func isImage(_ metadata: tableMetadata) -> Bool { metadata.classFile == NKTypeClassFile.image.rawValue } @@ -1034,7 +755,6 @@ final class NCMediaViewerModel: ObservableObject { // MARK: - NCMediaViewerPageState Helpers private extension NCMediaViewerPageState { - /// Returns true when the page has not started loading yet. var isIdle: Bool { switch self { case .idle: @@ -1053,14 +773,6 @@ private extension NCMediaViewerPageState { } } - /// Returns true when selected-page loading should continue. - /// - /// A prefetched image page can already have a preview but still needs - /// selected-page loading to download the full image file. - /// - /// Video is considered resolved only after selected-page loading sets `.video`. - /// Prefetch must use `.downloading(previewURL:progress:)` for videos so selected-page - /// loading can still run when the user reaches the page. var needsSelectedPageLoading: Bool { switch self { case .idle: diff --git a/iOSClient/Viewer/NCViewerMedia/Model - View/NCMediaViewerView.swift b/iOSClient/Viewer/NCViewerMedia/Model - View/NCMediaViewerView.swift index f1367dbf6b..f748af4e71 100644 --- a/iOSClient/Viewer/NCViewerMedia/Model - View/NCMediaViewerView.swift +++ b/iOSClient/Viewer/NCViewerMedia/Model - View/NCMediaViewerView.swift @@ -7,12 +7,6 @@ import SwiftUI // MARK: - Media Viewer View /// Main SwiftUI media viewer. -/// -/// This view owns the `NCMediaViewerModel` as a `StateObject`. -/// Paging is handled by `NCMediaViewerPagingView`, which is backed by -/// `UICollectionView` to support large virtualized media lists. -/// -/// Navigation buttons and title are provided by `NCMediaViewerHostingController`. struct NCMediaViewerView: View { @StateObject private var model: NCMediaViewerModel let contextMenuController: NCMainTabBarController? @@ -21,13 +15,6 @@ struct NCMediaViewerView: View { let onClose: (_ ocId: String?) -> Void /// Creates the media viewer view. - /// - /// - Parameters: - /// - model: Media viewer model containing page state and loading logic. - /// - contextMenuController: Optional controller used to present context menu actions. - /// - navigationBar: Optional navigation bar reference used by video controls for top action positioning. - /// - onVisibleMetadataChanged: Callback invoked when the visually visible page metadata and background color change. - /// - onClose: Callback invoked with the current media ocId when the media viewer should close. init( model: NCMediaViewerModel, contextMenuController: NCMainTabBarController? = nil, diff --git a/iOSClient/Viewer/NCViewerMedia/NCMediaViewerHostingController.swift b/iOSClient/Viewer/NCViewerMedia/NCMediaViewerHostingController.swift index 666e84a0ef..92bdf59c0a 100644 --- a/iOSClient/Viewer/NCViewerMedia/NCMediaViewerHostingController.swift +++ b/iOSClient/Viewer/NCViewerMedia/NCMediaViewerHostingController.swift @@ -9,10 +9,7 @@ import NextcloudKit // MARK: - Media Viewer Hosting Controller -/// UIKit hosting controller used by the media viewer. -/// -/// This controller embeds the SwiftUI media viewer and provides standard UIKit -/// navigation items for the title, close button, context menu button, and detail button. +/// Hosts the SwiftUI media viewer inside a UIKit controller. @MainActor final class NCMediaViewerHostingController: UIHostingController, UIAdaptivePresentationControllerDelegate { private let model: NCMediaViewerModel @@ -71,11 +68,6 @@ final class NCMediaViewerHostingController: UIHostingController NCMediaViewerView { NCMediaViewerView( model: model, @@ -203,8 +192,6 @@ final class NCMediaViewerHostingController: UIHostingController UIColor { let resolvedColor = backgroundColor.resolvedColor(with: traitCollection) var red: CGFloat = 0 @@ -299,19 +279,12 @@ final class NCMediaViewerHostingController: UIHostingController String? { floatingTitleDateFormatter.string(from: metadata.date as Date) } /// Shows or hides the viewer chrome. - /// - /// - Parameters: - /// - hidden: Whether the chrome should be hidden. - /// - animated: Whether the transition should be animated. private func setChromeHidden(_ hidden: Bool, animated: Bool) { navigationController?.setNavigationBarHidden( hidden, @@ -358,9 +331,7 @@ final class NCMediaViewerHostingController: UIHostingController Void let sceneIdentifier: String = "" diff --git a/iOSClient/Viewer/NCViewerMedia/NCMediaViewerPresenter.swift b/iOSClient/Viewer/NCViewerMedia/NCMediaViewerPresenter.swift index 4269e5f828..c4a5e92d3f 100644 --- a/iOSClient/Viewer/NCViewerMedia/NCMediaViewerPresenter.swift +++ b/iOSClient/Viewer/NCViewerMedia/NCMediaViewerPresenter.swift @@ -7,16 +7,7 @@ import UIKit // MARK: - Media Viewer Presenter -/// Presents the media viewer as a fullscreen overlay above the current window. -/// -/// The presenter installs a dedicated `UINavigationController` directly on the -/// active window instead of pushing into the app navigation stack. This keeps the -/// viewer independent from the current screen while still allowing the viewer to -/// use a real navigation bar for title, close, and menu actions. -/// -/// When a transition source is provided, the presenter animates the visible -/// thumbnail into the fullscreen viewer and animates the currently selected media -/// item back into its matching thumbnail frame on dismissal. +/// Presents the media viewer as a fullscreen overlay with optional thumbnail transitions. @MainActor final class NCMediaViewerPresenter: NSObject { static let shared = NCMediaViewerPresenter() @@ -44,13 +35,6 @@ final class NCMediaViewerPresenter: NSObject { // MARK: - Presentation /// Shows the media viewer above the current window. - /// - /// - Parameters: - /// - model: Media viewer model used to render and page through media items. - /// - viewerTransitionSource: Optional thumbnail source used for the opening animation. - /// - sourceView: Optional view used to resolve the current window. When nil, the active foreground key window is used. - /// - contextMenuController: Controller used by the viewer context menu. - /// - closingTransitionSourceProvider: Optional provider used to resolve the current thumbnail source on dismissal. func show( model: NCMediaViewerModel, viewerTransitionSource: NCViewerTransitionSource?, @@ -123,8 +107,6 @@ final class NCMediaViewerPresenter: NSObject { } /// Dismisses the current media viewer overlay. - /// - /// - Parameter animated: Whether dismissal should be animated. func dismiss(animated: Bool = true) { guard !isDismissing else { return @@ -172,13 +154,7 @@ final class NCMediaViewerPresenter: NSObject { // MARK: - Navigation Appearance - /// Configures the dedicated navigation controller used by the viewer. - /// - /// The navigation bar is transparent and overlays the SwiftUI content, allowing - /// media pages to remain fullscreen while still using standard UIKit navigation - /// items. - /// - /// - Parameter navigationController: Viewer navigation controller. + /// Configures the transparent navigation bar used by the viewer. private func configureNavigationController(_ navigationController: UINavigationController) { navigationController.setNavigationBarHidden(false, animated: false) navigationController.navigationBar.isTranslucent = true @@ -202,12 +178,7 @@ final class NCMediaViewerPresenter: NSObject { // MARK: - Dismiss Pan Gesture - /// Installs the swipe-down dismiss gesture on the fullscreen viewer container. - /// - /// The gesture is attached at presenter level, above the paging implementation, - /// so it does not require custom logic inside collection view cells or SwiftUI pages. - /// - /// - Parameter view: Viewer container view. + /// Installs the swipe-down gesture used to close the viewer. private func installDismissPanGesture(on view: UIView) { removeDismissPanGesture() @@ -237,10 +208,7 @@ final class NCMediaViewerPresenter: NSObject { isTrackingDismissPan = false } - /// Handles swipe-down dismissal from the fullscreen viewer container. - /// - /// The gesture dismisses when downward movement clearly wins over horizontal paging, - /// using permissive thresholds similar to a photo viewer drag-to-close interaction. + /// Handles swipe-down dismissal when vertical movement wins over paging. @objc private func handleDismissPanGesture(_ gesture: UIPanGestureRecognizer) { guard !isDismissing, @@ -301,15 +269,6 @@ final class NCMediaViewerPresenter: NSObject { // MARK: - Opening Animation /// Animates the source thumbnail into the fullscreen viewer. - /// - /// The real viewer is kept hidden until the temporary transition image reaches - /// its destination frame. This prevents seeing both the viewer image and the - /// transition image at the same time. - /// - /// - Parameters: - /// - viewerTransitionSource: Source thumbnail data. - /// - window: Window that contains the overlay transition views. - /// - viewerView: Real viewer container view to reveal at the end. private func animateOpening( viewerTransitionSource: NCViewerTransitionSource, in window: UIWindow, @@ -357,15 +316,6 @@ final class NCMediaViewerPresenter: NSObject { // MARK: - Closing Animation /// Animates the fullscreen viewer back into the current thumbnail frame. - /// - /// The real viewer is hidden immediately and replaced by a temporary transition - /// image, avoiding double-image artifacts during the zoom-out animation. - /// - /// - Parameters: - /// - viewerTransitionSource: Current thumbnail data used as closing destination. - /// - closingImage: Image currently displayed by the viewer, used during the closing transition. - /// - window: Window that contains the overlay transition views. - /// - viewerView: Real viewer container view to dismiss. private func animateClosing( viewerTransitionSource: NCViewerTransitionSource, closingImage: UIImage, @@ -403,13 +353,7 @@ final class NCMediaViewerPresenter: NSObject { // MARK: - Closing Source - /// Returns the transition source for the currently selected media item. - /// - /// The source controller knows how to map the current `ocId` to the visible - /// thumbnail frame. If no current source can be resolved, the presenter closes - /// without a thumbnail transition. - /// - /// - Returns: Current transition source if available. + /// Returns the transition source for the currently selected item. private func currentClosingTransitionSource() -> NCViewerTransitionSource? { let ocId = forcedClosingOcId ?? currentModel?.selectedOcId @@ -420,14 +364,7 @@ final class NCMediaViewerPresenter: NSObject { return closingTransitionSourceProvider?(ocId) } - /// Returns the best currently displayed image for the closing transition. - /// - /// The full local image is preferred when available. - /// If the full image is not available yet, the preview image is used. - /// If no current image can be resolved, the caller should fall back to the - /// transition source image. - /// - /// - Returns: Current image suitable for the closing transition. + /// Returns the best available image for the closing transition. private func currentClosingImage() -> UIImage? { guard let page = currentModel?.selectedPageModel() else { return nil @@ -500,9 +437,7 @@ final class NCMediaViewerPresenter: NSObject { // MARK: - Helpers - /// Returns the current active foreground key window. - /// - /// - Returns: Active foreground key window if available. + /// Returns the active foreground key window. private func activeWindow() -> UIWindow? { UIApplication.shared.connectedScenes .compactMap { $0 as? UIWindowScene } @@ -511,12 +446,7 @@ final class NCMediaViewerPresenter: NSObject { .first { $0.isKeyWindow } } - /// Computes the aspect-fit frame for an image inside the fullscreen container. - /// - /// - Parameters: - /// - imageSize: Source image size. - /// - containerSize: Window size. - /// - Returns: Aspect-fit destination frame. + /// Computes the aspect-fit frame for an image inside the container. private func aspectFitFrame( imageSize: CGSize, containerSize: CGSize diff --git a/iOSClient/Viewer/NCViewerMedia/Views/NCImageZoomView.swift b/iOSClient/Viewer/NCViewerMedia/Views/NCImageZoomView.swift index d17886bf8e..2cf53de2a4 100644 --- a/iOSClient/Viewer/NCViewerMedia/Views/NCImageZoomView.swift +++ b/iOSClient/Viewer/NCViewerMedia/Views/NCImageZoomView.swift @@ -7,12 +7,6 @@ import UIKit import VisionKit // MARK: - Image Zoom View - -/// UIKit-backed image zoom view. -/// -/// This view uses `UIScrollView` because it provides native, smooth pinch-to-zoom -/// and pan behavior, which is more reliable than SwiftUI `MagnifyGesture` when -/// hosted inside a paging container. struct NCImageZoomView: UIViewRepresentable { let image: UIImage let backgroundStyle: NCViewerBackgroundStyle @@ -22,11 +16,6 @@ struct NCImageZoomView: UIViewRepresentable { private let maximumZoomScale: CGFloat = 5 private let doubleTapZoomScale: CGFloat = 2.5 - /// Creates an image zoom view. - /// - /// - Parameters: - /// - image: Image rendered inside the zoomable scroll view. - /// - backgroundStyle: Viewer background style. init( image: UIImage, backgroundStyle: NCViewerBackgroundStyle = .system, @@ -38,7 +27,6 @@ struct NCImageZoomView: UIViewRepresentable { } // MARK: - UIViewRepresentable - func makeUIView(context: Context) -> NCZoomScrollView { let scrollView = NCZoomScrollView() @@ -145,10 +133,8 @@ struct NCImageZoomView: UIViewRepresentable { } // MARK: - Scroll View - final class NCZoomScrollView: UIScrollView { var onLayoutSubviews: (() -> Void)? - override func layoutSubviews() { super.layoutSubviews() onLayoutSubviews?() @@ -156,7 +142,6 @@ struct NCImageZoomView: UIViewRepresentable { } // MARK: - Coordinator - final class Coordinator: NSObject, UIScrollViewDelegate { weak var scrollView: UIScrollView? weak var imageView: UIImageView? @@ -170,7 +155,6 @@ struct NCImageZoomView: UIViewRepresentable { private var lastBoundsSize: CGSize = .zero // MARK: - UIScrollViewDelegate - func viewForZooming(in scrollView: UIScrollView) -> UIView? { imageView } @@ -180,13 +164,10 @@ struct NCImageZoomView: UIViewRepresentable { } // MARK: - Layout - - /// Resets cached bounds tracking so the next layout pass refits the image. func resetBoundsTracking() { lastBoundsSize = .zero } - /// Lays out the image view and resets zoom to the fitted image. func layoutImageViewResettingZoom() { guard let scrollView, let imageView, @@ -223,10 +204,7 @@ struct NCImageZoomView: UIViewRepresentable { centerImageView() } - /// Lays out the image view when the container size changes. - /// - /// The zoom is reset on bounds changes because rotation, iPad resizing, - /// and Stage Manager can otherwise leave stale offsets or invalid content sizes. + // Reset zoom on size changes to avoid stale offsets. func layoutImageViewResettingOnBoundsChange() { guard let scrollView, let imageView, @@ -268,7 +246,6 @@ struct NCImageZoomView: UIViewRepresentable { centerImageView() } - /// Centers the image view inside the scroll view when the image is smaller than the viewport. private func centerImageView() { guard let scrollView, let imageView else { @@ -293,7 +270,6 @@ struct NCImageZoomView: UIViewRepresentable { } } - /// Returns whether the current image and container sizes can be used for layout. private func isValidLayout( imageSize: CGSize, boundsSize: CGSize @@ -304,7 +280,6 @@ struct NCImageZoomView: UIViewRepresentable { boundsSize.height > 0 } - /// Returns the aspect-fit size of an image inside a container. private func fittedImageSize( imageSize: CGSize, containerSize: CGSize @@ -320,8 +295,6 @@ struct NCImageZoomView: UIViewRepresentable { } // MARK: - Gestures - - /// Handles double tap zoom and reset. @objc func handleDoubleTap(_ gesture: UITapGestureRecognizer) { guard let scrollView, @@ -346,7 +319,6 @@ struct NCImageZoomView: UIViewRepresentable { scrollView.zoom(to: zoomRect, animated: true) } - /// Builds the zoom rect used by double tap. private func zoomRect( for scrollView: UIScrollView, scale: CGFloat, @@ -367,16 +339,7 @@ struct NCImageZoomView: UIViewRepresentable { } // MARK: - Image Analysis - - /// Adds VisionKit image analysis to the displayed image when supported. - /// - /// Existing analysis interactions are removed before installing a new one, - /// so stale analysis results are not reused after an image change. - /// - /// - Parameters: - /// - image: Image to analyze. - /// - imageView: Image view that renders the image. - /// - coordinator: Coordinator used to validate that the image is still current. + // Rebuild analysis to avoid stale VisionKit results after image changes. @MainActor private func analyzeImageIfAvailable( image: UIImage, @@ -423,9 +386,6 @@ struct NCImageZoomView: UIViewRepresentable { } } - /// Removes VisionKit image analysis interactions from the image view. - /// - /// - Parameter imageView: Image view from which analysis interactions should be removed. @MainActor private func removeImageAnalysisInteractions(from imageView: UIImageView) { imageView.interactions diff --git a/iOSClient/Viewer/NCViewerMedia/Views/NCMediaViewerDetailView.swift b/iOSClient/Viewer/NCViewerMedia/Views/NCMediaViewerDetailView.swift index 750fb3404f..557b55e028 100644 --- a/iOSClient/Viewer/NCViewerMedia/Views/NCMediaViewerDetailView.swift +++ b/iOSClient/Viewer/NCViewerMedia/Views/NCMediaViewerDetailView.swift @@ -7,10 +7,6 @@ import MapKit import NextcloudKit // MARK: - Media Viewer Detail View - -/// SwiftUI detail panel for media viewer metadata. -/// -/// It renders file information, optional EXIF information, and optional location data. struct NCMediaViewerDetailView: View { let metadata: tableMetadata let exif: ExifData diff --git a/iOSClient/Viewer/NCViewerMedia/Views/NCMediaViewerPageView.swift b/iOSClient/Viewer/NCViewerMedia/Views/NCMediaViewerPageView.swift index b2a123666d..7f940b95a8 100644 --- a/iOSClient/Viewer/NCViewerMedia/Views/NCMediaViewerPageView.swift +++ b/iOSClient/Viewer/NCViewerMedia/Views/NCMediaViewerPageView.swift @@ -7,10 +7,6 @@ import NextcloudKit // MARK: - Media Viewer Page View -/// Renders a single media viewer page. -/// -/// This view is pure rendering logic. -/// It does not load metadata, check local files, read Realm, or start downloads. struct NCMediaViewerPageView: View { // MARK: - Rendered Kind @@ -116,18 +112,11 @@ struct NCMediaViewerPageView: View { } } - /// Returns whether this page should consume an auto-play request. - /// - /// Auto-play is valid only for the currently selected page. - /// Neighbor pages can be prefetched and rendered, but they must not start playback - /// or consume a pending auto-play request. + // Neighbor pages must not consume auto-play. private var effectiveShouldAutoPlay: Bool { isSelected && shouldAutoPlay } - /// Moves to the previous page using the coordinator callback. - /// - /// - Parameter requestedAutoPlay: Whether the hosted content requests auto-play on the target page. private func goToPreviousPage(_ requestedAutoPlay: Bool) { guard canGoPrevious else { return @@ -138,9 +127,6 @@ struct NCMediaViewerPageView: View { ) } - /// Moves to the next page using the coordinator callback. - /// - /// - Parameter requestedAutoPlay: Whether the hosted content requests auto-play on the target page. private func goToNextPage(_ requestedAutoPlay: Bool) { guard canGoNext else { return @@ -151,7 +137,6 @@ struct NCMediaViewerPageView: View { ) } - /// Consumes the pending auto-play request only when this page is selected. private func consumeAutoPlayIfNeeded() { guard isSelected else { return @@ -160,20 +145,11 @@ struct NCMediaViewerPageView: View { onAutoPlayConsumed() } - /// Moves to the previous page from video-specific controls or VLC swipe. - /// - /// Boundary validation is delegated to the paging coordinator so callbacks coming - /// from the UIKit-only VLC controller do not depend on potentially stale SwiftUI - /// `canGoPrevious` values captured when VLC was presented. + // Video controllers delegate boundary checks to the paging coordinator. private func goToPreviousPageFromVideo() { onPreviousPage(false) } - /// Moves to the next page from video-specific controls or VLC swipe. - /// - /// Boundary validation is delegated to the paging coordinator so callbacks coming - /// from the UIKit-only VLC controller do not depend on potentially stale SwiftUI - /// `canGoNext` values captured when VLC was presented. private func goToNextPageFromVideo() { onNextPage(false) } @@ -412,9 +388,7 @@ struct NCMediaViewerPageView: View { .padding() } - /// Returns the tap gesture used to toggle the viewer chrome. - /// - /// Double tap is ignored here so image zoom can keep using it. + // Keep double tap reserved for image zoom. private func chromeToggleGesture() -> some Gesture { TapGesture(count: 2) .exclusively( diff --git a/iOSClient/Viewer/NCViewerMedia/Views/NCMediaViewerPagingView.swift b/iOSClient/Viewer/NCViewerMedia/Views/NCMediaViewerPagingView.swift index 01c38a0d57..f398fc1b42 100644 --- a/iOSClient/Viewer/NCViewerMedia/Views/NCMediaViewerPagingView.swift +++ b/iOSClient/Viewer/NCViewerMedia/Views/NCMediaViewerPagingView.swift @@ -9,13 +9,6 @@ import NextcloudKit // MARK: - Media Viewer Paging View -/// UIKit-backed horizontal paging view for the media viewer. -/// -/// This replaces SwiftUI `TabView(.page)` because `TabView` is not suitable for -/// very large virtualized media lists and can flicker when its page array changes. -/// -/// The paging view uses a `UICollectionView` with reusable cells. -/// Each cell hosts a SwiftUI `NCMediaViewerPageView`. struct NCMediaViewerPagingView: UIViewRepresentable { @ObservedObject var model: NCMediaViewerModel let contextMenuController: NCMainTabBarController? @@ -106,11 +99,6 @@ struct NCMediaViewerPagingView: UIViewRepresentable { // MARK: - Media Viewer Collection View -/// Collection view subclass used to detect bounds changes reliably. -/// -/// This is needed because rotation, iPad split view resizing, and floating window -/// resizing can change the collection view bounds without SwiftUI immediately -/// rebuilding the representable. final class NCMediaViewerCollectionView: UICollectionView { var onLayoutSubviews: (() -> Void)? @@ -122,11 +110,6 @@ final class NCMediaViewerCollectionView: UICollectionView { // MARK: - Media Viewer Paging Coordinator -/// Coordinator for the UIKit paging collection view. -/// -/// It acts as: -/// - collection view data source -/// - collection view delegate flow layout @MainActor final class NCMediaViewerPagingCoordinator: NSObject, UICollectionViewDataSource, @@ -173,10 +156,6 @@ final class NCMediaViewerPagingCoordinator: NSObject, // MARK: - Layout - /// Updates the paging layout after bounds changes. - /// - /// This keeps the selected page centered after rotation, split view resizing, - /// or iPad floating window resizing. func updateLayoutAfterBoundsChangeIfNeeded() { guard let collectionView else { return @@ -196,13 +175,6 @@ final class NCMediaViewerPagingCoordinator: NSObject, relayoutAndKeepCurrentIndex(size: boundsSize) } - /// Invalidates the paging layout while preserving the current selected page. - /// - /// During bounds changes, the collection view content offset can temporarily be - /// expressed using the old page width. This method prevents those intermediate - /// offsets from being interpreted as real page changes. - /// - /// - Parameter size: New page size to apply to the flow layout. func relayoutAndKeepCurrentIndex(size: CGSize) { guard let collectionView else { return @@ -212,7 +184,7 @@ final class NCMediaViewerPagingCoordinator: NSObject, size.height > 0 else { return } - + // Ignore intermediate offsets while the layout is being resized. lastCollectionViewBoundsSize = size isAdjustingLayout = true @@ -241,10 +213,6 @@ final class NCMediaViewerPagingCoordinator: NSObject, // MARK: - Background - /// Returns the UIKit background color for the given page. - /// - /// Audio and video use black because their player surfaces are dark. - /// Images use the viewer background style unless chrome is hidden. private func backgroundColor(for page: NCMediaViewerPageModel?) -> UIColor { guard !model.isChromeHidden else { return .black @@ -266,7 +234,6 @@ final class NCMediaViewerPagingCoordinator: NSObject, } } - /// Applies the current page background to the collection view. func updateCollectionBackground(for index: Int? = nil) { let pageIndex = index ?? model.selectedIndex let page = model.pageModel(at: pageIndex) @@ -275,14 +242,10 @@ final class NCMediaViewerPagingCoordinator: NSObject, collectionView?.backgroundColor = color } - /// Sends the metadata of the currently selected page to the hosting controller title view. func updateVisibleMetadataTitleForCurrentPage() { updateVisibleMetadataTitle(for: model.selectedIndex) } - /// Sends the metadata of the currently visible page to the hosting controller title view. - /// - /// - Parameter index: Page index currently closest to the collection view center. private func updateVisibleMetadataTitle(for index: Int) { guard index >= 0, index < model.numberOfPages else { @@ -299,9 +262,6 @@ final class NCMediaViewerPagingCoordinator: NSObject, // MARK: - Initial Scroll - /// Scrolls to the initial selected page once. - /// - /// - Parameter animated: Whether the scroll should be animated. func scrollToInitialIndexIfNeeded(animated: Bool) { guard !didScrollToInitialIndex else { return @@ -337,12 +297,6 @@ final class NCMediaViewerPagingCoordinator: NSObject, refreshVisibleCells() } - /// Scrolls to the current selected index. - /// - /// This is used after layout size changes, for example after rotation or - /// iPad window resizing. - /// - /// - Parameter animated: Whether the scroll should be animated. func scrollToCurrentIndex(animated: Bool) { scrollToIndex( model.selectedIndex, @@ -350,11 +304,6 @@ final class NCMediaViewerPagingCoordinator: NSObject, ) } - /// Scrolls to a specific page index without changing the selected model index. - /// - /// - Parameters: - /// - index: Page index to center. - /// - animated: Whether the scroll should be animated. private func scrollToIndex( _ index: Int, animated: Bool @@ -388,7 +337,6 @@ final class NCMediaViewerPagingCoordinator: NSObject, // MARK: - Visible Cell Refresh - /// Refreshes currently visible cells using the latest page models and selected index. func refreshVisibleCells() { guard let collectionView else { return @@ -410,14 +358,6 @@ final class NCMediaViewerPagingCoordinator: NSObject, // MARK: - Page Navigation - /// Moves to the previous or next page using the paging collection view. - /// - /// The target page becomes selected only after the scrolling animation finishes. - /// This keeps programmatic navigation consistent with manual swipe navigation. - /// - /// - Parameters: - /// - offset: Relative page offset. Use `-1` for previous and `1` for next. - /// - shouldAutoPlay: Whether the target page should autoplay after selection. private func moveToPage( offset: Int, shouldAutoPlay: Bool @@ -441,7 +381,7 @@ final class NCMediaViewerPagingCoordinator: NSObject, if shouldAutoPlay { model.requestAutoPlay(at: targetIndex) } - + // Selection is finalized when the scroll animation ends. isUserPaging = true lastVisibleIndex = targetIndex @@ -456,11 +396,6 @@ final class NCMediaViewerPagingCoordinator: NSObject, ) } - /// Configures a paging cell with all callbacks required by the hosted SwiftUI page. - /// - /// - Parameters: - /// - cell: Cell to configure. - /// - page: Page model to render. private func configure( cell: NCMediaViewerPagingCell, page: NCMediaViewerPageModel @@ -587,10 +522,6 @@ final class NCMediaViewerPagingCoordinator: NSObject, refreshVisibleCells() } - /// Returns the nearest page index for the current horizontal scroll position. - /// - /// - Parameter scrollView: Source scroll view. - /// - Returns: Rounded page index if it is inside the media range. private func pageIndex(for scrollView: UIScrollView) -> Int? { pageIndex( forContentOffsetX: scrollView.contentOffset.x, @@ -598,14 +529,6 @@ final class NCMediaViewerPagingCoordinator: NSObject, ) } - /// Returns the nearest page index for the provided horizontal content offset. - /// - /// This is used to predict the final page before deceleration finishes. - /// - /// - Parameters: - /// - contentOffsetX: Horizontal content offset to evaluate. - /// - width: Current page width. - /// - Returns: Rounded page index if it is inside the media range. private func pageIndex( forContentOffsetX contentOffsetX: CGFloat, width: CGFloat @@ -666,12 +589,6 @@ final class NCMediaViewerPagingCoordinator: NSObject, } } - /// Updates the selected page index after paging has settled. - /// - /// This is the only place where a finished swipe becomes the real selected page. - /// During dragging, visible pages are tracked for background updates, but they are not considered selected. - /// - /// - Parameter scrollView: Source scroll view. private func updateSelectedIndexFromScrollView(_ scrollView: UIScrollView) { guard !isAdjustingLayout else { return @@ -680,7 +597,7 @@ final class NCMediaViewerPagingCoordinator: NSObject, guard let index = pageIndex(for: scrollView) else { return } - + // The settled page is now the selected page. isUserPaging = false lastVisibleIndex = index @@ -697,7 +614,6 @@ final class NCMediaViewerPagingCoordinator: NSObject, // MARK: - Media Viewer Paging Cell -/// Collection view cell hosting one SwiftUI media viewer page. final class NCMediaViewerPagingCell: UICollectionViewCell { static let reuseIdentifier = "NCMediaViewerPagingCell" @@ -742,21 +658,6 @@ final class NCMediaViewerPagingCell: UICollectionViewCell { // MARK: - Configuration - /// Configures the cell with a media viewer page. - /// - /// - Parameters: - /// - page: Page model to render. - /// - isSelected: Whether this cell represents the currently selected page. - /// - isChromeHidden: Whether viewer chrome is currently hidden. - /// - backgroundColor: Background color matching the currently rendered page. - /// - canGoPrevious: Whether the page can navigate to a previous item. - /// - canGoNext: Whether the page can navigate to a next item. - /// - shouldAutoPlay: Whether hosted audio content should start playback automatically. - /// - onToggleChrome: Callback used by image pages to show or hide chrome. - /// - onPreviousPage: Callback used by inline controls to move to previous page. - /// - onNextPage: Callback used by inline controls to move to next page. - /// - onClose: Callback used by fullscreen video controllers to close the media viewer with the current media ocId. - /// - onAutoPlayConsumed: Callback invoked after the hosted page consumes the auto-play request. func configure( page: NCMediaViewerPageModel, isSelected: Bool, @@ -822,9 +723,6 @@ final class NCMediaViewerPagingCell: UICollectionViewCell { } } - /// Configures the cell as an empty page. - /// - /// - Parameter backgroundColor: Background color to apply to the empty page. func configureEmpty(backgroundColor: UIColor = .black) { self.backgroundColor = backgroundColor contentView.backgroundColor = backgroundColor diff --git a/iOSClient/Viewer/NCViewerMedia/Views/NCViewerFloatingTitleView.swift b/iOSClient/Viewer/NCViewerMedia/Views/NCViewerFloatingTitleView.swift index ca731ec216..a802f1fb72 100644 --- a/iOSClient/Viewer/NCViewerMedia/Views/NCViewerFloatingTitleView.swift +++ b/iOSClient/Viewer/NCViewerMedia/Views/NCViewerFloatingTitleView.swift @@ -4,10 +4,6 @@ import UIKit -/// Floating title view used by media viewer controllers. -/// -/// The view renders only primary and secondary text without any visual material, -/// background, glass, blur, or border decoration. final class NCViewerFloatingTitleView: UIView { private let primaryLabel = UILabel() private let secondaryLabel = UILabel() @@ -30,15 +26,7 @@ final class NCViewerFloatingTitleView: UIView { fatalError("init(coder:) has not been implemented") } - /// Attaches the floating title view to the provided navigation bar. - /// - /// The title is installed as a navigation bar subview and can then align itself - /// against the real visible bar button containers. - /// - /// - Parameters: - /// - navigationBar: Navigation bar that owns the floating title view. - /// - widthMultiplier: Maximum title width relative to the navigation bar width. - /// - verticalOffset: Vertical adjustment applied to the navigation bar top edge. + // Attach directly to the navigation bar to match real button layout. func attach( to navigationBar: UINavigationBar, widthMultiplier: CGFloat = 0.36, @@ -70,12 +58,10 @@ final class NCViewerFloatingTitleView: UIView { updateHorizontalAlignment() } - /// Resets the horizontal title position to the navigation bar center. func updateHorizontalAlignment() { centerXConstraint?.constant = 0 } - /// Updates the title height using the visible navigation item height. func updateNavigationItemHeight() { guard let navigationBar else { return @@ -84,10 +70,7 @@ final class NCViewerFloatingTitleView: UIView { heightConstraint?.constant = navigationItemHeight(in: navigationBar) } - /// Returns the best visible navigation item height for the provided navigation bar. - /// - /// - Parameter navigationBar: Navigation bar containing the title and bar button items. - /// - Returns: Height used by visible navigation items, falling back to `44` points. + // Use visible bar item height when possible. private func navigationItemHeight(in navigationBar: UINavigationBar) -> CGFloat { let heights = navigationBar.subviews.flatMap { subview in navigationItemHeights( @@ -99,12 +82,6 @@ final class NCViewerFloatingTitleView: UIView { return heights.max() ?? navigationBar.bounds.height } - /// Recursively collects visible navigation item heights from the navigation bar hierarchy. - /// - /// - Parameters: - /// - view: Current hierarchy node. - /// - navigationBar: Navigation bar used as coordinate target. - /// - Returns: Visible item heights in navigation bar coordinates. private func navigationItemHeights( from view: UIView, in navigationBar: UINavigationBar @@ -139,12 +116,6 @@ final class NCViewerFloatingTitleView: UIView { return childHeights } - /// Updates the visible title content. - /// - /// - Parameters: - /// - primaryText: Main title text displayed on the first line. - /// - secondaryText: Optional subtitle text displayed on the second line. - /// - textColor: Text color selected by the caller according to the current viewer background. func update( primaryText: String?, secondaryText: String?, @@ -168,7 +139,6 @@ final class NCViewerFloatingTitleView: UIView { .joined(separator: ", ") } - /// Clears the visible title content. func clear() { update( primaryText: nil, @@ -177,7 +147,6 @@ final class NCViewerFloatingTitleView: UIView { ) } - /// Configures the visual container. private func configureView() { translatesAutoresizingMaskIntoConstraints = false backgroundColor = .clear @@ -185,7 +154,6 @@ final class NCViewerFloatingTitleView: UIView { isAccessibilityElement = true } - /// Configures the primary and secondary labels. private func configureLabels() { primaryLabel.font = .preferredFont(forTextStyle: .subheadline) primaryLabel.textColor = .white @@ -202,7 +170,6 @@ final class NCViewerFloatingTitleView: UIView { secondaryLabel.numberOfLines = 1 } - /// Configures the vertical label stack. private func configureStackView() { stackView.translatesAutoresizingMaskIntoConstraints = false stackView.axis = .vertical From 2a0175e718b3f5b1fe3a2e892a93a81b67c1f37d Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Fri, 22 May 2026 17:53:46 +0200 Subject: [PATCH 03/54] Clean up media viewer helpers Signed-off-by: Marino Faggiana --- .../Image/NCImageViewerContentView.swift | 20 +++---- .../NCVideoAVPlayerViewController.swift | 6 +- .../NCVideoAVPlayerViewControls.swift | 56 ++++++++----------- .../Content/Video/NCVideoControlsView.swift | 31 +++------- .../Video/NCVideoViewerContentView.swift | 9 +-- .../Video/VLC/NCVideoVLCViewController.swift | 6 +- .../Video/VLC/NCVideoVLCViewControls.swift | 51 ++++++----------- .../NCNextcloudMediaViewerLoader.swift | 19 +++---- .../Model - View/NCMediaViewerModel.swift | 25 +++------ .../NCViewerMedia/Views/NCImageZoomView.swift | 30 +++------- .../Views/NCMediaViewerPageView.swift | 46 ++++----------- .../Views/NCMediaViewerPagingView.swift | 18 +++--- .../Views/NCViewerFloatingTitleView.swift | 13 ++--- 13 files changed, 105 insertions(+), 225 deletions(-) diff --git a/iOSClient/Viewer/NCViewerMedia/Content/Image/NCImageViewerContentView.swift b/iOSClient/Viewer/NCViewerMedia/Content/Image/NCImageViewerContentView.swift index f1b18ee943..490a4db72d 100644 --- a/iOSClient/Viewer/NCViewerMedia/Content/Image/NCImageViewerContentView.swift +++ b/iOSClient/Viewer/NCViewerMedia/Content/Image/NCImageViewerContentView.swift @@ -175,7 +175,13 @@ struct NCImageViewerContentView: View { } if currentImage == nil { - failedMessage = imageDecodeFailedMessage(for: expectedFullURL) + if isGIF(expectedFullURL) { + failedMessage = "GIF file could not be decoded." + } else if isSVG(expectedFullURL) { + failedMessage = "SVG file could not be rendered." + } else { + failedMessage = "UIImage could not decode this file." + } } } @@ -249,18 +255,6 @@ struct NCImageViewerContentView: View { url?.pathExtension.lowercased() == "svg" } - private func imageDecodeFailedMessage(for url: URL) -> String { - if isGIF(url) { - return "GIF file could not be decoded." - } - - if isSVG(url) { - return "SVG file could not be rendered." - } - - return "UIImage could not decode this file." - } - private func isValidLocalFile(url: URL) -> Bool { let path = url.path diff --git a/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoAVPlayerViewController.swift b/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoAVPlayerViewController.swift index 984e5c1f86..74a662ae8d 100644 --- a/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoAVPlayerViewController.swift +++ b/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoAVPlayerViewController.swift @@ -316,15 +316,11 @@ final class NCVideoAVPlayerViewController: UIViewController { floatingTitleView.update( primaryText: primaryTitle, - secondaryText: floatingTitleSecondaryText(for: metadata), + secondaryText: floatingTitleDateFormatter.string(from: metadata.date as Date), textColor: .white ) } - private func floatingTitleSecondaryText(for metadata: tableMetadata) -> String? { - floatingTitleDateFormatter.string(from: metadata.date as Date) - } - private func refreshMoreMenu() { moreNavigationItem.menu = makeMoreMenu() } diff --git a/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoAVPlayerViewControls.swift b/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoAVPlayerViewControls.swift index 0f10744444..73c24800ca 100644 --- a/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoAVPlayerViewControls.swift +++ b/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoAVPlayerViewControls.swift @@ -9,37 +9,6 @@ import UIKit extension NCVideoAVPlayerViewController { - func seekBackwardTapped() { - seek(bySeconds: -10) - } - - func playPauseTapped() { - switch player.timeControlStatus { - case .playing: - player.pause() - - case .paused, - .waitingToPlayAtSpecifiedRate: - if let duration = player.currentItem?.duration.seconds, - duration.isFinite, - player.currentTime().seconds >= duration - 0.2 { - player.seek(to: .zero) - } - - player.play() - - @unknown default: - player.play() - } - - updatePlayPauseButton() - scheduleControlsHide() - } - - func seekForwardTapped() { - seek(bySeconds: 10) - } - private func seek(bySeconds seconds: Double) { guard let duration = player.currentItem?.duration.seconds, duration.isFinite, @@ -196,15 +165,34 @@ extension NCVideoAVPlayerViewController: NCVideoControlsViewDelegate { } func videoControlsDidTapSeekBackward(_ controlsView: NCVideoControlsView) { - seekBackwardTapped() + seek(bySeconds: -10) } func videoControlsDidTapPlayPause(_ controlsView: NCVideoControlsView) { - playPauseTapped() + switch player.timeControlStatus { + case .playing: + player.pause() + + case .paused, + .waitingToPlayAtSpecifiedRate: + if let duration = player.currentItem?.duration.seconds, + duration.isFinite, + player.currentTime().seconds >= duration - 0.2 { + player.seek(to: .zero) + } + + player.play() + + @unknown default: + player.play() + } + + updatePlayPauseButton() + scheduleControlsHide() } func videoControlsDidTapSeekForward(_ controlsView: NCVideoControlsView) { - seekForwardTapped() + seek(bySeconds: 10) } func videoControlsDidTapPictureInPicture(_ controlsView: NCVideoControlsView) { diff --git a/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoControlsView.swift b/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoControlsView.swift index 5f55c7dfed..3dffff9fbd 100644 --- a/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoControlsView.swift +++ b/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoControlsView.swift @@ -503,11 +503,13 @@ private struct NCVideoControlsSwiftUIView: View { EmptyView() case .pictureInPicture: - topActionButton( - systemName: "pip.enter", - pointSize: 18, - action: onPictureInPicture - ) + Button(action: onPictureInPicture) { + topActionIcon( + systemName: "pip.enter", + pointSize: 18 + ) + } + .buttonStyle(.plain) case .vlcTracks: subtitleActionMenu( @@ -515,7 +517,6 @@ private struct NCVideoControlsSwiftUIView: View { pointSize: 17, items: state.subtitleTrackItems, emptyTitle: "_no_subtitles_available_", - onOpen: onSubtitle, onSelect: onSubtitleTrackSelected, onAddExternalSubtitle: onAddExternalSubtitle ) @@ -525,7 +526,6 @@ private struct NCVideoControlsSwiftUIView: View { pointSize: 17, items: state.audioTrackItems, emptyTitle: "_no_audio_tracks_available_", - onOpen: onAudio, onSelect: onAudioTrackSelected ) } @@ -537,7 +537,6 @@ private struct NCVideoControlsSwiftUIView: View { pointSize: CGFloat, items: [NCVideoTrackMenuItem], emptyTitle: String, - onOpen: @escaping () -> Void, onSelect: @escaping (_ index: Int32) -> Void, onAddExternalSubtitle: @escaping () -> Void ) -> some View { @@ -578,27 +577,11 @@ private struct NCVideoControlsSwiftUIView: View { } .buttonStyle(.plain) } - - private func topActionButton( - systemName: String, - pointSize: CGFloat, - action: @escaping () -> Void - ) -> some View { - Button(action: action) { - topActionIcon( - systemName: systemName, - pointSize: pointSize - ) - } - .buttonStyle(.plain) - } - private func topActionMenu( systemName: String, pointSize: CGFloat, items: [NCVideoTrackMenuItem], emptyTitle: String, - onOpen: @escaping () -> Void, onSelect: @escaping (_ index: Int32) -> Void ) -> some View { return Menu { diff --git a/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoViewerContentView.swift b/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoViewerContentView.swift index 780f2d095c..3166b678b8 100644 --- a/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoViewerContentView.swift +++ b/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoViewerContentView.swift @@ -67,7 +67,7 @@ struct NCVideoViewerContentView: View { Color.black .ignoresSafeArea() - previewPlaceholderView + NCVideoPreviewPlaceholderView(previewURL: previewURL) if let errorMessage { failedView(errorMessage) @@ -157,13 +157,6 @@ struct NCVideoViewerContentView: View { } } - // MARK: - Views - - @ViewBuilder - private var previewPlaceholderView: some View { - NCVideoPreviewPlaceholderView(previewURL: previewURL) - } - private func failedView(_ message: String) -> some View { VStack(spacing: 12) { Image(systemName: "video.slash") diff --git a/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCViewController.swift b/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCViewController.swift index 979f436765..da23fa0c78 100644 --- a/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCViewController.swift +++ b/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCViewController.swift @@ -287,15 +287,11 @@ final class NCVideoVLCViewController: UIViewController { floatingTitleView.update( primaryText: primaryTitle, - secondaryText: floatingTitleSecondaryText(for: metadata), + secondaryText: floatingTitleDateFormatter.string(from: metadata.date as Date), textColor: .white ) } - private func floatingTitleSecondaryText(for metadata: tableMetadata) -> String? { - floatingTitleDateFormatter.string(from: metadata.date as Date) - } - private func refreshMoreMenu() { moreNavigationItem.menu = makeMoreMenu() } diff --git a/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCViewControls.swift b/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCViewControls.swift index 9794211d05..08034c3c2b 100644 --- a/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCViewControls.swift +++ b/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCViewControls.swift @@ -4,36 +4,6 @@ import MobileVLCKit // MARK: - Playback Controls extension NCVideoVLCViewController { - @objc - func seekBackwardTapped() { - showControls(animated: true) - scheduleControlsHide() - seek(byMilliseconds: -10_000) - } - - @objc - func playPauseTapped() { - showControls(animated: true) - - if mediaPlayer.isPlaying { - mediaPlayer.pause() - showControls(animated: false) - stopControlsHideTimer() - } else { - mediaPlayer.play() - } - - updatePlayPauseButton() - updateProgressControls() - } - - @objc - func seekForwardTapped() { - showControls(animated: true) - scheduleControlsHide() - seek(byMilliseconds: 10_000) - } - func seek(byMilliseconds deltaMilliseconds: Int32) { let duration = mediaPlayer.media?.length.intValue ?? 0 guard duration > 0 else { @@ -199,15 +169,30 @@ extension NCVideoVLCViewController { // MARK: - Shared Controls Delegate extension NCVideoVLCViewController: NCVideoControlsViewDelegate { func videoControlsDidTapSeekBackward(_ controlsView: NCVideoControlsView) { - seekBackwardTapped() + showControls(animated: true) + scheduleControlsHide() + seek(byMilliseconds: -10_000) } func videoControlsDidTapPlayPause(_ controlsView: NCVideoControlsView) { - playPauseTapped() + showControls(animated: true) + + if mediaPlayer.isPlaying { + mediaPlayer.pause() + showControls(animated: false) + stopControlsHideTimer() + } else { + mediaPlayer.play() + } + + updatePlayPauseButton() + updateProgressControls() } func videoControlsDidTapSeekForward(_ controlsView: NCVideoControlsView) { - seekForwardTapped() + showControls(animated: true) + scheduleControlsHide() + seek(byMilliseconds: 10_000) } // VLC does not expose Picture in Picture controls. diff --git a/iOSClient/Viewer/NCViewerMedia/Loading/NCNextcloudMediaViewerLoader.swift b/iOSClient/Viewer/NCViewerMedia/Loading/NCNextcloudMediaViewerLoader.swift index 6601e27980..25b29abad7 100644 --- a/iOSClient/Viewer/NCViewerMedia/Loading/NCNextcloudMediaViewerLoader.swift +++ b/iOSClient/Viewer/NCViewerMedia/Loading/NCNextcloudMediaViewerLoader.swift @@ -8,7 +8,6 @@ import NextcloudKit // MARK: - Media Viewer Loader final class NCMediaViewerLoader: NCMediaViewerLoading, @unchecked Sendable { private let database = NCManageDatabase.shared - private let global = NCGlobal.shared private let utilityFileSystem = NCUtilityFileSystem() private let fileManager = FileManager.default @@ -39,7 +38,13 @@ final class NCMediaViewerLoader: NCMediaViewerLoading, @unchecked Sendable { } func previewURL(for metadata: tableMetadata, index: Int) async -> URL? { - let localPath = previewLocalPath(for: metadata) + let localPath = utilityFileSystem.getDirectoryProviderStorageImageOcId( + metadata.ocId, + etag: metadata.etag, + ext: NCGlobal.shared.previewExt1024, + userId: metadata.userId, + urlBase: metadata.urlBase + ) if isValidLocalFile(path: localPath) { nkLog(tag: NCGlobal.shared.logTagViewer, emoji: .debug, message: "PREVIEW local \(index)", consoleOnly: true) @@ -206,16 +211,6 @@ final class NCMediaViewerLoader: NCMediaViewerLoading, @unchecked Sendable { ) } - private func previewLocalPath(for metadata: tableMetadata) -> String { - utilityFileSystem.getDirectoryProviderStorageImageOcId( - metadata.ocId, - etag: metadata.etag, - ext: global.previewExt1024, - userId: metadata.userId, - urlBase: metadata.urlBase - ) - } - private func isValidLocalFile(path: String) -> Bool { guard !path.isEmpty else { return false diff --git a/iOSClient/Viewer/NCViewerMedia/Model - View/NCMediaViewerModel.swift b/iOSClient/Viewer/NCViewerMedia/Model - View/NCMediaViewerModel.swift index a29637fa63..001bfc65c6 100644 --- a/iOSClient/Viewer/NCViewerMedia/Model - View/NCMediaViewerModel.swift +++ b/iOSClient/Viewer/NCViewerMedia/Model - View/NCMediaViewerModel.swift @@ -420,7 +420,7 @@ final class NCMediaViewerModel: ObservableObject { return } - if isImage(metadata), let previewURL { + if metadata.classFile == NKTypeClassFile.image.rawValue, let previewURL { setState( .image( previewURL: previewURL, @@ -432,7 +432,7 @@ final class NCMediaViewerModel: ObservableObject { ) } - if isVideo(metadata) { + if metadata.classFile == NKTypeClassFile.video.rawValue { setState( .video(previewURL: previewURL), for: ocId @@ -445,7 +445,7 @@ final class NCMediaViewerModel: ObservableObject { } do { - if isAudio(metadata) { + if metadata.classFile == NKTypeClassFile.audio.rawValue { setState( .downloading( previewURL: previewURL, @@ -578,7 +578,7 @@ final class NCMediaViewerModel: ObservableObject { return } - if isImage(metadata), let previewURL { + if metadata.classFile == NKTypeClassFile.image.rawValue, let previewURL { setState( .image( previewURL: previewURL, @@ -591,7 +591,7 @@ final class NCMediaViewerModel: ObservableObject { return } - if isVideo(metadata) { + if metadata.classFile == NKTypeClassFile.video.rawValue { setState( .downloading( previewURL: previewURL, @@ -602,7 +602,7 @@ final class NCMediaViewerModel: ObservableObject { return } - if isAudio(metadata) { + if metadata.classFile == NKTypeClassFile.audio.rawValue { setState( .downloading( previewURL: previewURL, @@ -628,14 +628,6 @@ final class NCMediaViewerModel: ObservableObject { cachedPagesByOcId[ocId]?.state ?? .idle } - private func isAudio(_ metadata: tableMetadata) -> Bool { - metadata.classFile == NKTypeClassFile.audio.rawValue - } - - private func isVideo(_ metadata: tableMetadata) -> Bool { - metadata.classFile == NKTypeClassFile.video.rawValue - } - private func currentPreviewURL(for ocId: String) -> URL? { guard let page = cachedPagesByOcId[ocId] else { return nil @@ -683,7 +675,7 @@ final class NCMediaViewerModel: ObservableObject { for ocId: String, index: Int ) async { - if isImage(metadata) { + if metadata.classFile == NKTypeClassFile.image.rawValue { let livePhotoURL: URL? if metadata.isLivePhoto { @@ -747,9 +739,6 @@ final class NCMediaViewerModel: ObservableObject { loadingTasksByOcId[ocId] = nil } - private func isImage(_ metadata: tableMetadata) -> Bool { - metadata.classFile == NKTypeClassFile.image.rawValue - } } // MARK: - NCMediaViewerPageState Helpers diff --git a/iOSClient/Viewer/NCViewerMedia/Views/NCImageZoomView.swift b/iOSClient/Viewer/NCViewerMedia/Views/NCImageZoomView.swift index 2cf53de2a4..a880bad5fe 100644 --- a/iOSClient/Viewer/NCViewerMedia/Views/NCImageZoomView.swift +++ b/iOSClient/Viewer/NCViewerMedia/Views/NCImageZoomView.swift @@ -310,31 +310,19 @@ struct NCImageZoomView: UIViewRepresentable { let point = gesture.location(in: imageView) let targetScale = min(doubleTapZoomScale, maximumZoomScale) - let zoomRect = zoomRect( - for: scrollView, - scale: targetScale, - center: point + let zoomSize = CGSize( + width: scrollView.bounds.width / targetScale, + height: scrollView.bounds.height / targetScale ) - scrollView.zoom(to: zoomRect, animated: true) - } - - private func zoomRect( - for scrollView: UIScrollView, - scale: CGFloat, - center: CGPoint - ) -> CGRect { - let size = CGSize( - width: scrollView.bounds.width / scale, - height: scrollView.bounds.height / scale + let zoomRect = CGRect( + x: point.x - zoomSize.width * 0.5, + y: point.y - zoomSize.height * 0.5, + width: zoomSize.width, + height: zoomSize.height ) - return CGRect( - x: center.x - size.width * 0.5, - y: center.y - size.height * 0.5, - width: size.width, - height: size.height - ) + scrollView.zoom(to: zoomRect, animated: true) } } diff --git a/iOSClient/Viewer/NCViewerMedia/Views/NCMediaViewerPageView.swift b/iOSClient/Viewer/NCViewerMedia/Views/NCMediaViewerPageView.swift index 7f940b95a8..79e3b1dde4 100644 --- a/iOSClient/Viewer/NCViewerMedia/Views/NCMediaViewerPageView.swift +++ b/iOSClient/Viewer/NCViewerMedia/Views/NCMediaViewerPageView.swift @@ -9,14 +9,6 @@ import NextcloudKit struct NCMediaViewerPageView: View { - // MARK: - Rendered Kind - - private enum NCMediaViewerRenderedKind { - case image - case video - case audio - } - // MARK: - Properties let page: NCMediaViewerPageModel @@ -250,16 +242,8 @@ struct NCMediaViewerPageView: View { previewURL: URL? ) -> some View { if let metadata = page.metadata { - switch mediaKind(for: metadata) { - case .image: - imageContentView( - previewURL: previewURL, - localURL: localURL, - livePhotoURL: nil, - backgroundStyle: backgroundStyle - ) - - case .video: + switch metadata.classFile { + case NKTypeClassFile.video.rawValue: NCVideoViewerContentView( metadata: metadata, localURL: localURL, @@ -276,7 +260,7 @@ struct NCMediaViewerPageView: View { .id("\(page.ocId)-local-\(localURL.absoluteString)") .background(Color.ncViewerBackground(backgroundStyle)) - case .audio: + case NKTypeClassFile.audio.rawValue: NCAudioViewerContentView( metadata: metadata, localURL: localURL, @@ -288,6 +272,14 @@ struct NCMediaViewerPageView: View { onAutoPlayConsumed: consumeAutoPlayIfNeeded ) .background(Color.black) + + default: + imageContentView( + previewURL: previewURL, + localURL: localURL, + livePhotoURL: nil, + backgroundStyle: backgroundStyle + ) } } else { metadataMissingView @@ -455,20 +447,4 @@ struct NCMediaViewerPageView: View { return metadata.fileName } - - private func mediaKind(for metadata: tableMetadata) -> NCMediaViewerRenderedKind { - switch metadata.classFile { - case NKTypeClassFile.image.rawValue: - return .image - - case NKTypeClassFile.video.rawValue: - return .video - - case NKTypeClassFile.audio.rawValue: - return .audio - - default: - return .image - } - } } diff --git a/iOSClient/Viewer/NCViewerMedia/Views/NCMediaViewerPagingView.swift b/iOSClient/Viewer/NCViewerMedia/Views/NCMediaViewerPagingView.swift index f398fc1b42..ad30edbc10 100644 --- a/iOSClient/Viewer/NCViewerMedia/Views/NCMediaViewerPagingView.swift +++ b/iOSClient/Viewer/NCViewerMedia/Views/NCMediaViewerPagingView.swift @@ -54,7 +54,7 @@ struct NCMediaViewerPagingView: UIViewRepresentable { DispatchQueue.main.async { context.coordinator.scrollToInitialIndexIfNeeded(animated: false) context.coordinator.updateCollectionBackground() - context.coordinator.updateVisibleMetadataTitleForCurrentPage() + context.coordinator.updateVisibleMetadataTitle(for: context.coordinator.model.selectedIndex) } return collectionView @@ -148,9 +148,13 @@ final class NCMediaViewerPagingCoordinator: NSObject, self.cancellable = model.$revision .receive(on: RunLoop.main) .sink { [weak self] _ in - self?.refreshVisibleCells() - self?.updateCollectionBackground() - self?.updateVisibleMetadataTitleForCurrentPage() + guard let self else { + return + } + + self.refreshVisibleCells() + self.updateCollectionBackground() + self.updateVisibleMetadataTitle(for: self.model.selectedIndex) } } @@ -242,11 +246,7 @@ final class NCMediaViewerPagingCoordinator: NSObject, collectionView?.backgroundColor = color } - func updateVisibleMetadataTitleForCurrentPage() { - updateVisibleMetadataTitle(for: model.selectedIndex) - } - - private func updateVisibleMetadataTitle(for index: Int) { + func updateVisibleMetadataTitle(for index: Int) { guard index >= 0, index < model.numberOfPages else { return diff --git a/iOSClient/Viewer/NCViewerMedia/Views/NCViewerFloatingTitleView.swift b/iOSClient/Viewer/NCViewerMedia/Views/NCViewerFloatingTitleView.swift index a802f1fb72..c3cef44e4d 100644 --- a/iOSClient/Viewer/NCViewerMedia/Views/NCViewerFloatingTitleView.swift +++ b/iOSClient/Viewer/NCViewerMedia/Views/NCViewerFloatingTitleView.swift @@ -16,7 +16,11 @@ final class NCViewerFloatingTitleView: UIView { init() { super.init(frame: .zero) - configureView() + translatesAutoresizingMaskIntoConstraints = false + backgroundColor = .clear + layoutMargins = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 0) + isAccessibilityElement = true + configureLabels() configureStackView() } @@ -147,13 +151,6 @@ final class NCViewerFloatingTitleView: UIView { ) } - private func configureView() { - translatesAutoresizingMaskIntoConstraints = false - backgroundColor = .clear - layoutMargins = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 0) - isAccessibilityElement = true - } - private func configureLabels() { primaryLabel.font = .preferredFont(forTextStyle: .subheadline) primaryLabel.textColor = .white From 7ca15195faf47c45859a165d6b05144c2e4a36fe Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Sat, 23 May 2026 10:17:16 +0200 Subject: [PATCH 04/54] Refactor the media viewer paging and playback architecture. --- Nextcloud.xcodeproj/project.pbxproj | 2 +- .../AVPlayer/NCVideoAVPlayerPresenter.swift | 4 -- .../NCVideoAVPlayerViewController.swift | 59 ------------------- .../NCVideoAVPlayerViewControls.swift | 5 -- .../Content/Video/NCVideoControlsView.swift | 2 +- .../Video/NCVideoViewerContentView.swift | 36 ----------- .../Video/VLC/NCVideoVLCPresenter.swift | 4 -- .../Video/VLC/NCVideoVLCViewController.swift | 58 ------------------ .../Video/VLC/NCVideoVLCViewControls.swift | 4 -- .../Views/NCMediaViewerPageView.swift | 2 - 10 files changed, 2 insertions(+), 174 deletions(-) diff --git a/Nextcloud.xcodeproj/project.pbxproj b/Nextcloud.xcodeproj/project.pbxproj index 5ea21be103..23c4fe746c 100644 --- a/Nextcloud.xcodeproj/project.pbxproj +++ b/Nextcloud.xcodeproj/project.pbxproj @@ -2842,8 +2842,8 @@ F78448A32FB1BE9000F2909A /* NCVideoPlaybackController.swift */, F78448A82FB1BE9000F2909A /* NCVideoViewerContentView.swift */, F7547FE52FB76C1800E372C3 /* NCVideoControlsView.swift */, - F78448C02FB1C79A00F2909A /* VLC */, F78448BF2FB1C78900F2909A /* AVPlayer */, + F78448C02FB1C79A00F2909A /* VLC */, ); path = Video; sourceTree = ""; diff --git a/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoAVPlayerPresenter.swift b/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoAVPlayerPresenter.swift index 05c482c2f4..adfb55aaa5 100644 --- a/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoAVPlayerPresenter.swift +++ b/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoAVPlayerPresenter.swift @@ -19,7 +19,6 @@ enum NCVideoAVPlayerPresenter { static func present( metadata: tableMetadata, url: URL, - previewURL: URL?, userAgent: String?, contextMenuController: NCMainTabBarController?, canGoPrevious: Bool = false, @@ -33,7 +32,6 @@ enum NCVideoAVPlayerPresenter { currentViewController.update( metadata: metadata, url: url, - previewURL: previewURL, userAgent: userAgent, contextMenuController: contextMenuController ) @@ -66,7 +64,6 @@ enum NCVideoAVPlayerPresenter { currentViewController.update( metadata: metadata, url: url, - previewURL: previewURL, userAgent: userAgent, contextMenuController: contextMenuController ) @@ -104,7 +101,6 @@ enum NCVideoAVPlayerPresenter { let viewController = NCVideoAVPlayerViewController( metadata: metadata, url: url, - previewURL: previewURL, userAgent: userAgent, contextMenuController: contextMenuController ) diff --git a/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoAVPlayerViewController.swift b/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoAVPlayerViewController.swift index 74a662ae8d..897c9b2941 100644 --- a/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoAVPlayerViewController.swift +++ b/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoAVPlayerViewController.swift @@ -37,7 +37,6 @@ final class NCVideoAVPlayerViewController: UIViewController { private var metadata: tableMetadata private var url: URL - private var previewURL: URL? private var userAgent: String? private weak var contextMenuController: NCMainTabBarController? @@ -52,7 +51,6 @@ final class NCVideoAVPlayerViewController: UIViewController { // MARK: - Views internal let playerContainerView = NCVideoAVPlayerLayerView() - private let previewImageView = UIImageView() internal let controlsView = NCVideoControlsView() private let floatingTitleView = NCViewerFloatingTitleView() @@ -121,13 +119,11 @@ final class NCVideoAVPlayerViewController: UIViewController { init( metadata: tableMetadata, url: URL, - previewURL: URL?, userAgent: String?, contextMenuController: NCMainTabBarController? ) { self.metadata = metadata self.url = url - self.previewURL = previewURL self.userAgent = userAgent self.contextMenuController = contextMenuController @@ -165,19 +161,12 @@ final class NCVideoAVPlayerViewController: UIViewController { playerContainerView.translatesAutoresizingMaskIntoConstraints = false playerContainerView.playerLayer.videoGravity = .resizeAspect - previewImageView.backgroundColor = .black - previewImageView.contentMode = .scaleAspectFit - previewImageView.clipsToBounds = true - previewImageView.translatesAutoresizingMaskIntoConstraints = false - updatePreviewImage() - controlsView.delegate = self controlsView.alpha = 0 controlsView.isHidden = true controlsView.translatesAutoresizingMaskIntoConstraints = false rootView.addSubview(playerContainerView) - rootView.addSubview(previewImageView) rootView.addSubview(controlsView) NSLayoutConstraint.activate([ @@ -186,11 +175,6 @@ final class NCVideoAVPlayerViewController: UIViewController { playerContainerView.topAnchor.constraint(equalTo: rootView.topAnchor), playerContainerView.bottomAnchor.constraint(equalTo: rootView.bottomAnchor), - previewImageView.leadingAnchor.constraint(equalTo: rootView.leadingAnchor), - previewImageView.trailingAnchor.constraint(equalTo: rootView.trailingAnchor), - previewImageView.topAnchor.constraint(equalTo: rootView.topAnchor), - previewImageView.bottomAnchor.constraint(equalTo: rootView.bottomAnchor), - controlsView.leadingAnchor.constraint(equalTo: rootView.leadingAnchor), controlsView.trailingAnchor.constraint(equalTo: rootView.trailingAnchor), controlsView.topAnchor.constraint(equalTo: rootView.topAnchor), @@ -253,7 +237,6 @@ final class NCVideoAVPlayerViewController: UIViewController { func update( metadata: tableMetadata, url: URL, - previewURL: URL?, userAgent: String?, contextMenuController: NCMainTabBarController? ) { @@ -265,10 +248,8 @@ final class NCVideoAVPlayerViewController: UIViewController { self.metadata = metadata self.url = url - self.previewURL = previewURL self.userAgent = userAgent self.contextMenuController = contextMenuController - updatePreviewImage() updateTitleLabel(metadata: metadata) refreshMoreMenu() @@ -556,7 +537,6 @@ final class NCVideoAVPlayerViewController: UIViewController { player.replaceCurrentItem(with: item) playerContainerView.player = player - showPreviewImage() configureObservers() configurePictureInPicture() @@ -578,7 +558,6 @@ final class NCVideoAVPlayerViewController: UIViewController { cleanupObservers() player.replaceCurrentItem(with: nil) playerContainerView.player = nil - showPreviewImage() pictureInPictureController?.delegate = nil pictureInPictureController = nil updatePlayPauseButton() @@ -730,47 +709,11 @@ final class NCVideoAVPlayerViewController: UIViewController { return } - hidePreviewImage() - if controlsVisible { scheduleControlsHide() } } - private func updatePreviewImage() { - guard let previewURL, - previewURL.isFileURL else { - previewImageView.image = nil - previewImageView.isHidden = true - return - } - - previewImageView.image = UIImage(contentsOfFile: previewURL.path) - previewImageView.isHidden = previewImageView.image == nil - previewImageView.alpha = 1 - } - - private func showPreviewImage() { - guard previewImageView.image != nil else { - previewImageView.isHidden = true - return - } - - previewImageView.layer.removeAllAnimations() - previewImageView.alpha = 1 - previewImageView.isHidden = false - } - - private func hidePreviewImage() { - guard !previewImageView.isHidden else { - return - } - - previewImageView.layer.removeAllAnimations() - previewImageView.alpha = 0 - previewImageView.isHidden = true - } - private func handlePlaybackEnded() { updatePlayPauseButton() updateProgressControls() @@ -880,7 +823,6 @@ extension NCVideoAVPlayerViewController: AVPictureInPictureControllerDelegate { stopControlsHideTimer() hideControls(animated: false) - hidePreviewImage() } func pictureInPictureControllerDidStartPictureInPicture( @@ -895,7 +837,6 @@ extension NCVideoAVPlayerViewController: AVPictureInPictureControllerDelegate { stopControlsHideTimer() hideControls(animated: false) - hidePreviewImage() } func pictureInPictureControllerWillStopPictureInPicture( diff --git a/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoAVPlayerViewControls.swift b/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoAVPlayerViewControls.swift index 73c24800ca..457798ed3a 100644 --- a/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoAVPlayerViewControls.swift +++ b/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoAVPlayerViewControls.swift @@ -158,11 +158,6 @@ extension NCVideoAVPlayerViewController { // MARK: - Shared Controls Delegate extension NCVideoAVPlayerViewController: NCVideoControlsViewDelegate { - func videoControlsDidTapSubtitle(_ controlsView: NCVideoControlsView) { - } - - func videoControlsDidTapAudio(_ controlsView: NCVideoControlsView) { - } func videoControlsDidTapSeekBackward(_ controlsView: NCVideoControlsView) { seek(bySeconds: -10) diff --git a/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoControlsView.swift b/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoControlsView.swift index 3dffff9fbd..a28879200d 100644 --- a/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoControlsView.swift +++ b/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoControlsView.swift @@ -577,6 +577,7 @@ private struct NCVideoControlsSwiftUIView: View { } .buttonStyle(.plain) } + private func topActionMenu( systemName: String, pointSize: CGFloat, @@ -681,7 +682,6 @@ private struct NCVideoControlsPreviewView: UIViewRepresentable { let controlsView = NCVideoControlsView() controlsView.translatesAutoresizingMaskIntoConstraints = false controlsView.setTopActionsMode(.pictureInPicture) - // controlsView.setTopActionsMode(.vlcTracks) controlsView.updatePlayPauseButton(isPlaying: true) controlsView.updateProgress( progress: 0.42, diff --git a/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoViewerContentView.swift b/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoViewerContentView.swift index 3166b678b8..0253bd2ea9 100644 --- a/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoViewerContentView.swift +++ b/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoViewerContentView.swift @@ -10,7 +10,6 @@ import NextcloudKit struct NCVideoViewerContentView: View { let metadata: tableMetadata let localURL: URL? - let previewURL: URL? let userAgent: String? let isSelected: Bool let contextMenuController: NCMainTabBarController? @@ -37,7 +36,6 @@ struct NCVideoViewerContentView: View { init( metadata: tableMetadata, localURL: URL?, - previewURL: URL? = nil, userAgent: String? = nil, isSelected: Bool = true, contextMenuController: NCMainTabBarController? = nil, @@ -50,7 +48,6 @@ struct NCVideoViewerContentView: View { ) { self.metadata = metadata self.localURL = localURL - self.previewURL = previewURL self.userAgent = userAgent self.isSelected = isSelected self.contextMenuController = contextMenuController @@ -67,8 +64,6 @@ struct NCVideoViewerContentView: View { Color.black .ignoresSafeArea() - NCVideoPreviewPlaceholderView(previewURL: previewURL) - if let errorMessage { failedView(errorMessage) } else { @@ -472,7 +467,6 @@ struct NCVideoViewerContentView: View { NCVideoAVPlayerPresenter.present( metadata: metadata, url: url, - previewURL: previewURL, userAgent: userAgent, contextMenuController: contextMenuController, canGoPrevious: canGoPrevious, @@ -520,7 +514,6 @@ struct NCVideoViewerContentView: View { NCVideoVLCPresenter.present( metadata: metadata, url: url, - previewURL: previewURL, userAgent: userAgent, contextMenuController: contextMenuController, canGoPrevious: canGoPrevious, @@ -582,35 +575,6 @@ struct NCVideoViewerContentView: View { } } -// MARK: - Video Preview Placeholder - -private struct NCVideoPreviewPlaceholderView: View { - let previewURL: URL? - - var body: some View { - ZStack { - Color.black - .ignoresSafeArea() - - if let image = previewImage { - Image(uiImage: image) - .resizable() - .scaledToFit() - .allowsHitTesting(false) - } - } - } - - private var previewImage: UIImage? { - guard let previewURL, - previewURL.isFileURL else { - return nil - } - - return UIImage(contentsOfFile: previewURL.path) - } -} - // MARK: - Video URL Resolution struct NCVideoURLResolver { diff --git a/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCPresenter.swift b/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCPresenter.swift index b31485c4c2..b208022488 100644 --- a/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCPresenter.swift +++ b/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCPresenter.swift @@ -19,7 +19,6 @@ enum NCVideoVLCPresenter { static func present( metadata: tableMetadata, url: URL, - previewURL: URL?, userAgent: String?, contextMenuController: NCMainTabBarController?, canGoPrevious: Bool = false, @@ -33,7 +32,6 @@ enum NCVideoVLCPresenter { currentViewController.update( metadata: metadata, url: url, - previewURL: previewURL, userAgent: userAgent, contextMenuController: contextMenuController ) @@ -65,7 +63,6 @@ enum NCVideoVLCPresenter { currentViewController.update( metadata: metadata, url: url, - previewURL: previewURL, userAgent: userAgent, contextMenuController: contextMenuController ) @@ -103,7 +100,6 @@ enum NCVideoVLCPresenter { let viewController = NCVideoVLCViewController( metadata: metadata, url: url, - previewURL: previewURL, userAgent: userAgent, contextMenuController: contextMenuController ) diff --git a/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCViewController.swift b/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCViewController.swift index da23fa0c78..dfa0681b95 100644 --- a/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCViewController.swift +++ b/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCViewController.swift @@ -17,7 +17,6 @@ final class NCVideoVLCViewController: UIViewController { private var metadata: tableMetadata private var url: URL - private var previewURL: URL? private var userAgent: String? private weak var contextMenuController: NCMainTabBarController? @@ -32,7 +31,6 @@ final class NCVideoVLCViewController: UIViewController { // MARK: - Views internal let drawableView = UIView() - private let previewImageView = UIImageView() internal let controlsView = NCVideoControlsView() private let floatingTitleView = NCViewerFloatingTitleView() @@ -92,13 +90,11 @@ final class NCVideoVLCViewController: UIViewController { init( metadata: tableMetadata, url: URL, - previewURL: URL?, userAgent: String?, contextMenuController: NCMainTabBarController? ) { self.metadata = metadata self.url = url - self.previewURL = previewURL self.userAgent = userAgent self.contextMenuController = contextMenuController @@ -134,11 +130,6 @@ final class NCVideoVLCViewController: UIViewController { drawableView.clipsToBounds = true drawableView.translatesAutoresizingMaskIntoConstraints = false - previewImageView.backgroundColor = .black - previewImageView.contentMode = .scaleAspectFit - previewImageView.clipsToBounds = true - previewImageView.translatesAutoresizingMaskIntoConstraints = false - updatePreviewImage() controlsView.delegate = self controlsView.setTopActionsMode(.vlcTracks) @@ -147,7 +138,6 @@ final class NCVideoVLCViewController: UIViewController { controlsView.translatesAutoresizingMaskIntoConstraints = false rootView.addSubview(drawableView) - rootView.addSubview(previewImageView) rootView.addSubview(controlsView) NSLayoutConstraint.activate([ @@ -156,11 +146,6 @@ final class NCVideoVLCViewController: UIViewController { drawableView.topAnchor.constraint(equalTo: rootView.topAnchor), drawableView.bottomAnchor.constraint(equalTo: rootView.bottomAnchor), - previewImageView.leadingAnchor.constraint(equalTo: rootView.leadingAnchor), - previewImageView.trailingAnchor.constraint(equalTo: rootView.trailingAnchor), - previewImageView.topAnchor.constraint(equalTo: rootView.topAnchor), - previewImageView.bottomAnchor.constraint(equalTo: rootView.bottomAnchor), - controlsView.leadingAnchor.constraint(equalTo: rootView.leadingAnchor), controlsView.trailingAnchor.constraint(equalTo: rootView.trailingAnchor), controlsView.topAnchor.constraint(equalTo: rootView.topAnchor), @@ -224,7 +209,6 @@ final class NCVideoVLCViewController: UIViewController { func update( metadata: tableMetadata, url: URL, - previewURL: URL?, userAgent: String?, contextMenuController: NCMainTabBarController? ) { @@ -236,10 +220,8 @@ final class NCVideoVLCViewController: UIViewController { self.metadata = metadata self.url = url - self.previewURL = previewURL self.userAgent = userAgent self.contextMenuController = contextMenuController - updatePreviewImage() updateTitleLabel(metadata: metadata) refreshVLCTrackMenuItemsWhenPlayerIsActive() @@ -499,7 +481,6 @@ final class NCVideoVLCViewController: UIViewController { private func start() { attachDrawable() - showPreviewImage() let media = VLCMedia(url: url) @@ -530,7 +511,6 @@ final class NCVideoVLCViewController: UIViewController { mediaPlayer.media = nil mediaPlayer.drawable = nil externalSubtitleURL = nil - showPreviewImage() stopProgressTimer() updatePlayPauseButton() updateProgressControls() @@ -544,9 +524,6 @@ final class NCVideoVLCViewController: UIViewController { } mediaPlayer.drawable = drawableView - if mediaPlayer.isPlaying { - hidePreviewImage() - } } private func handleMediaPlayerStateChange() { @@ -577,7 +554,6 @@ final class NCVideoVLCViewController: UIViewController { return } - hidePreviewImage() scheduleControlsHide() } @@ -794,40 +770,6 @@ final class NCVideoVLCViewController: UIViewController { // MARK: - Helpers - private func updatePreviewImage() { - guard let previewURL, - previewURL.isFileURL else { - previewImageView.image = nil - previewImageView.isHidden = true - return - } - - previewImageView.image = UIImage(contentsOfFile: previewURL.path) - previewImageView.isHidden = previewImageView.image == nil - previewImageView.alpha = 1 - } - - private func showPreviewImage() { - guard previewImageView.image != nil else { - previewImageView.isHidden = true - return - } - - previewImageView.layer.removeAllAnimations() - previewImageView.alpha = 1 - previewImageView.isHidden = false - } - - private func hidePreviewImage() { - guard !previewImageView.isHidden else { - return - } - - previewImageView.layer.removeAllAnimations() - previewImageView.alpha = 0 - previewImageView.isHidden = true - } - private func updateControlsNavigationBar() { controlsView.setTopActionsNavigationBar(navigationController?.navigationBar) } diff --git a/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCViewControls.swift b/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCViewControls.swift index 08034c3c2b..768c735e7e 100644 --- a/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCViewControls.swift +++ b/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCViewControls.swift @@ -195,10 +195,6 @@ extension NCVideoVLCViewController: NCVideoControlsViewDelegate { seek(byMilliseconds: 10_000) } - // VLC does not expose Picture in Picture controls. - func videoControlsDidTapPictureInPicture(_ controlsView: NCVideoControlsView) { - } - func videoControlsDidBeginScrubbing(_ controlsView: NCVideoControlsView) { showControls(animated: true) stopControlsHideTimer() diff --git a/iOSClient/Viewer/NCViewerMedia/Views/NCMediaViewerPageView.swift b/iOSClient/Viewer/NCViewerMedia/Views/NCMediaViewerPageView.swift index 79e3b1dde4..6368eeb25f 100644 --- a/iOSClient/Viewer/NCViewerMedia/Views/NCMediaViewerPageView.swift +++ b/iOSClient/Viewer/NCViewerMedia/Views/NCMediaViewerPageView.swift @@ -203,7 +203,6 @@ struct NCMediaViewerPageView: View { NCVideoViewerContentView( metadata: metadata, localURL: nil, - previewURL: previewURL, isSelected: isSelected, contextMenuController: contextMenuController, navigationBar: navigationBar, @@ -247,7 +246,6 @@ struct NCMediaViewerPageView: View { NCVideoViewerContentView( metadata: metadata, localURL: localURL, - previewURL: previewURL, isSelected: isSelected, contextMenuController: contextMenuController, navigationBar: navigationBar, From bd8fe9be9040892889f1a2aa1b690ba5449a20de Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Sun, 24 May 2026 10:58:01 +0200 Subject: [PATCH 05/54] Remove video playback fallback timeout --- .../Content/Video/VLC/NCVideoVLCViewController.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCViewController.swift b/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCViewController.swift index dfa0681b95..9bc1bd4894 100644 --- a/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCViewController.swift +++ b/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCViewController.swift @@ -130,7 +130,6 @@ final class NCVideoVLCViewController: UIViewController { drawableView.clipsToBounds = true drawableView.translatesAutoresizingMaskIntoConstraints = false - controlsView.delegate = self controlsView.setTopActionsMode(.vlcTracks) controlsView.alpha = 0 From 7587bfb7922e1b35e3f011555eb0eaef88ef625f Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Sun, 24 May 2026 10:58:06 +0200 Subject: [PATCH 06/54] Update NCVideoPlaybackController.swift --- .../Video/NCVideoPlaybackController.swift | 50 ------------------- 1 file changed, 50 deletions(-) diff --git a/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoPlaybackController.swift b/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoPlaybackController.swift index 95cd6297f1..1d2bd4df53 100644 --- a/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoPlaybackController.swift +++ b/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoPlaybackController.swift @@ -31,7 +31,6 @@ final class NCVideoPlaybackController: ObservableObject { private var avProbePlayer: AVPlayer? private var avProbeItem: AVPlayerItem? private var statusObservation: NSKeyValueObservation? - private var timeoutTask: Task? private var currentOcId: String? private var currentEtag: String? @@ -39,8 +38,6 @@ final class NCVideoPlaybackController: ObservableObject { private var currentFileName: String? private var loadToken = UUID() - private let fallbackTimeoutMilliseconds = 1_500 - private init() { } // MARK: - Public API @@ -125,11 +122,6 @@ final class NCVideoPlaybackController: ObservableObject { shouldAutoPlay: shouldAutoPlay, token: token ) - - startFallbackTimeout( - url: url, - token: token - ) } func stopIfCurrent(ocId: String) { @@ -143,9 +135,6 @@ final class NCVideoPlaybackController: ObservableObject { func stop() { loadToken = UUID() - timeoutTask?.cancel() - timeoutTask = nil - statusObservation?.invalidate() statusObservation = nil @@ -246,9 +235,6 @@ final class NCVideoPlaybackController: ObservableObject { return } - timeoutTask?.cancel() - timeoutTask = nil - engine = .avFoundation(url: url) nkLog( @@ -259,39 +245,6 @@ final class NCVideoPlaybackController: ObservableObject { ) } - // Fall back to VLC if AVFoundation does not become ready quickly. - private func startFallbackTimeout( - url: URL, - token: UUID - ) { - timeoutTask = Task { [weak self] in - guard let self else { - return - } - - try? await Task.sleep( - for: .milliseconds(self.fallbackTimeoutMilliseconds) - ) - - await MainActor.run { - guard self.isCurrentLoad( - url: url, - token: token - ) else { - return - } - - if case .loading = self.engine { - self.resolveWithVLC( - url: url, - reason: "AVFoundation timeout.", - token: token - ) - } - } - } - } - // MARK: - VLC private func resolveWithVLC( @@ -306,9 +259,6 @@ final class NCVideoPlaybackController: ObservableObject { return } - timeoutTask?.cancel() - timeoutTask = nil - statusObservation?.invalidate() statusObservation = nil From ecd5053084e8395e159049a065bbf0fdf4823839 Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Sun, 24 May 2026 11:00:45 +0200 Subject: [PATCH 07/54] cleaning --- iOSClient/Files/NCFiles.swift | 4 ---- .../NCViewerMedia/Content/Video/NCVideoControlsView.swift | 4 ++-- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/iOSClient/Files/NCFiles.swift b/iOSClient/Files/NCFiles.swift index 44675a705e..b8ef53c5c2 100644 --- a/iOSClient/Files/NCFiles.swift +++ b/iOSClient/Files/NCFiles.swift @@ -126,10 +126,6 @@ class NCFiles: NCCollectionViewCommon { } } - override func viewDidDisappear(_ animated: Bool) { - super.viewDidDisappear(animated) - } - // MARK: - DataSource override func reloadDataSource() async { diff --git a/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoControlsView.swift b/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoControlsView.swift index a28879200d..4cde46abe3 100644 --- a/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoControlsView.swift +++ b/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoControlsView.swift @@ -140,11 +140,11 @@ final class NCVideoControlsView: UIView { func setTopActionsMode(_ mode: NCVideoControlsTopActionsMode) { let didChangeMode = state.topActionsMode != mode var didResetTrackItems = false + let hasTrackItems = !state.subtitleTrackItems.isEmpty || !state.audioTrackItems.isEmpty state.topActionsMode = mode - if mode != .vlcTracks, - (!state.subtitleTrackItems.isEmpty || !state.audioTrackItems.isEmpty) { + if mode != .vlcTracks, hasTrackItems { state.subtitleTrackItems = [] state.audioTrackItems = [] didResetTrackItems = true From c566818e138e990728d90b8a1b8c60bca9d38e0f Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Mon, 25 May 2026 08:40:59 +0200 Subject: [PATCH 08/54] Isolate video progress control area Signed-off-by: Marino Faggiana --- .../Content/Video/NCVideoControlsView.swift | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoControlsView.swift b/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoControlsView.swift index 4cde46abe3..17f1765421 100644 --- a/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoControlsView.swift +++ b/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoControlsView.swift @@ -74,9 +74,9 @@ final class NCVideoControlsView: UIView { fileprivate static let centerControlsWidth: CGFloat = 220 fileprivate static let centerControlsHeight: CGFloat = 76 - fileprivate static let bottomControlsHeight: CGFloat = 64 + fileprivate static let bottomControlsHeight: CGFloat = 46 fileprivate static let bottomControlsHorizontalInset: CGFloat = 28 - fileprivate static let bottomControlsBottomInset: CGFloat = 18 + fileprivate static let bottomControlsBottomInset: CGFloat = 28 fileprivate static let topActionsHeight: CGFloat = 46 fileprivate static let topActionsHorizontalInset: CGFloat = 28 fileprivate static let topActionsButtonSize: CGFloat = 38 @@ -494,6 +494,16 @@ private struct NCVideoControlsSwiftUIView: View { .background(.white.opacity(0.92)) .clipShape(Capsule()) .shadow(color: .black.opacity(0.16), radius: 18, x: 0, y: 5) + .contentShape(Capsule()) + .simultaneousGesture( + DragGesture(minimumDistance: 0) + .onChanged { _ in + onScrubBegan() + } + .onEnded { _ in + onScrubEnded(state.progress) + } + ) } private var topActions: some View { From 6166ec77174c4c078b669cdeaa4dfa6754a8d988 Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Mon, 25 May 2026 08:55:01 +0200 Subject: [PATCH 09/54] Guard paging index updates during layout changes Signed-off-by: Marino Faggiana --- .../Views/NCMediaViewerPagingView.swift | 22 ++++++++++++++++--- 1 file changed, 19 insertions(+), 3 deletions(-) diff --git a/iOSClient/Viewer/NCViewerMedia/Views/NCMediaViewerPagingView.swift b/iOSClient/Viewer/NCViewerMedia/Views/NCMediaViewerPagingView.swift index ad30edbc10..5745d57056 100644 --- a/iOSClient/Viewer/NCViewerMedia/Views/NCMediaViewerPagingView.swift +++ b/iOSClient/Viewer/NCViewerMedia/Views/NCMediaViewerPagingView.swift @@ -495,12 +495,13 @@ final class NCMediaViewerPagingCoordinator: NSObject, refreshVisibleCells() } + func scrollViewWillEndDragging( _ scrollView: UIScrollView, withVelocity velocity: CGPoint, targetContentOffset: UnsafeMutablePointer ) { - guard !isAdjustingLayout else { + guard isScrollGeometryStable(scrollView) else { return } @@ -522,6 +523,21 @@ final class NCMediaViewerPagingCoordinator: NSObject, refreshVisibleCells() } + private func isScrollGeometryStable(_ scrollView: UIScrollView) -> Bool { + guard !isAdjustingLayout else { + return false + } + + let boundsSize = scrollView.bounds.size + + guard boundsSize.width > 0, + boundsSize.height > 0 else { + return false + } + + return boundsSize == lastCollectionViewBoundsSize + } + private func pageIndex(for scrollView: UIScrollView) -> Int? { pageIndex( forContentOffsetX: scrollView.contentOffset.x, @@ -549,7 +565,7 @@ final class NCMediaViewerPagingCoordinator: NSObject, } func scrollViewDidScroll(_ scrollView: UIScrollView) { - guard !isAdjustingLayout else { + guard isScrollGeometryStable(scrollView) else { return } @@ -590,7 +606,7 @@ final class NCMediaViewerPagingCoordinator: NSObject, } private func updateSelectedIndexFromScrollView(_ scrollView: UIScrollView) { - guard !isAdjustingLayout else { + guard isScrollGeometryStable(scrollView) else { return } From e5a39516332496866a73a5fdda222d7c8e188c60 Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Mon, 25 May 2026 09:00:41 +0200 Subject: [PATCH 10/54] Reduce media viewer loader logs Signed-off-by: Marino Faggiana --- .../Loading/NCNextcloudMediaViewerLoader.swift | 16 ---------------- 1 file changed, 16 deletions(-) diff --git a/iOSClient/Viewer/NCViewerMedia/Loading/NCNextcloudMediaViewerLoader.swift b/iOSClient/Viewer/NCViewerMedia/Loading/NCNextcloudMediaViewerLoader.swift index 25b29abad7..b696746621 100644 --- a/iOSClient/Viewer/NCViewerMedia/Loading/NCNextcloudMediaViewerLoader.swift +++ b/iOSClient/Viewer/NCViewerMedia/Loading/NCNextcloudMediaViewerLoader.swift @@ -47,12 +47,9 @@ final class NCMediaViewerLoader: NCMediaViewerLoading, @unchecked Sendable { ) if isValidLocalFile(path: localPath) { - nkLog(tag: NCGlobal.shared.logTagViewer, emoji: .debug, message: "PREVIEW local \(index)", consoleOnly: true) return URL(fileURLWithPath: localPath) } - nkLog(tag: NCGlobal.shared.logTagViewer, emoji: .debug, message: "PREVIEW request \(index)", consoleOnly: true) - let result = await NextcloudKit.shared.downloadPreviewAsync( fileId: metadata.fileId, etag: metadata.etag, @@ -72,8 +69,6 @@ final class NCMediaViewerLoader: NCMediaViewerLoading, @unchecked Sendable { return nil } - nkLog(tag: NCGlobal.shared.logTagViewer, emoji: .debug, message: "PREVIEW ready \(index)", consoleOnly: true) - return URL(fileURLWithPath: localPath) } @@ -84,19 +79,14 @@ final class NCMediaViewerLoader: NCMediaViewerLoading, @unchecked Sendable { return nil } - nkLog(tag: NCGlobal.shared.logTagViewer, emoji: .debug, message: "FULL local \(index)", consoleOnly: true) - return URL(fileURLWithPath: localPath) } func downloadMedia(for metadata: tableMetadata, index: Int) async throws -> URL { if let localURL = await localMediaURL(for: metadata, index: index) { - nkLog(tag: NCGlobal.shared.logTagViewer, emoji: .debug, message: "FULL resolve \(index)", consoleOnly: true) return localURL } - nkLog(tag: NCGlobal.shared.logTagViewer, emoji: .debug, message: "FULL network request \(index)", consoleOnly: true) - guard let metadata = await self.database.setMetadataSessionInWaitDownloadAsync( ocId: metadata.ocId, session: NCNetworking.shared.sessionDownload, @@ -118,7 +108,6 @@ final class NCMediaViewerLoader: NCMediaViewerLoading, @unchecked Sendable { } if let localURL = await localMediaURL(for: metadata, index: index) { - nkLog(tag: NCGlobal.shared.logTagViewer, emoji: .debug, message: "FULL ready \(index)", consoleOnly: true) return localURL } @@ -143,8 +132,6 @@ final class NCMediaViewerLoader: NCMediaViewerLoading, @unchecked Sendable { return nil } - nkLog(tag: NCGlobal.shared.logTagViewer, emoji: .debug, message: "LIVE local \(index)", consoleOnly: true) - return URL(fileURLWithPath: localPath) } @@ -155,7 +142,6 @@ final class NCMediaViewerLoader: NCMediaViewerLoading, @unchecked Sendable { } if let localURL = await localLivePhotoURL(for: metadata, index: index) { - nkLog(tag: NCGlobal.shared.logTagViewer, emoji: .debug, message: "LIVE resolve \(index)", consoleOnly: true) return localURL } @@ -173,7 +159,6 @@ final class NCMediaViewerLoader: NCMediaViewerLoading, @unchecked Sendable { return await localLivePhotoURL(for: metadata, index: index) } - nkLog(tag: NCGlobal.shared.logTagViewer, emoji: .debug, message: "LIVE network request \(index)", consoleOnly: true) guard let downloadMetadata = await database.setMetadataSessionInWaitDownloadAsync( ocId: livePhotoMetadata.ocId, @@ -192,7 +177,6 @@ final class NCMediaViewerLoader: NCMediaViewerLoading, @unchecked Sendable { } if let localURL = await localLivePhotoURL(for: metadata, index: index) { - nkLog(tag: NCGlobal.shared.logTagViewer, emoji: .debug, message: "LIVE ready \(index)", consoleOnly: true) return localURL } From c36a1205f56ba38f8df9d078e0c817b6d36b7255 Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Mon, 25 May 2026 09:05:33 +0200 Subject: [PATCH 11/54] Remove media viewer failed download overlay Signed-off-by: Marino Faggiana --- iOSClient/Supporting Files/en.lproj/Localizable.strings | 2 ++ .../Viewer/NCViewerMedia/Views/NCMediaViewerPageView.swift | 6 +++--- .../NCViewerMedia/Views/NCMediaViewerPagingView.swift | 1 - 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/iOSClient/Supporting Files/en.lproj/Localizable.strings b/iOSClient/Supporting Files/en.lproj/Localizable.strings index 23851fc7e0..f33c061c97 100644 --- a/iOSClient/Supporting Files/en.lproj/Localizable.strings +++ b/iOSClient/Supporting Files/en.lproj/Localizable.strings @@ -682,6 +682,8 @@ "_e2ee_upload_tip_" = "End-to-end files require the app to remain open until the transfer is complete"; "_finalizing_wait_" = "Waiting for finalization …"; "_in_this_folder_" = "In this folder"; +"_media_no_longer_available_" = "Media no longer available"; +"_this_item_has_been_deleted_" = "This item has been deleted."; // Tip "_tip_pdf_thumbnails_" = "Swipe left from the right edge of the screen to show the thumbnails"; diff --git a/iOSClient/Viewer/NCViewerMedia/Views/NCMediaViewerPageView.swift b/iOSClient/Viewer/NCViewerMedia/Views/NCMediaViewerPageView.swift index 6368eeb25f..06360b2973 100644 --- a/iOSClient/Viewer/NCViewerMedia/Views/NCMediaViewerPageView.swift +++ b/iOSClient/Viewer/NCViewerMedia/Views/NCMediaViewerPageView.swift @@ -166,10 +166,10 @@ struct NCMediaViewerPageView: View { Image(systemName: "trash") .font(.system(size: 44, weight: .regular)) - Text("Media no longer available") + Text(NSLocalizedString("_media_no_longer_available_", comment: "")) .font(.headline) - Text("This item has been deleted.") + Text(NSLocalizedString("_this_item_has_been_deleted_", comment: "")) .font(.caption) .foregroundStyle(secondaryForegroundStyle) } @@ -352,7 +352,7 @@ struct NCMediaViewerPageView: View { Image(systemName: "icloud.slash") .font(.system(size: 44, weight: .regular)) - Text("Download failed") + Text(NSLocalizedString("_download_failed_", comment: "")) .font(.headline) if let fileName, !fileName.isEmpty { diff --git a/iOSClient/Viewer/NCViewerMedia/Views/NCMediaViewerPagingView.swift b/iOSClient/Viewer/NCViewerMedia/Views/NCMediaViewerPagingView.swift index 5745d57056..402dd9f35f 100644 --- a/iOSClient/Viewer/NCViewerMedia/Views/NCMediaViewerPagingView.swift +++ b/iOSClient/Viewer/NCViewerMedia/Views/NCMediaViewerPagingView.swift @@ -495,7 +495,6 @@ final class NCMediaViewerPagingCoordinator: NSObject, refreshVisibleCells() } - func scrollViewWillEndDragging( _ scrollView: UIScrollView, withVelocity velocity: CGPoint, From c7539d071e3ee410ae74255d6799ee8cca29ea69 Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Mon, 25 May 2026 09:20:04 +0200 Subject: [PATCH 12/54] cleaning messagge internal log Signed-off-by: Marino Faggiana --- .../en.lproj/Localizable.strings | 12 +++- .../Image/NCImageViewerContentView.swift | 10 ++-- .../Image/NCLivePhotoViewerContentView.swift | 40 ------------- .../AVPlayer/NCVideoAVPlayerPresenter.swift | 12 ---- .../NCVideoAVPlayerViewController.swift | 30 ---------- .../Content/Video/NCVideoControlsView.swift | 2 +- .../Video/NCVideoPlaybackController.swift | 22 ------- .../Video/NCVideoViewerContentView.swift | 57 +----------------- .../Video/VLC/NCVideoVLCPresenter.swift | 12 ---- .../Video/VLC/NCVideoVLCViewController.swift | 6 -- .../Views/NCMediaViewerPageView.swift | 59 ++----------------- 11 files changed, 22 insertions(+), 240 deletions(-) diff --git a/iOSClient/Supporting Files/en.lproj/Localizable.strings b/iOSClient/Supporting Files/en.lproj/Localizable.strings index f33c061c97..2d77429b26 100644 --- a/iOSClient/Supporting Files/en.lproj/Localizable.strings +++ b/iOSClient/Supporting Files/en.lproj/Localizable.strings @@ -671,7 +671,7 @@ "_delete_in_progress_" = "Delete in progress …"; "_download_in_progress_" = "Download in progress …"; "_upload_in_progress_" = "Upload in progress …"; -"_transfer_in_progress_" = "Transfer in progress …"; +"_transfer_in_progress_" = "Transfer in progress …"; "_in_waiting_" = "In waiting"; "_in_progress_" = "In progress"; "_in_error_" = "In error"; @@ -684,6 +684,16 @@ "_in_this_folder_" = "In this folder"; "_media_no_longer_available_" = "Media no longer available"; "_this_item_has_been_deleted_" = "This item has been deleted."; +"_video_not_available_" = "Video not available"; +"_disable_" = "Disable"; +"_no_subtitles_available_" = "No subtitles available"; +"_no_audio_tracks_available_" = "No audio tracks available"; +"_add_external_subtitle_" = "Add external subtitle"; +"_image_load_failed_" = "Image load failed"; +"_image_load_failed_" = "Image load failed"; +"_gif_file_could_not_be_decoded_" = "GIF file could not be decoded."; +"_svg_file_could_not_be_rendered_" = "SVG file could not be rendered."; +"_image_file_could_not_be_decoded_" = "Image file could not be decoded."; // Tip "_tip_pdf_thumbnails_" = "Swipe left from the right edge of the screen to show the thumbnails"; diff --git a/iOSClient/Viewer/NCViewerMedia/Content/Image/NCImageViewerContentView.swift b/iOSClient/Viewer/NCViewerMedia/Content/Image/NCImageViewerContentView.swift index 490a4db72d..7b7450c0b4 100644 --- a/iOSClient/Viewer/NCViewerMedia/Content/Image/NCImageViewerContentView.swift +++ b/iOSClient/Viewer/NCViewerMedia/Content/Image/NCImageViewerContentView.swift @@ -62,7 +62,7 @@ struct NCImageViewerContentView: View { Image(systemName: "photo.badge.exclamationmark") .font(.system(size: 44, weight: .regular)) - Text("Image load failed") + Text(NSLocalizedString("_image_load_failed_", comment: "")) .font(.headline) Text(message) @@ -176,11 +176,11 @@ struct NCImageViewerContentView: View { if currentImage == nil { if isGIF(expectedFullURL) { - failedMessage = "GIF file could not be decoded." + failedMessage = NSLocalizedString("_gif_file_could_not_be_decoded_", comment: "") } else if isSVG(expectedFullURL) { - failedMessage = "SVG file could not be rendered." + failedMessage = NSLocalizedString("_svg_file_could_not_be_rendered_", comment: "") } else { - failedMessage = "UIImage could not decode this file." + failedMessage = NSLocalizedString("_image_file_could_not_be_decoded_", comment: "") } } } @@ -282,11 +282,9 @@ struct NCImageViewerContentView: View { return false } - /* for now disable (marino) if isSVG(url) { return false } - */ return true } diff --git a/iOSClient/Viewer/NCViewerMedia/Content/Image/NCLivePhotoViewerContentView.swift b/iOSClient/Viewer/NCViewerMedia/Content/Image/NCLivePhotoViewerContentView.swift index bcb5c6b33e..c961566a69 100644 --- a/iOSClient/Viewer/NCViewerMedia/Content/Image/NCLivePhotoViewerContentView.swift +++ b/iOSClient/Viewer/NCViewerMedia/Content/Image/NCLivePhotoViewerContentView.swift @@ -19,7 +19,6 @@ struct NCLivePhotoViewerContentView: View { let topOverlayInset: CGFloat @State private var livePhoto: PHLivePhoto? - @State private var failedMessage: String? @State private var isPlayingLivePhoto = false @State private var loadedTaskIdentifier: String? @@ -57,10 +56,6 @@ struct NCLivePhotoViewerContentView: View { } livePhotoBadge - - if let failedMessage { - failedOverlay(failedMessage) - } } .background(Color.ncViewerBackground(backgroundStyle)) .task(id: taskIdentifier) { @@ -176,36 +171,6 @@ struct NCLivePhotoViewerContentView: View { .allowsHitTesting(false) } - private func failedOverlay(_ message: String) -> some View { - VStack(spacing: 8) { - Image(systemName: "livephoto.slash") - .font(.system(size: 24, weight: .regular)) - - Text(message) - .font(.caption) - .multilineTextAlignment(.center) - } - .foregroundStyle(primaryForegroundStyle) - .padding(12) - .background(.black.opacity(0.35)) - .clipShape(RoundedRectangle(cornerRadius: 12)) - .padding() - } - - // MARK: - Appearance - - private var primaryForegroundStyle: Color { - switch backgroundStyle { - case .black: - return .white - - case .system, - .white, - .custom: - return .primary - } - } - // MARK: - Identifiers private var taskIdentifier: String { @@ -223,7 +188,6 @@ struct NCLivePhotoViewerContentView: View { private func loadLivePhotoIfNeeded() async { if loadedTaskIdentifier != taskIdentifier { livePhoto = nil - failedMessage = nil isPlayingLivePhoto = false loadedTaskIdentifier = taskIdentifier } @@ -232,8 +196,6 @@ struct NCLivePhotoViewerContentView: View { return } - failedMessage = nil - guard let fullURL, let videoURL else { return @@ -260,11 +222,9 @@ struct NCLivePhotoViewerContentView: View { } guard let loadedLivePhoto else { - failedMessage = "PHLivePhoto could not load these resources." return } - failedMessage = nil livePhoto = loadedLivePhoto } diff --git a/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoAVPlayerPresenter.swift b/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoAVPlayerPresenter.swift index adfb55aaa5..ed3822c21c 100644 --- a/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoAVPlayerPresenter.swift +++ b/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoAVPlayerPresenter.swift @@ -41,22 +41,10 @@ enum NCVideoAVPlayerPresenter { currentViewController.onNext = onNext currentViewController.onClose = onClose - nkLog( - tag: NCGlobal.shared.logTagViewer, - emoji: .debug, - message: "VIDEO AVPlayer presenter ignored duplicate URL \(url.absoluteString)", - consoleOnly: true - ) return } if isPresenting { - nkLog( - tag: NCGlobal.shared.logTagViewer, - emoji: .debug, - message: "VIDEO AVPlayer presenter ignored while presentation is in progress", - consoleOnly: true - ) return } diff --git a/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoAVPlayerViewController.swift b/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoAVPlayerViewController.swift index 897c9b2941..518b1495dd 100644 --- a/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoAVPlayerViewController.swift +++ b/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoAVPlayerViewController.swift @@ -544,12 +544,6 @@ final class NCVideoAVPlayerViewController: UIViewController { updateProgressControls() updateSeekingState() - nkLog( - tag: NCGlobal.shared.logTagViewer, - emoji: .debug, - message: "VIDEO AVPlayer UIKit prepared without autoplay ocId \(metadata.ocId), url \(url.absoluteString)", - consoleOnly: true - ) } private func stop() { @@ -814,12 +808,6 @@ extension NCVideoAVPlayerViewController: AVPictureInPictureControllerDelegate { func pictureInPictureControllerWillStartPictureInPicture( _ pictureInPictureController: AVPictureInPictureController ) { - nkLog( - tag: NCGlobal.shared.logTagViewer, - emoji: .debug, - message: "VIDEO AVPlayer PiP will start", - consoleOnly: true - ) stopControlsHideTimer() hideControls(animated: false) @@ -828,12 +816,6 @@ extension NCVideoAVPlayerViewController: AVPictureInPictureControllerDelegate { func pictureInPictureControllerDidStartPictureInPicture( _ pictureInPictureController: AVPictureInPictureController ) { - nkLog( - tag: NCGlobal.shared.logTagViewer, - emoji: .debug, - message: "VIDEO AVPlayer PiP did start", - consoleOnly: true - ) stopControlsHideTimer() hideControls(animated: false) @@ -842,23 +824,11 @@ extension NCVideoAVPlayerViewController: AVPictureInPictureControllerDelegate { func pictureInPictureControllerWillStopPictureInPicture( _ pictureInPictureController: AVPictureInPictureController ) { - nkLog( - tag: NCGlobal.shared.logTagViewer, - emoji: .debug, - message: "VIDEO AVPlayer PiP will stop", - consoleOnly: true - ) } func pictureInPictureControllerDidStopPictureInPicture( _ pictureInPictureController: AVPictureInPictureController ) { - nkLog( - tag: NCGlobal.shared.logTagViewer, - emoji: .debug, - message: "VIDEO AVPlayer PiP did stop", - consoleOnly: true - ) updatePlayPauseButton() updateProgressControls() diff --git a/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoControlsView.swift b/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoControlsView.swift index 17f1765421..2cb166d99c 100644 --- a/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoControlsView.swift +++ b/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoControlsView.swift @@ -699,7 +699,7 @@ private struct NCVideoControlsPreviewView: UIViewRepresentable { remainingText: "−2:31" ) controlsView.setSubtitleTrackMenuItems([ - NCVideoTrackMenuItem(index: -1, title: "Disable", isSelected: true), + NCVideoTrackMenuItem(index: -1, title: NSLocalizedString("_disable_", comment: ""), isSelected: true), NCVideoTrackMenuItem(index: 0, title: "English", isSelected: false) ]) controlsView.setAudioTrackMenuItems([ diff --git a/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoPlaybackController.swift b/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoPlaybackController.swift index 1d2bd4df53..d3902c6fa7 100644 --- a/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoPlaybackController.swift +++ b/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoPlaybackController.swift @@ -74,14 +74,6 @@ final class NCVideoPlaybackController: ObservableObject { url: url ) { resumeCurrentPlaybackIfNeeded(shouldAutoPlay: shouldAutoPlay) - - nkLog( - tag: NCGlobal.shared.logTagViewer, - emoji: .debug, - message: "VIDEO controller reuse existing player ocId \(metadata.ocId)", - consoleOnly: true - ) - return } @@ -236,13 +228,6 @@ final class NCVideoPlaybackController: ObservableObject { } engine = .avFoundation(url: url) - - nkLog( - tag: NCGlobal.shared.logTagViewer, - emoji: .debug, - message: "VIDEO engine AVFoundation ready autoplay disabled requested \(shouldAutoPlay)", - consoleOnly: true - ) } // MARK: - VLC @@ -267,13 +252,6 @@ final class NCVideoPlaybackController: ObservableObject { avProbeItem = nil engine = .vlc(url: url) - - nkLog( - tag: NCGlobal.shared.logTagViewer, - emoji: .debug, - message: "VIDEO engine VLC: \(reason)", - consoleOnly: true - ) } // MARK: - State Helpers diff --git a/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoViewerContentView.swift b/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoViewerContentView.swift index 0253bd2ea9..fc8c1a5078 100644 --- a/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoViewerContentView.swift +++ b/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoViewerContentView.swift @@ -157,7 +157,7 @@ struct NCVideoViewerContentView: View { Image(systemName: "video.slash") .font(.system(size: 44, weight: .regular)) - Text("Video not available") + Text(NSLocalizedString("_video_not_available_", comment: "")) .font(.headline) Text(message) @@ -263,54 +263,24 @@ struct NCVideoViewerContentView: View { return } - nkLog( - tag: NCGlobal.shared.logTagViewer, - emoji: .debug, - message: "VIDEO resolve start ocId \(metadata.ocId), fileName \(metadata.fileNameView), fileId \(metadata.fileId)", - consoleOnly: true - ) let result = await resolvedVideoURL( taskIdentifier: expectedTaskIdentifier ) guard !Task.isCancelled else { - nkLog( - tag: NCGlobal.shared.logTagViewer, - emoji: .debug, - message: "VIDEO resolve cancelled ocId \(metadata.ocId)", - consoleOnly: true - ) return } guard expectedTaskIdentifier == taskIdentifier else { - nkLog( - tag: NCGlobal.shared.logTagViewer, - emoji: .debug, - message: "VIDEO resolve ignored stale task ocId \(metadata.ocId)", - consoleOnly: true - ) return } guard expectedLoadGeneration == loadGeneration else { - nkLog( - tag: NCGlobal.shared.logTagViewer, - emoji: .debug, - message: "VIDEO resolve ignored stale generation ocId \(metadata.ocId)", - consoleOnly: true - ) return } guard isSelected else { - nkLog( - tag: NCGlobal.shared.logTagViewer, - emoji: .debug, - message: "VIDEO resolve skipped final not selected ocId \(metadata.ocId), fileName \(metadata.fileNameView)", - consoleOnly: true - ) return } @@ -345,42 +315,17 @@ struct NCVideoViewerContentView: View { source: String ) { guard expectedTaskIdentifier == taskIdentifier else { - nkLog( - tag: NCGlobal.shared.logTagViewer, - emoji: .debug, - message: "VIDEO load ignored stale task ocId \(metadata.ocId), source \(source), url \(url.absoluteString)", - consoleOnly: true - ) return } guard expectedLoadGeneration == loadGeneration else { - nkLog( - tag: NCGlobal.shared.logTagViewer, - emoji: .debug, - message: "VIDEO load ignored stale generation ocId \(metadata.ocId), source \(source), url \(url.absoluteString)", - consoleOnly: true - ) return } guard isSelected else { - nkLog( - tag: NCGlobal.shared.logTagViewer, - emoji: .debug, - message: "VIDEO load skipped not selected ocId \(metadata.ocId), source \(source), url \(url.absoluteString)", - consoleOnly: true - ) return } - nkLog( - tag: NCGlobal.shared.logTagViewer, - emoji: .debug, - message: "VIDEO load \(source) url \(url.absoluteString), isFileURL \(url.isFileURL), fileName \(resolvedFileName)", - consoleOnly: true - ) - resolvedVideoURL = url playback.loadVideo( diff --git a/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCPresenter.swift b/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCPresenter.swift index b208022488..55883ae56a 100644 --- a/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCPresenter.swift +++ b/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCPresenter.swift @@ -40,22 +40,10 @@ enum NCVideoVLCPresenter { currentViewController.onClose = onClose currentViewController.canGoPrevious = canGoPrevious currentViewController.canGoNext = canGoNext - nkLog( - tag: NCGlobal.shared.logTagViewer, - emoji: .debug, - message: "VIDEO VLC presenter ignored duplicate URL \(url.absoluteString)", - consoleOnly: true - ) return } if isPresenting { - nkLog( - tag: NCGlobal.shared.logTagViewer, - emoji: .debug, - message: "VIDEO VLC presenter ignored while presentation is in progress", - consoleOnly: true - ) return } diff --git a/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCViewController.swift b/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCViewController.swift index 9bc1bd4894..d25cced7cb 100644 --- a/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCViewController.swift +++ b/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCViewController.swift @@ -497,12 +497,6 @@ final class NCVideoVLCViewController: UIViewController { showControls(animated: false) stopControlsHideTimer() - nkLog( - tag: NCGlobal.shared.logTagViewer, - emoji: .debug, - message: "VIDEO VLC UIKit prepared without autoplay ocId \(metadata.ocId), url \(url.absoluteString)", - consoleOnly: true - ) } private func stop() { diff --git a/iOSClient/Viewer/NCViewerMedia/Views/NCMediaViewerPageView.swift b/iOSClient/Viewer/NCViewerMedia/Views/NCMediaViewerPageView.swift index 06360b2973..a1d8428616 100644 --- a/iOSClient/Viewer/NCViewerMedia/Views/NCMediaViewerPageView.swift +++ b/iOSClient/Viewer/NCViewerMedia/Views/NCMediaViewerPageView.swift @@ -289,18 +289,11 @@ struct NCMediaViewerPageView: View { previewURL: URL?, message: String ) -> some View { - ZStack { - if let previewURL { - previewOnlyView(previewURL: previewURL) - } else { - Color.ncViewerBackground(backgroundStyle) - .ignoresSafeArea() - } - - failedOverlay( - fileName: displayFileName(from: page.metadata), - message: message - ) + if let previewURL { + previewOnlyView(previewURL: previewURL) + } else { + Color.ncViewerBackground(backgroundStyle) + .ignoresSafeArea() } } @@ -347,36 +340,6 @@ struct NCMediaViewerPageView: View { .gesture(chromeToggleGesture()) } - private func failedOverlay(fileName: String?, message: String) -> some View { - VStack(spacing: 12) { - Image(systemName: "icloud.slash") - .font(.system(size: 44, weight: .regular)) - - Text(NSLocalizedString("_download_failed_", comment: "")) - .font(.headline) - - if let fileName, !fileName.isEmpty { - Text(fileName) - .font(.footnote) - .foregroundStyle(.white.opacity(0.65)) - .lineLimit(1) - .truncationMode(.middle) - } - - if !message.isEmpty { - Text(message) - .font(.caption) - .foregroundStyle(.white.opacity(0.55)) - .multilineTextAlignment(.center) - } - } - .foregroundStyle(.white) - .multilineTextAlignment(.center) - .padding(16) - .background(.black.opacity(0.45)) - .clipShape(RoundedRectangle(cornerRadius: 16)) - .padding() - } // Keep double tap reserved for image zoom. private func chromeToggleGesture() -> some Gesture { @@ -433,16 +396,4 @@ struct NCMediaViewerPageView: View { return safeTop + 44 + 8 } - - private func displayFileName(from metadata: tableMetadata?) -> String? { - guard let metadata else { - return nil - } - - if !metadata.fileNameView.isEmpty { - return metadata.fileNameView - } - - return metadata.fileName - } } From 9fc1b8af838d1a27d86e2d6bf65e6d99b55a81ff Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Mon, 25 May 2026 09:20:43 +0200 Subject: [PATCH 13/54] lint Signed-off-by: Marino Faggiana --- .../NCViewerMedia/Content/Video/NCVideoViewerContentView.swift | 1 - .../NCViewerMedia/Loading/NCNextcloudMediaViewerLoader.swift | 1 - iOSClient/Viewer/NCViewerMedia/Views/NCMediaViewerPageView.swift | 1 - 3 files changed, 3 deletions(-) diff --git a/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoViewerContentView.swift b/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoViewerContentView.swift index fc8c1a5078..f2622b90eb 100644 --- a/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoViewerContentView.swift +++ b/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoViewerContentView.swift @@ -263,7 +263,6 @@ struct NCVideoViewerContentView: View { return } - let result = await resolvedVideoURL( taskIdentifier: expectedTaskIdentifier ) diff --git a/iOSClient/Viewer/NCViewerMedia/Loading/NCNextcloudMediaViewerLoader.swift b/iOSClient/Viewer/NCViewerMedia/Loading/NCNextcloudMediaViewerLoader.swift index b696746621..f81d004ed4 100644 --- a/iOSClient/Viewer/NCViewerMedia/Loading/NCNextcloudMediaViewerLoader.swift +++ b/iOSClient/Viewer/NCViewerMedia/Loading/NCNextcloudMediaViewerLoader.swift @@ -159,7 +159,6 @@ final class NCMediaViewerLoader: NCMediaViewerLoading, @unchecked Sendable { return await localLivePhotoURL(for: metadata, index: index) } - guard let downloadMetadata = await database.setMetadataSessionInWaitDownloadAsync( ocId: livePhotoMetadata.ocId, session: NCNetworking.shared.sessionDownload, diff --git a/iOSClient/Viewer/NCViewerMedia/Views/NCMediaViewerPageView.swift b/iOSClient/Viewer/NCViewerMedia/Views/NCMediaViewerPageView.swift index a1d8428616..90b1210420 100644 --- a/iOSClient/Viewer/NCViewerMedia/Views/NCMediaViewerPageView.swift +++ b/iOSClient/Viewer/NCViewerMedia/Views/NCMediaViewerPageView.swift @@ -340,7 +340,6 @@ struct NCMediaViewerPageView: View { .gesture(chromeToggleGesture()) } - // Keep double tap reserved for image zoom. private func chromeToggleGesture() -> some Gesture { TapGesture(count: 2) From 62d886f2a1a40360d1324fcf553238df93abe2da Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Mon, 25 May 2026 18:40:12 +0200 Subject: [PATCH 14/54] Enable VisionKit only for full images Signed-off-by: Marino Faggiana --- .../Content/Image/NCImageViewerContentView.swift | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/iOSClient/Viewer/NCViewerMedia/Content/Image/NCImageViewerContentView.swift b/iOSClient/Viewer/NCViewerMedia/Content/Image/NCImageViewerContentView.swift index 7b7450c0b4..f1f7e71d3f 100644 --- a/iOSClient/Viewer/NCViewerMedia/Content/Image/NCImageViewerContentView.swift +++ b/iOSClient/Viewer/NCViewerMedia/Content/Image/NCImageViewerContentView.swift @@ -18,6 +18,7 @@ struct NCImageViewerContentView: View { @State private var loadedFullURL: URL? @State private var loadedIdentifier: String? @State private var failedMessage: String? + @State private var isShowingFullImage = false private var taskIdentifier: String { "\(identifier)|\(previewURL?.absoluteString ?? "")|\(fullURL?.absoluteString ?? "")" @@ -114,6 +115,7 @@ struct NCImageViewerContentView: View { loadedPreviewURL = nil loadedFullURL = nil failedMessage = nil + isShowingFullImage = false loadedIdentifier = expectedIdentifier } @@ -131,6 +133,7 @@ struct NCImageViewerContentView: View { loadedPreviewURL = expectedPreviewURL failedMessage = nil + isShowingFullImage = false currentImage = previewImage await Task.yield() @@ -148,6 +151,7 @@ struct NCImageViewerContentView: View { if loadedPreviewURL == expectedFullURL, currentImage != nil { loadedFullURL = expectedFullURL + isShowingFullImage = true return } @@ -170,6 +174,7 @@ struct NCImageViewerContentView: View { if let fullImage { loadedFullURL = expectedFullURL failedMessage = nil + isShowingFullImage = true currentImage = fullImage return } @@ -272,17 +277,16 @@ struct NCImageViewerContentView: View { } private var allowsImageAnalysis: Bool { - let url = fullURL ?? previewURL - - guard let url else { + guard isShowingFullImage, + let fullURL else { return false } - if isGIF(url) { + if isGIF(fullURL) { return false } - if isSVG(url) { + if isSVG(fullURL) { return false } From ece7a76b405b15aeb4e3ce1d84ed826f1608e6ed Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Mon, 25 May 2026 18:43:00 +0200 Subject: [PATCH 15/54] Pass audio preview to audio viewer Signed-off-by: Marino Faggiana --- .../Audio/NCAudioViewerContentView.swift | 32 +++++++++++++++---- .../Views/NCMediaViewerPageView.swift | 1 + 2 files changed, 27 insertions(+), 6 deletions(-) diff --git a/iOSClient/Viewer/NCViewerMedia/Content/Audio/NCAudioViewerContentView.swift b/iOSClient/Viewer/NCViewerMedia/Content/Audio/NCAudioViewerContentView.swift index 7e8871661a..f7b8326337 100644 --- a/iOSClient/Viewer/NCViewerMedia/Content/Audio/NCAudioViewerContentView.swift +++ b/iOSClient/Viewer/NCViewerMedia/Content/Audio/NCAudioViewerContentView.swift @@ -11,6 +11,7 @@ import NextcloudKit struct NCAudioViewerContentView: View { let metadata: tableMetadata let localURL: URL + let previewURL: URL? let canGoPrevious: Bool let canGoNext: Bool let shouldAutoPlay: Bool @@ -23,6 +24,7 @@ struct NCAudioViewerContentView: View { init( metadata: tableMetadata, localURL: URL, + previewURL: URL? = nil, canGoPrevious: Bool = false, canGoNext: Bool = false, shouldAutoPlay: Bool = false, @@ -32,6 +34,7 @@ struct NCAudioViewerContentView: View { ) { self.metadata = metadata self.localURL = localURL + self.previewURL = previewURL self.canGoPrevious = canGoPrevious self.canGoNext = canGoNext self.shouldAutoPlay = shouldAutoPlay @@ -138,14 +141,31 @@ struct NCAudioViewerContentView: View { private var artworkView: some View { ZStack { - Circle() - .fill(.white.opacity(0.08)) - .frame(width: 180, height: 180) + if let previewImage { + Image(uiImage: previewImage) + .resizable() + .scaledToFill() + .frame(width: 180, height: 180) + .clipShape(RoundedRectangle(cornerRadius: 24)) + } else { + Circle() + .fill(.white.opacity(0.08)) + .frame(width: 180, height: 180) + + Image(systemName: "waveform") + .font(.system(size: 76, weight: .regular)) + .foregroundStyle(.white.opacity(0.9)) + } + } + } - Image(systemName: "waveform") - .font(.system(size: 76, weight: .regular)) - .foregroundStyle(.white.opacity(0.9)) + private var previewImage: UIImage? { + guard let previewURL, + previewURL.isFileURL else { + return nil } + + return UIImage(contentsOfFile: previewURL.path) } // MARK: - Private diff --git a/iOSClient/Viewer/NCViewerMedia/Views/NCMediaViewerPageView.swift b/iOSClient/Viewer/NCViewerMedia/Views/NCMediaViewerPageView.swift index 90b1210420..92968fc3a2 100644 --- a/iOSClient/Viewer/NCViewerMedia/Views/NCMediaViewerPageView.swift +++ b/iOSClient/Viewer/NCViewerMedia/Views/NCMediaViewerPageView.swift @@ -262,6 +262,7 @@ struct NCMediaViewerPageView: View { NCAudioViewerContentView( metadata: metadata, localURL: localURL, + previewURL: previewURL, canGoPrevious: canGoPrevious, canGoNext: canGoNext, shouldAutoPlay: effectiveShouldAutoPlay, From 8e49e08a2c09e9ac370c80481ef52a7b1e033fc8 Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Mon, 25 May 2026 18:57:13 +0200 Subject: [PATCH 16/54] Avoid standalone preview for audio pages Signed-off-by: Marino Faggiana --- .../Viewer/NCViewerMedia/Views/NCMediaViewerPageView.swift | 3 +++ 1 file changed, 3 insertions(+) diff --git a/iOSClient/Viewer/NCViewerMedia/Views/NCMediaViewerPageView.swift b/iOSClient/Viewer/NCViewerMedia/Views/NCMediaViewerPageView.swift index 92968fc3a2..153ef57a8f 100644 --- a/iOSClient/Viewer/NCViewerMedia/Views/NCMediaViewerPageView.swift +++ b/iOSClient/Viewer/NCViewerMedia/Views/NCMediaViewerPageView.swift @@ -227,6 +227,9 @@ struct NCMediaViewerPageView: View { if page.metadata?.classFile == NKTypeClassFile.video.rawValue, isSelected { videoStateView(previewURL: previewURL) + } else if page.metadata?.classFile == NKTypeClassFile.audio.rawValue { + Color.ncViewerBackground(backgroundStyle) + .ignoresSafeArea() } else if let previewURL { previewOnlyView(previewURL: previewURL) } else { From 62426787e8033cc60e634f745b944b61bd6d04bb Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Mon, 25 May 2026 19:08:41 +0200 Subject: [PATCH 17/54] Audio GUI improvements Signed-off-by: Marino Faggiana --- .../Audio/NCAudioViewerContentView.swift | 181 ++++++++++-------- .../Model - View/NCMediaViewerModel.swift | 67 ++++++- 2 files changed, 165 insertions(+), 83 deletions(-) diff --git a/iOSClient/Viewer/NCViewerMedia/Content/Audio/NCAudioViewerContentView.swift b/iOSClient/Viewer/NCViewerMedia/Content/Audio/NCAudioViewerContentView.swift index f7b8326337..3edf72538f 100644 --- a/iOSClient/Viewer/NCViewerMedia/Content/Audio/NCAudioViewerContentView.swift +++ b/iOSClient/Viewer/NCViewerMedia/Content/Audio/NCAudioViewerContentView.swift @@ -50,77 +50,91 @@ struct NCAudioViewerContentView: View { } var body: some View { - VStack(spacing: 28) { - artworkView - - VStack(spacing: 8) { - Text(displayFileName) - .font(.headline) - .foregroundStyle(.white) - .lineLimit(2) - .multilineTextAlignment(.center) - - Text(metadata.contentType.isEmpty ? "Audio" : metadata.contentType) - .font(.footnote) - .foregroundStyle(.white.opacity(0.55)) - .lineLimit(1) - } - .padding(.horizontal, 24) + GeometryReader { proxy in + let isLandscape = proxy.size.width > proxy.size.height + let artworkSize: CGFloat = isLandscape ? 110 : 180 + let mainSpacing: CGFloat = isLandscape ? 18 : 28 + let titleHorizontalPadding: CGFloat = 24 + let sliderHorizontalPadding: CGFloat = isLandscape ? 90 : 32 + let topPadding: CGFloat = isLandscape ? 72 : 0 + let buttonSpacing: CGFloat = isLandscape ? 24 : 28 + let sideButtonSize: CGFloat = isLandscape ? 30 : 34 + let playButtonSize: CGFloat = isLandscape ? 64 : 72 + + VStack(spacing: mainSpacing) { + artworkView(size: artworkSize) + if !isLandscape { + VStack(spacing: 8) { + Text(displayFileName) + .font(.headline) + .foregroundStyle(.white) + .lineLimit(2) + .multilineTextAlignment(.center) + + Text(metadata.contentType.isEmpty ? "Audio" : metadata.contentType) + .font(.footnote) + .foregroundStyle(.white.opacity(0.55)) + .lineLimit(1) + } + .padding(.horizontal, titleHorizontalPadding) + } - VStack(spacing: 10) { - Slider( - value: Binding( - get: { model.currentTime }, - set: { model.seek(to: $0) } - ), - in: 0...max(model.duration, 1) - ) - .disabled(model.duration <= 0) + VStack(spacing: 10) { + Slider( + value: Binding( + get: { model.currentTime }, + set: { model.seek(to: $0) } + ), + in: 0...max(model.duration, 1) + ) + .disabled(model.duration <= 0) - HStack { - Text(formatTime(model.currentTime)) + HStack { + Text(formatTime(model.currentTime)) - Spacer() + Spacer() - Text(formatTime(model.duration)) - } - .font(.caption.monospacedDigit()) - .foregroundStyle(.white.opacity(0.6)) - } - .padding(.horizontal, 32) - - HStack(spacing: 28) { - Button { - model.toggleLoop() - } label: { - Image(systemName: model.isLoopEnabled ? "repeat.circle.fill" : "repeat.circle") - .font(.system(size: 34, weight: .regular)) - .foregroundStyle(model.isLoopEnabled ? .white : .white.opacity(0.45)) - } - .buttonStyle(.plain) - - Button { - model.togglePlayback() - } label: { - Image(systemName: model.isPlaying ? "pause.circle.fill" : "play.circle.fill") - .font(.system(size: 72, weight: .regular)) - .foregroundStyle(.white) + Text(formatTime(model.duration)) + } + .font(.caption.monospacedDigit()) + .foregroundStyle(.white.opacity(0.6)) } - .buttonStyle(.plain) - - Button { - model.restart() - } label: { - Image(systemName: "gobackward") - .font(.system(size: 34, weight: .regular)) - .foregroundStyle(.white.opacity(0.45)) + .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 ? .white : .white.opacity(0.45)) + } + .buttonStyle(.plain) + + Button { + model.togglePlayback() + } label: { + Image(systemName: model.isPlaying ? "pause.circle.fill" : "play.circle.fill") + .font(.system(size: playButtonSize, weight: .regular)) + .foregroundStyle(.white) + } + .buttonStyle(.plain) + + Button { + model.restart() + } label: { + Image(systemName: "gobackward") + .font(.system(size: sideButtonSize, weight: .regular)) + .foregroundStyle(.white.opacity(0.45)) + } + .buttonStyle(.plain) + .disabled(model.duration <= 0) } - .buttonStyle(.plain) - .disabled(model.duration <= 0) } + .padding(.top, topPadding) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color.black) } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(Color.black) .task(id: localURL) { await model.load(url: localURL) consumeAutoPlayIfNeeded() @@ -139,18 +153,18 @@ struct NCAudioViewerContentView: View { // MARK: - Views - private var artworkView: some View { + private func artworkView(size: CGFloat) -> some View { ZStack { if let previewImage { Image(uiImage: previewImage) .resizable() .scaledToFill() - .frame(width: 180, height: 180) + .frame(width: size, height: size) .clipShape(RoundedRectangle(cornerRadius: 24)) } else { Circle() .fill(.white.opacity(0.08)) - .frame(width: 180, height: 180) + .frame(width: size, height: size) Image(systemName: "waveform") .font(.system(size: 76, weight: .regular)) @@ -275,26 +289,29 @@ final class NCAudioViewerModel: ObservableObject { self.player = player - let loadedDuration: Double + addTimeObserver(to: player) + addEndObserver(for: item, player: player) - if let duration = try? await asset.load(.duration), - duration.seconds.isFinite { - loadedDuration = duration.seconds - } else { - loadedDuration = 0 - } + Task { [weak self] in + let loadedDuration: Double - guard !Task.isCancelled, - currentURL == url, - self.player === player else { - player.pause() - return - } + if let duration = try? await asset.load(.duration), + duration.seconds.isFinite { + loadedDuration = duration.seconds + } else { + loadedDuration = 0 + } - self.duration = loadedDuration + await MainActor.run { + guard let self, + self.currentURL == url, + self.player === player else { + return + } - addTimeObserver(to: player) - addEndObserver(for: item, player: player) + self.duration = loadedDuration + } + } } func play() { diff --git a/iOSClient/Viewer/NCViewerMedia/Model - View/NCMediaViewerModel.swift b/iOSClient/Viewer/NCViewerMedia/Model - View/NCMediaViewerModel.swift index 001bfc65c6..596fda1afc 100644 --- a/iOSClient/Viewer/NCViewerMedia/Model - View/NCMediaViewerModel.swift +++ b/iOSClient/Viewer/NCViewerMedia/Model - View/NCMediaViewerModel.swift @@ -387,6 +387,25 @@ final class NCMediaViewerModel: ObservableObject { return } + if metadata.classFile == NKTypeClassFile.audio.rawValue { + await setReadyState( + metadata: metadata, + previewURL: previewURL, + localURL: localURL, + for: ocId, + index: index + ) + + await loadAudioPreviewIfNeeded( + metadata: metadata, + localURL: localURL, + currentPreviewURL: previewURL, + for: ocId, + index: index + ) + return + } + if previewURL == nil { previewURL = await loader.previewURL( for: metadata, @@ -412,7 +431,8 @@ final class NCMediaViewerModel: ObservableObject { return } - if previewURL == nil { + if metadata.classFile != NKTypeClassFile.audio.rawValue, + previewURL == nil { previewURL = await loader.previewURL(for: metadata, index: index) } @@ -471,6 +491,16 @@ final class NCMediaViewerModel: ObservableObject { for: ocId, index: index ) + + if metadata.classFile == NKTypeClassFile.audio.rawValue { + await loadAudioPreviewIfNeeded( + metadata: metadata, + localURL: downloadedURL, + currentPreviewURL: previewURL, + for: ocId, + index: index + ) + } } catch is CancellationError { return } catch { @@ -707,6 +737,41 @@ final class NCMediaViewerModel: ObservableObject { } } + private func loadAudioPreviewIfNeeded( + metadata: tableMetadata, + localURL: URL, + currentPreviewURL: URL?, + for ocId: String, + index: Int + ) async { + guard currentPreviewURL == nil else { + return + } + + let previewURL = await loader.previewURL( + for: metadata, + index: index + ) + + guard !Task.isCancelled, + let previewURL else { + return + } + + guard case .ready(let readyLocalURL, _) = pageState(for: ocId), + readyLocalURL == localURL else { + return + } + + setState( + .ready( + localURL: localURL, + previewURL: previewURL + ), + for: ocId + ) + } + private func updatePage( ocId: String, mutation: (inout NCMediaViewerPageModel) -> Void From d3118646660372cea4cb08173e285ead51afe683 Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Tue, 26 May 2026 07:56:40 +0200 Subject: [PATCH 18/54] cleaning code Signed-off-by: Marino Faggiana --- .../en.lproj/Localizable.strings | 7 +- .../Model - View/NCMediaViewerModel.swift | 57 ++++---- .../NCMediaViewerPresenter.swift | 42 +++--- .../Views/NCMediaViewerPageView.swift | 125 +++++++++--------- 4 files changed, 115 insertions(+), 116 deletions(-) diff --git a/iOSClient/Supporting Files/en.lproj/Localizable.strings b/iOSClient/Supporting Files/en.lproj/Localizable.strings index 2d77429b26..18d9ca61bd 100644 --- a/iOSClient/Supporting Files/en.lproj/Localizable.strings +++ b/iOSClient/Supporting Files/en.lproj/Localizable.strings @@ -691,9 +691,10 @@ "_add_external_subtitle_" = "Add external subtitle"; "_image_load_failed_" = "Image load failed"; "_image_load_failed_" = "Image load failed"; -"_gif_file_could_not_be_decoded_" = "GIF file could not be decoded."; -"_svg_file_could_not_be_rendered_" = "SVG file could not be rendered."; -"_image_file_could_not_be_decoded_" = "Image file could not be decoded."; +"_gif_file_could_not_be_decoded_" = "GIF file could not be decoded"; +"_svg_file_could_not_be_rendered_" = "SVG file could not be rendered"; +"_image_file_could_not_be_decoded_" = "Image file could not be decoded"; +"_media_not_available_" = "Media not available"; // Tip "_tip_pdf_thumbnails_" = "Swipe left from the right edge of the screen to show the thumbnails"; diff --git a/iOSClient/Viewer/NCViewerMedia/Model - View/NCMediaViewerModel.swift b/iOSClient/Viewer/NCViewerMedia/Model - View/NCMediaViewerModel.swift index 596fda1afc..10b89b630b 100644 --- a/iOSClient/Viewer/NCViewerMedia/Model - View/NCMediaViewerModel.swift +++ b/iOSClient/Viewer/NCViewerMedia/Model - View/NCMediaViewerModel.swift @@ -13,7 +13,8 @@ enum NCMediaViewerPageState { case metadataMissing case checkingLocalFile case image(previewURL: URL?, localURL: URL?, livePhotoURL: URL?, progress: Double?) - case video(previewURL: URL?) + case audio(localURL: URL, previewURL: URL?) + case video case downloading(previewURL: URL?, progress: Double?) case ready(localURL: URL, previewURL: URL?) case deleted @@ -359,13 +360,6 @@ final class NCMediaViewerModel: ObservableObject { return } - nkLog( - tag: NCGlobal.shared.logTagViewer, - emoji: .debug, - message: "LOAD PAGE \(index)", - consoleOnly: true - ) - let ocId = ocIds[index] let metadata = await resolvedMetadata(for: ocId) @@ -431,7 +425,7 @@ final class NCMediaViewerModel: ObservableObject { return } - if metadata.classFile != NKTypeClassFile.audio.rawValue, + if metadata.classFile == NKTypeClassFile.image.rawValue, previewURL == nil { previewURL = await loader.previewURL(for: metadata, index: index) } @@ -454,7 +448,7 @@ final class NCMediaViewerModel: ObservableObject { if metadata.classFile == NKTypeClassFile.video.rawValue { setState( - .video(previewURL: previewURL), + .video, for: ocId ) return @@ -579,13 +573,6 @@ final class NCMediaViewerModel: ObservableObject { return } - nkLog( - tag: NCGlobal.shared.logTagViewer, - emoji: .debug, - message: "LOAD PREFETCH \(index)", - consoleOnly: true - ) - let ocId = ocIds[index] let metadata = await resolvedMetadata(for: ocId) @@ -599,10 +586,17 @@ final class NCMediaViewerModel: ObservableObject { setMetadata(metadata, for: ocId) - let previewURL = await loader.previewURL( - for: metadata, - index: index - ) + let previewURL: URL? + + if metadata.classFile == NKTypeClassFile.image.rawValue || + metadata.classFile == NKTypeClassFile.audio.rawValue { + previewURL = await loader.previewURL( + for: metadata, + index: index + ) + } else { + previewURL = nil + } guard !Task.isCancelled else { return @@ -667,19 +661,18 @@ final class NCMediaViewerModel: ObservableObject { case .image(let previewURL, _, _, _): return previewURL - case .video(let previewURL): - return previewURL - case .downloading(let previewURL, _): return previewURL - case .ready(_, let previewURL), + case .audio(_, let previewURL), + .ready(_, let previewURL), .failed(let previewURL, _): return previewURL case .idle, .loadingMetadata, .metadataMissing, + .video, .deleted, .checkingLocalFile: return nil @@ -726,6 +719,14 @@ final class NCMediaViewerModel: ObservableObject { ), for: ocId ) + } else if metadata.classFile == NKTypeClassFile.audio.rawValue { + setState( + .audio( + localURL: localURL, + previewURL: previewURL + ), + for: ocId + ) } else { setState( .ready( @@ -758,13 +759,13 @@ final class NCMediaViewerModel: ObservableObject { return } - guard case .ready(let readyLocalURL, _) = pageState(for: ocId), + guard case .audio(let readyLocalURL, _) = pageState(for: ocId), readyLocalURL == localURL else { return } setState( - .ready( + .audio( localURL: localURL, previewURL: previewURL ), @@ -818,6 +819,7 @@ private extension NCMediaViewerPageState { .metadataMissing, .checkingLocalFile, .image, + .audio, .video, .downloading, .ready, @@ -839,6 +841,7 @@ private extension NCMediaViewerPageState { return true case .image(_, .some, _, _), + .audio, .video, .loadingMetadata, .metadataMissing, diff --git a/iOSClient/Viewer/NCViewerMedia/NCMediaViewerPresenter.swift b/iOSClient/Viewer/NCViewerMedia/NCMediaViewerPresenter.swift index c4a5e92d3f..beb2621bf6 100644 --- a/iOSClient/Viewer/NCViewerMedia/NCMediaViewerPresenter.swift +++ b/iOSClient/Viewer/NCViewerMedia/NCMediaViewerPresenter.swift @@ -3,6 +3,7 @@ // SPDX-License-Identifier: GPL-3.0-or-later import SwiftUI +import NextcloudKit import UIKit // MARK: - Media Viewer Presenter @@ -372,42 +373,25 @@ final class NCMediaViewerPresenter: NSObject { switch page.state { case .image(let previewURL, let localURL, _, _): - if let localURL, - let image = UIImage(contentsOfFile: localURL.path) { - return image - } + return imageFromURL(localURL) ?? imageFromURL(previewURL) - if let previewURL { - return UIImage(contentsOfFile: previewURL.path) - } + case .audio(_, let previewURL): + return imageFromURL(previewURL) + case .video: return nil - case .video(let previewURL): - guard let previewURL else { - return nil - } - - return UIImage(contentsOfFile: previewURL.path) - case .ready(let localURL, let previewURL): - if let image = UIImage(contentsOfFile: localURL.path) { - return image - } - - if let previewURL { - return UIImage(contentsOfFile: previewURL.path) - } - - return nil + return imageFromURL(localURL) ?? imageFromURL(previewURL) case .downloading(let previewURL, _), .failed(let previewURL, _): - guard let previewURL else { + guard page.metadata?.classFile != NKTypeClassFile.audio.rawValue, + page.metadata?.classFile != NKTypeClassFile.video.rawValue else { return nil } - return UIImage(contentsOfFile: previewURL.path) + return imageFromURL(previewURL) case .deleted, .idle, @@ -418,6 +402,14 @@ final class NCMediaViewerPresenter: NSObject { } } + private func imageFromURL(_ url: URL?) -> UIImage? { + guard let url else { + return nil + } + + return UIImage(contentsOfFile: url.path) + } + // MARK: - Cleanup /// Clears retained presenter state after the viewer has been removed. diff --git a/iOSClient/Viewer/NCViewerMedia/Views/NCMediaViewerPageView.swift b/iOSClient/Viewer/NCViewerMedia/Views/NCMediaViewerPageView.swift index 153ef57a8f..aeface0288 100644 --- a/iOSClient/Viewer/NCViewerMedia/Views/NCMediaViewerPageView.swift +++ b/iOSClient/Viewer/NCViewerMedia/Views/NCMediaViewerPageView.swift @@ -51,17 +51,23 @@ struct NCMediaViewerPageView: View { livePhotoURL: livePhotoURL ) - case .video(let previewURL): - videoStateView(previewURL: previewURL) + case .video: + videoStateView() + + case .audio(let localURL, let previewURL): + audioStateView( + localURL: localURL, + previewURL: previewURL + ) case .downloading(let previewURL, let progress): downloadingStateView( previewURL: previewURL, - progress: progress + progress ) case .ready(let localURL, let previewURL): - readyStateView( + genericReadyStateView( localURL: localURL, previewURL: previewURL ) @@ -75,7 +81,7 @@ struct NCMediaViewerPageView: View { case .failed(let previewURL, let message): failedStateView( previewURL: previewURL, - message: message + message ) } } @@ -83,8 +89,6 @@ struct NCMediaViewerPageView: View { .ignoresSafeArea() } - // MARK: - Computed Properties - private var backgroundStyle: NCViewerBackgroundStyle { if isChromeHidden { return .black @@ -153,7 +157,7 @@ struct NCMediaViewerPageView: View { Image(systemName: "photo.badge.exclamationmark") .font(.system(size: 44, weight: .regular)) - Text("Media not available") + Text(NSLocalizedString("_media_not_available_", comment: "")) .font(.headline) } .foregroundStyle(primaryForegroundStyle) @@ -198,7 +202,7 @@ struct NCMediaViewerPageView: View { } @ViewBuilder - private func videoStateView(previewURL: URL?) -> some View { + private func videoStateView() -> some View { if let metadata = page.metadata { NCVideoViewerContentView( metadata: metadata, @@ -219,70 +223,69 @@ struct NCMediaViewerPageView: View { } } + @ViewBuilder + private func audioStateView( + localURL: URL, + previewURL: URL? + ) -> some View { + if let metadata = page.metadata { + NCAudioViewerContentView( + metadata: metadata, + localURL: localURL, + previewURL: previewURL, + canGoPrevious: canGoPrevious, + canGoNext: canGoNext, + shouldAutoPlay: effectiveShouldAutoPlay, + onPrevious: goToPreviousPage, + onNext: goToNextPage, + onAutoPlayConsumed: consumeAutoPlayIfNeeded + ) + .background(Color.black) + } else { + metadataMissingView + } + } + @ViewBuilder private func downloadingStateView( previewURL: URL?, - progress: Double? + _ progress: Double? ) -> some View { - if page.metadata?.classFile == NKTypeClassFile.video.rawValue, - isSelected { - videoStateView(previewURL: previewURL) - } else if page.metadata?.classFile == NKTypeClassFile.audio.rawValue { - Color.ncViewerBackground(backgroundStyle) - .ignoresSafeArea() - } else if let previewURL { - previewOnlyView(previewURL: previewURL) - } else { + switch page.metadata?.classFile { + case NKTypeClassFile.video.rawValue: + if isSelected { + videoStateView() + } else { + Color.ncViewerBackground(backgroundStyle) + .ignoresSafeArea() + } + + case NKTypeClassFile.audio.rawValue: Color.ncViewerBackground(backgroundStyle) .ignoresSafeArea() + + default: + if let previewURL { + previewOnlyView(previewURL: previewURL) + } else { + Color.ncViewerBackground(backgroundStyle) + .ignoresSafeArea() + } } } @ViewBuilder - private func readyStateView( + private func genericReadyStateView( localURL: URL, previewURL: URL? ) -> some View { - if let metadata = page.metadata { - switch metadata.classFile { - case NKTypeClassFile.video.rawValue: - NCVideoViewerContentView( - metadata: metadata, - localURL: localURL, - isSelected: isSelected, - contextMenuController: contextMenuController, - navigationBar: navigationBar, - canGoPrevious: canGoPrevious, - canGoNext: canGoNext, - onPreviousPage: goToPreviousPageFromVideo, - onNextPage: goToNextPageFromVideo, - onClose: onClose - ) - .id("\(page.ocId)-local-\(localURL.absoluteString)") - .background(Color.ncViewerBackground(backgroundStyle)) - - case NKTypeClassFile.audio.rawValue: - NCAudioViewerContentView( - metadata: metadata, - localURL: localURL, - previewURL: previewURL, - canGoPrevious: canGoPrevious, - canGoNext: canGoNext, - shouldAutoPlay: effectiveShouldAutoPlay, - onPrevious: goToPreviousPage, - onNext: goToNextPage, - onAutoPlayConsumed: consumeAutoPlayIfNeeded - ) - .background(Color.black) - - default: - imageContentView( - previewURL: previewURL, - localURL: localURL, - livePhotoURL: nil, - backgroundStyle: backgroundStyle - ) - } + if page.metadata != nil { + imageContentView( + previewURL: previewURL, + localURL: localURL, + livePhotoURL: nil, + backgroundStyle: backgroundStyle + ) } else { metadataMissingView } @@ -291,7 +294,7 @@ struct NCMediaViewerPageView: View { @ViewBuilder private func failedStateView( previewURL: URL?, - message: String + _ message: String ) -> some View { if let previewURL { previewOnlyView(previewURL: previewURL) From e90892d10d988da5a7a11b117490a669b5e1ae09 Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Tue, 26 May 2026 09:35:41 +0200 Subject: [PATCH 19/54] Add fullscreen video transition overlay Signed-off-by: Marino Faggiana --- .../Video/NCVideoViewerContentView.swift | 72 +++++++++++++++++++ 1 file changed, 72 insertions(+) diff --git a/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoViewerContentView.swift b/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoViewerContentView.swift index f2622b90eb..6428b97a0a 100644 --- a/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoViewerContentView.swift +++ b/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoViewerContentView.swift @@ -419,20 +419,26 @@ struct NCVideoViewerContentView: View { onNext: goToNextPageFromAVPlayer, onClose: closeFromFullscreenVideo ) + + NCVideoFullscreenTransitionOverlay.hide() } @MainActor private func goToPreviousPageFromAVPlayer() { + NCVideoFullscreenTransitionOverlay.show() presentedAVPlayerURL = nil NCVideoAVPlayerPresenter.dismiss() onPreviousPage?() + NCVideoFullscreenTransitionOverlay.hideAfterDelay() } @MainActor private func goToNextPageFromAVPlayer() { + NCVideoFullscreenTransitionOverlay.show() presentedAVPlayerURL = nil NCVideoAVPlayerPresenter.dismiss() onNextPage?() + NCVideoFullscreenTransitionOverlay.hideAfterDelay() } @MainActor @@ -440,6 +446,7 @@ struct NCVideoViewerContentView: View { presentedAVPlayerURL = nil presentedVLCURL = nil playback.stop() + NCVideoFullscreenTransitionOverlay.hide() onClose?(ocId) } @@ -466,20 +473,26 @@ struct NCVideoViewerContentView: View { onNext: goToNextPageFromVLC, onClose: closeFromFullscreenVideo ) + + NCVideoFullscreenTransitionOverlay.hide() } @MainActor private func goToPreviousPageFromVLC() { + NCVideoFullscreenTransitionOverlay.show() presentedVLCURL = nil NCVideoVLCPresenter.dismiss() onPreviousPage?() + NCVideoFullscreenTransitionOverlay.hideAfterDelay() } @MainActor private func goToNextPageFromVLC() { + NCVideoFullscreenTransitionOverlay.show() presentedVLCURL = nil NCVideoVLCPresenter.dismiss() onNextPage?() + NCVideoFullscreenTransitionOverlay.hideAfterDelay() } // MARK: - In-Flight Resolution Cache @@ -519,6 +532,65 @@ struct NCVideoViewerContentView: View { } } +// MARK: - Fullscreen Video Transition Overlay + +@MainActor +private enum NCVideoFullscreenTransitionOverlay { + private static weak var overlayView: UIView? + private static var hideTask: Task? + + static func show() { + hideTask?.cancel() + + guard let window = keyWindow else { + return + } + + let overlayView = overlayView ?? makeOverlayView(in: window) + window.bringSubviewToFront(overlayView) + overlayView.frame = window.bounds + overlayView.alpha = 1 + overlayView.isHidden = false + } + + static func hide() { + hideTask?.cancel() + hideTask = nil + + overlayView?.removeFromSuperview() + overlayView = nil + } + + static func hideAfterDelay() { + hideTask?.cancel() + hideTask = Task { @MainActor in + try? await Task.sleep(for: .milliseconds(100)) + hide() + } + } + + private static func makeOverlayView(in window: UIWindow) -> UIView { + let view = UIView(frame: window.bounds) + view.backgroundColor = .black + view.isUserInteractionEnabled = false + view.autoresizingMask = [ + .flexibleWidth, + .flexibleHeight + ] + window.addSubview(view) + overlayView = view + return view + } + + private static var keyWindow: UIWindow? { + UIApplication.shared.connectedScenes + .compactMap { $0 as? UIWindowScene } + .filter { $0.activationState == .foregroundActive } + .flatMap { $0.windows } + .first { $0.isKeyWindow } + } +} + // MARK: - Video URL Resolution struct NCVideoURLResolver { From a2332e8a172bfc06f39373692358e8fd5a7e6753 Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Tue, 26 May 2026 09:48:23 +0200 Subject: [PATCH 20/54] Reduce media loader optional-path logs Signed-off-by: Marino Faggiana --- .../Content/Video/NCVideoViewerContentView.swift | 5 +---- .../Loading/NCNextcloudMediaViewerLoader.swift | 8 -------- 2 files changed, 1 insertion(+), 12 deletions(-) diff --git a/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoViewerContentView.swift b/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoViewerContentView.swift index 6428b97a0a..1d530563f3 100644 --- a/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoViewerContentView.swift +++ b/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoViewerContentView.swift @@ -225,7 +225,7 @@ struct NCVideoViewerContentView: View { } do { - try await Task.sleep(nanoseconds: Self.videoSelectionSettleDelayNanoseconds) + try await Task.sleep(for: .milliseconds(150)) } catch { return false } @@ -520,9 +520,6 @@ struct NCVideoViewerContentView: View { // MARK: - Helpers - // Prevent transient video pages from starting playback work. - private static let videoSelectionSettleDelayNanoseconds: UInt64 = 150_000_000 - private var resolvedFileName: String { if !metadata.fileNameView.isEmpty { return metadata.fileNameView diff --git a/iOSClient/Viewer/NCViewerMedia/Loading/NCNextcloudMediaViewerLoader.swift b/iOSClient/Viewer/NCViewerMedia/Loading/NCNextcloudMediaViewerLoader.swift index f81d004ed4..8d5c5a5200 100644 --- a/iOSClient/Viewer/NCViewerMedia/Loading/NCNextcloudMediaViewerLoader.swift +++ b/iOSClient/Viewer/NCViewerMedia/Loading/NCNextcloudMediaViewerLoader.swift @@ -65,7 +65,6 @@ final class NCMediaViewerLoader: NCMediaViewerLoading, @unchecked Sendable { } guard isValidLocalFile(path: localPath) else { - nkLog(tag: NCGlobal.shared.logTagViewer, emoji: .debug, message: "PREVIEW failed \(index)", consoleOnly: true) return nil } @@ -122,7 +121,6 @@ final class NCMediaViewerLoader: NCMediaViewerLoading, @unchecked Sendable { } guard let livePhotoMetadata = database.getMetadataLivePhoto(metadata: metadata) else { - nkLog(tag: NCGlobal.shared.logTagViewer, emoji: .debug, message: "LIVE metadata missing \(index)", consoleOnly: true) return nil } @@ -146,12 +144,10 @@ final class NCMediaViewerLoader: NCMediaViewerLoading, @unchecked Sendable { } guard NCNetworking.shared.isOnline else { - nkLog(tag: NCGlobal.shared.logTagViewer, emoji: .debug, message: "LIVE offline \(index)", consoleOnly: true) return nil } guard let livePhotoMetadata = database.getMetadataLivePhoto(metadata: metadata) else { - nkLog(tag: NCGlobal.shared.logTagViewer, emoji: .debug, message: "LIVE metadata missing \(index)", consoleOnly: true) return nil } @@ -164,14 +160,12 @@ final class NCMediaViewerLoader: NCMediaViewerLoading, @unchecked Sendable { session: NCNetworking.shared.sessionDownload, selector: "" ) else { - nkLog(tag: NCGlobal.shared.logTagViewer, emoji: .debug, message: "LIVE session error \(index)", consoleOnly: true) return nil } let result = await NCNetworking.shared.downloadFile(metadata: downloadMetadata) if result.afError != nil || result.nkError != .success { - nkLog(tag: NCGlobal.shared.logTagViewer, emoji: .debug, message: "LIVE error \(index)", consoleOnly: true) return nil } @@ -179,8 +173,6 @@ final class NCMediaViewerLoader: NCMediaViewerLoading, @unchecked Sendable { return localURL } - nkLog(tag: NCGlobal.shared.logTagViewer, emoji: .debug, message: "LIVE unavailable after download \(index)", consoleOnly: true) - return nil } From c617bcc39c1d2db3dae22ee9e56a3bc9d05680e4 Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Tue, 26 May 2026 09:55:49 +0200 Subject: [PATCH 21/54] Remove unused media viewer loader error Signed-off-by: Marino Faggiana --- .../Loading/NCNextcloudMediaViewerLoader.swift | 13 +------------ .../Model - View/NCMediaViewerModel.swift | 2 +- 2 files changed, 2 insertions(+), 13 deletions(-) diff --git a/iOSClient/Viewer/NCViewerMedia/Loading/NCNextcloudMediaViewerLoader.swift b/iOSClient/Viewer/NCViewerMedia/Loading/NCNextcloudMediaViewerLoader.swift index 8d5c5a5200..59c05b10f5 100644 --- a/iOSClient/Viewer/NCViewerMedia/Loading/NCNextcloudMediaViewerLoader.swift +++ b/iOSClient/Viewer/NCViewerMedia/Loading/NCNextcloudMediaViewerLoader.swift @@ -112,7 +112,7 @@ final class NCMediaViewerLoader: NCMediaViewerLoading, @unchecked Sendable { nkLog(tag: NCGlobal.shared.logTagViewer, emoji: .debug, message: "FULL unavailable after download \(index)", consoleOnly: true) - throw NCMediaViewerLoaderError.localFileUnavailable + throw NSError(domain: "Download Media", code: 2) } func localLivePhotoURL(for metadata: tableMetadata, index: Int) async -> URL? { @@ -205,17 +205,6 @@ final class NCMediaViewerLoader: NCMediaViewerLoading, @unchecked Sendable { } } -enum NCMediaViewerLoaderError: LocalizedError { - case localFileUnavailable - - var errorDescription: String? { - switch self { - case .localFileUnavailable: - return "The local file is not available." - } - } -} - protocol NCMediaViewerLoading: Sendable { func metadata(for ocId: String, account: String, mediaSearch: Bool) async -> tableMetadata? diff --git a/iOSClient/Viewer/NCViewerMedia/Model - View/NCMediaViewerModel.swift b/iOSClient/Viewer/NCViewerMedia/Model - View/NCMediaViewerModel.swift index 10b89b630b..bbc7b7152c 100644 --- a/iOSClient/Viewer/NCViewerMedia/Model - View/NCMediaViewerModel.swift +++ b/iOSClient/Viewer/NCViewerMedia/Model - View/NCMediaViewerModel.swift @@ -501,7 +501,7 @@ final class NCMediaViewerModel: ObservableObject { setState( .failed( previewURL: previewURL, - message: error.localizedDescription + message: "" ), for: ocId ) From 1b0875e0dc084d9f5afc55e3d13d59018a712a04 Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Tue, 26 May 2026 09:57:42 +0200 Subject: [PATCH 22/54] cleaning Signed-off-by: Marino Faggiana --- .../NCViewerMedia/Loading/NCNextcloudMediaViewerLoader.swift | 5 ----- 1 file changed, 5 deletions(-) diff --git a/iOSClient/Viewer/NCViewerMedia/Loading/NCNextcloudMediaViewerLoader.swift b/iOSClient/Viewer/NCViewerMedia/Loading/NCNextcloudMediaViewerLoader.swift index 59c05b10f5..950875b5f9 100644 --- a/iOSClient/Viewer/NCViewerMedia/Loading/NCNextcloudMediaViewerLoader.swift +++ b/iOSClient/Viewer/NCViewerMedia/Loading/NCNextcloudMediaViewerLoader.swift @@ -90,19 +90,16 @@ final class NCMediaViewerLoader: NCMediaViewerLoading, @unchecked Sendable { ocId: metadata.ocId, session: NCNetworking.shared.sessionDownload, selector: NCGlobal.shared.selectorDownloadFile) else { - nkLog(tag: NCGlobal.shared.logTagViewer, emoji: .debug, message: "FULL error \(index)", consoleOnly: true) throw NSError(domain: "Download Media", code: 1, userInfo: [NSLocalizedDescriptionKey: "FULL error \(index)"]) } let result = await NCNetworking.shared.downloadFile(metadata: metadata) if let afError = result.afError { - nkLog(tag: NCGlobal.shared.logTagViewer, emoji: .debug, message: "FULL error \(index)", consoleOnly: true) throw afError } if result.nkError != .success { - nkLog(tag: NCGlobal.shared.logTagViewer, emoji: .debug, message: "FULL error \(index)", consoleOnly: true) throw result.nkError } @@ -110,8 +107,6 @@ final class NCMediaViewerLoader: NCMediaViewerLoading, @unchecked Sendable { return localURL } - nkLog(tag: NCGlobal.shared.logTagViewer, emoji: .debug, message: "FULL unavailable after download \(index)", consoleOnly: true) - throw NSError(domain: "Download Media", code: 2) } From 7f2e30b3c153d6d380df191035cf6f76abef593e Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Wed, 27 May 2026 06:38:53 +0200 Subject: [PATCH 23/54] Lighten media detail value styling Signed-off-by: Marino Faggiana --- .../Views/NCMediaViewerDetailView.swift | 193 ++++++++++++++++-- 1 file changed, 171 insertions(+), 22 deletions(-) diff --git a/iOSClient/Viewer/NCViewerMedia/Views/NCMediaViewerDetailView.swift b/iOSClient/Viewer/NCViewerMedia/Views/NCMediaViewerDetailView.swift index 557b55e028..ed4c2b2359 100644 --- a/iOSClient/Viewer/NCViewerMedia/Views/NCMediaViewerDetailView.swift +++ b/iOSClient/Viewer/NCViewerMedia/Views/NCMediaViewerDetailView.swift @@ -17,10 +17,7 @@ struct NCMediaViewerDetailView: View { ScrollView { VStack(alignment: .leading, spacing: 18) { dateSection - fileSection - cameraSection - lensSection - exposureSection + mediaSummaryCard locationSection } .frame(maxWidth: .infinity, alignment: .leading) @@ -32,6 +29,60 @@ struct NCMediaViewerDetailView: View { .presentationBackground(Color.ncViewerBackground(.system)) } + private var mediaSummaryCard: some View { + VStack(alignment: .leading, spacing: 0) { + HStack(alignment: .firstTextBaseline, spacing: 8) { + Text(cameraText) + .font(.headline) + .lineLimit(1) + + Spacer(minLength: 8) + + if !metadata.fileExtension.isEmpty { + detailBadge(metadata.fileExtension.uppercased()) + } + } + .padding(.horizontal, 16) + .padding(.top, 14) + .padding(.bottom, 8) + .background(.secondary.opacity(0.10)) + + VStack(alignment: .leading, spacing: 10) { + Text(lensText) + .font(.subheadline) + .foregroundStyle(.secondary) + .lineLimit(2) + + FlowingDetailValues(values: primaryMediaValues) + } + .padding(.horizontal, 16) + .padding(.vertical, 14) + + if !exifStripValues.isEmpty { + Divider() + + HStack(spacing: 0) { + ForEach(Array(exifStripValues.enumerated()), id: \.offset) { index, value in + Text(value) + .font(.subheadline) + .foregroundStyle(.secondary) + .lineLimit(1) + .frame(maxWidth: .infinity) + + if index < exifStripValues.count - 1 { + Divider() + .frame(height: 22) + } + } + } + .padding(.horizontal, 8) + .padding(.vertical, 12) + } + } + .background(.secondary.opacity(0.08)) + .clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous)) + } + // MARK: - Sections @ViewBuilder @@ -167,20 +218,33 @@ struct NCMediaViewerDetailView: View { .foregroundStyle(.primary) } - Map( - initialPosition: .region( - MKCoordinateRegion( - center: coordinate, - latitudinalMeters: 500, - longitudinalMeters: 500 + ZStack { + Map( + initialPosition: .region( + MKCoordinateRegion( + center: coordinate, + latitudinalMeters: 500, + longitudinalMeters: 500 + ) ) - ) - ) { - Marker("", coordinate: coordinate) + ) { + Marker("", coordinate: coordinate) + } + .allowsHitTesting(false) + + Button { + openMaps( + coordinate: coordinate, + name: exif.location + ) + } label: { + Color.clear + .contentShape(Rectangle()) + } + .buttonStyle(.plain) } .frame(height: 180) .clipShape(RoundedRectangle(cornerRadius: 16)) - .allowsHitTesting(false) } } else if let location = exif.location, !location.isEmpty { HStack(spacing: 8) { @@ -196,16 +260,60 @@ struct NCMediaViewerDetailView: View { private func detailBadge(_ text: String) -> some View { Text(text) - .font(.footnote) - .foregroundStyle(.primary) - .padding(.horizontal, 10) - .padding(.vertical, 6) - .background(.secondary.opacity(0.12)) - .clipShape(Capsule()) + .font(.subheadline) + .foregroundStyle(.secondary) + .lineLimit(1) + .padding(.vertical, 2) } // MARK: - Computed Values + private var primaryMediaValues: [String] { + var values: [String] = [] + + if let megapixelsText { + values.append(megapixelsText) + } + + if let resolutionText { + values.append(resolutionText) + } + + values.append(utilityFileSystem.transformedSize(metadata.size)) + + if metadata.isLivePhoto { + values.append("LIVE") + } + + return values + } + + private var exifStripValues: [String] { + var values: [String] = [] + + if let iso = exif.iso { + values.append("ISO \(iso)") + } + + if let lensLength = exif.lensLength { + values.append("\(lensLength) mm") + } + + if let exposureValue = exif.exposureValue { + values.append("\(exposureValue) ev") + } + + if let apertureValue = exif.apertureValue { + values.append("ƒ\(apertureValue)") + } + + if let shutterSpeedApex = exif.shutterSpeedApex { + values.append("1/\(Int(pow(2, shutterSpeedApex))) s") + } + + return values + } + private var fileNameWithoutExtension: String { (metadata.fileNameView as NSString).deletingPathExtension } @@ -252,8 +360,8 @@ struct NCMediaViewerDetailView: View { let megapixels = Double(width * height) / 1_000_000 return megapixels < 1 - ? String(format: "%.1f MP", megapixels) - : "\(Int(megapixels)) MP" + ? String(format: "%.1f MP", megapixels) + : "\(Int(megapixels)) MP" } private var lensValues: [String] { @@ -324,3 +432,44 @@ struct NCMediaViewerDetailView: View { mapItem.openInMaps() } } + +// Helper view for flowing detail values +private struct FlowingDetailValues: View { + let values: [String] + + var body: some View { + ViewThatFits(in: .horizontal) { + HStack(spacing: 6) { + detailValues + } + + LazyVGrid( + columns: [ + GridItem(.adaptive(minimum: 92), spacing: 8) + ], + alignment: .leading, + spacing: 4 + ) { + detailValues + } + } + } + + @ViewBuilder + private var detailValues: some View { + ForEach(Array(values.enumerated()), id: \.offset) { index, value in + HStack(spacing: 6) { + Text(value) + .font(.subheadline) + .foregroundStyle(.secondary) + .lineLimit(1) + + if index < values.count - 1 { + Text("•") + .font(.subheadline) + .foregroundStyle(.tertiary) + } + } + } + } +} From b505b24334c99fa074e904ac88d18c45da803477 Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Wed, 27 May 2026 08:50:30 +0200 Subject: [PATCH 24/54] Fix cached video routing and clean media viewer details Signed-off-by: Marino Faggiana --- .../Model - View/NCMediaViewerModel.swift | 165 ++++++++++++------ .../NCMediaViewerPresenter.swift | 2 +- .../Views/NCMediaViewerPageView.swift | 10 +- 3 files changed, 116 insertions(+), 61 deletions(-) diff --git a/iOSClient/Viewer/NCViewerMedia/Model - View/NCMediaViewerModel.swift b/iOSClient/Viewer/NCViewerMedia/Model - View/NCMediaViewerModel.swift index bbc7b7152c..99ff8f6b4f 100644 --- a/iOSClient/Viewer/NCViewerMedia/Model - View/NCMediaViewerModel.swift +++ b/iOSClient/Viewer/NCViewerMedia/Model - View/NCMediaViewerModel.swift @@ -14,7 +14,7 @@ enum NCMediaViewerPageState { case checkingLocalFile case image(previewURL: URL?, localURL: URL?, livePhotoURL: URL?, progress: Double?) case audio(localURL: URL, previewURL: URL?) - case video + case video(localURL: URL?) case downloading(previewURL: URL?, progress: Double?) case ready(localURL: URL, previewURL: URL?) case deleted @@ -374,34 +374,71 @@ final class NCMediaViewerModel: ObservableObject { setMetadata(metadata, for: ocId) - var previewURL = currentPreviewURL(for: ocId) + let previewURL = currentPreviewURL(for: ocId) if let localURL = await loader.localMediaURL(for: metadata, index: index) { guard !Task.isCancelled else { return } - if metadata.classFile == NKTypeClassFile.audio.rawValue { - await setReadyState( - metadata: metadata, - previewURL: previewURL, - localURL: localURL, - for: ocId, - index: index - ) + await loadLocalPage( + metadata: metadata, + previewURL: previewURL, + localURL: localURL, + for: ocId, + index: index + ) + return + } - await loadAudioPreviewIfNeeded( - metadata: metadata, - localURL: localURL, - currentPreviewURL: previewURL, - for: ocId, - index: index - ) - return - } + guard !Task.isCancelled else { + return + } + + await loadRemotePage( + metadata: metadata, + previewURL: previewURL, + for: ocId, + index: index + ) + } - if previewURL == nil { - previewURL = await loader.previewURL( + private func loadLocalPage( + metadata: tableMetadata, + previewURL: URL?, + localURL: URL, + for ocId: String, + index: Int + ) async { + switch metadata.classFile { + case NKTypeClassFile.video.rawValue: + setState( + .video(localURL: localURL), + for: ocId + ) + + case NKTypeClassFile.audio.rawValue: + await setReadyState( + metadata: metadata, + previewURL: previewURL, + localURL: localURL, + for: ocId, + index: index + ) + + await loadAudioPreviewIfNeeded( + metadata: metadata, + localURL: localURL, + currentPreviewURL: previewURL, + for: ocId, + index: index + ) + + case NKTypeClassFile.image.rawValue: + var imagePreviewURL = previewURL + + if imagePreviewURL == nil { + imagePreviewURL = await loader.previewURL( for: metadata, index: index ) @@ -411,6 +448,15 @@ final class NCMediaViewerModel: ObservableObject { } } + await setReadyState( + metadata: metadata, + previewURL: imagePreviewURL, + localURL: localURL, + for: ocId, + index: index + ) + + default: await setReadyState( metadata: metadata, previewURL: previewURL, @@ -418,57 +464,64 @@ final class NCMediaViewerModel: ObservableObject { for: ocId, index: index ) - return } + } - guard !Task.isCancelled else { - return - } + private func loadRemotePage( + metadata: tableMetadata, + previewURL: URL?, + for ocId: String, + index: Int + ) async { + var previewURL = previewURL if metadata.classFile == NKTypeClassFile.image.rawValue, previewURL == nil { - previewURL = await loader.previewURL(for: metadata, index: index) + previewURL = await loader.previewURL( + for: metadata, + index: index + ) } guard !Task.isCancelled else { return } - if metadata.classFile == NKTypeClassFile.image.rawValue, let previewURL { + switch metadata.classFile { + case NKTypeClassFile.video.rawValue: setState( - .image( - previewURL: previewURL, - localURL: nil, - livePhotoURL: nil, - progress: nil - ), + .video(localURL: nil), for: ocId ) - } - - if metadata.classFile == NKTypeClassFile.video.rawValue { - setState( - .video, - for: ocId - ) - return - } - - guard !Task.isCancelled else { return - } - do { - if metadata.classFile == NKTypeClassFile.audio.rawValue { + case NKTypeClassFile.image.rawValue: + if let previewURL { setState( - .downloading( + .image( previewURL: previewURL, + localURL: nil, + livePhotoURL: nil, progress: nil ), for: ocId ) } + case NKTypeClassFile.audio.rawValue: + setState( + .downloading( + previewURL: previewURL, + progress: nil + ), + for: ocId + ) + + default: + break + } + + do { let downloadedURL = try await loader.downloadMedia( for: metadata, index: index @@ -617,10 +670,7 @@ final class NCMediaViewerModel: ObservableObject { if metadata.classFile == NKTypeClassFile.video.rawValue { setState( - .downloading( - previewURL: previewURL, - progress: nil - ), + .video(localURL: nil), for: ocId ) return @@ -672,7 +722,7 @@ final class NCMediaViewerModel: ObservableObject { case .idle, .loadingMetadata, .metadataMissing, - .video, + .video(_), .deleted, .checkingLocalFile: return nil @@ -719,6 +769,11 @@ final class NCMediaViewerModel: ObservableObject { ), for: ocId ) + } else if metadata.classFile == NKTypeClassFile.video.rawValue { + setState( + .video(localURL: localURL), + for: ocId + ) } else if metadata.classFile == NKTypeClassFile.audio.rawValue { setState( .audio( @@ -820,7 +875,7 @@ private extension NCMediaViewerPageState { .checkingLocalFile, .image, .audio, - .video, + .video(_), .downloading, .ready, .deleted, @@ -842,7 +897,7 @@ private extension NCMediaViewerPageState { case .image(_, .some, _, _), .audio, - .video, + .video(_), .loadingMetadata, .metadataMissing, .checkingLocalFile, diff --git a/iOSClient/Viewer/NCViewerMedia/NCMediaViewerPresenter.swift b/iOSClient/Viewer/NCViewerMedia/NCMediaViewerPresenter.swift index beb2621bf6..a3293a5c42 100644 --- a/iOSClient/Viewer/NCViewerMedia/NCMediaViewerPresenter.swift +++ b/iOSClient/Viewer/NCViewerMedia/NCMediaViewerPresenter.swift @@ -378,7 +378,7 @@ final class NCMediaViewerPresenter: NSObject { case .audio(_, let previewURL): return imageFromURL(previewURL) - case .video: + case .video(_): return nil case .ready(let localURL, let previewURL): diff --git a/iOSClient/Viewer/NCViewerMedia/Views/NCMediaViewerPageView.swift b/iOSClient/Viewer/NCViewerMedia/Views/NCMediaViewerPageView.swift index aeface0288..122aeebdac 100644 --- a/iOSClient/Viewer/NCViewerMedia/Views/NCMediaViewerPageView.swift +++ b/iOSClient/Viewer/NCViewerMedia/Views/NCMediaViewerPageView.swift @@ -51,8 +51,8 @@ struct NCMediaViewerPageView: View { livePhotoURL: livePhotoURL ) - case .video: - videoStateView() + case .video(let localURL): + videoStateView(localURL: localURL) case .audio(let localURL, let previewURL): audioStateView( @@ -202,11 +202,11 @@ struct NCMediaViewerPageView: View { } @ViewBuilder - private func videoStateView() -> some View { + private func videoStateView(localURL: URL?) -> some View { if let metadata = page.metadata { NCVideoViewerContentView( metadata: metadata, - localURL: nil, + localURL: localURL, isSelected: isSelected, contextMenuController: contextMenuController, navigationBar: navigationBar, @@ -254,7 +254,7 @@ struct NCMediaViewerPageView: View { switch page.metadata?.classFile { case NKTypeClassFile.video.rawValue: if isSelected { - videoStateView() + videoStateView(localURL: nil) } else { Color.ncViewerBackground(backgroundStyle) .ignoresSafeArea() From 4ad63413ad931ed5d4fdb91da7117f4c9fb0ff77 Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Wed, 27 May 2026 08:53:54 +0200 Subject: [PATCH 25/54] Preserve local URL during video prefetch Signed-off-by: Marino Faggiana --- .../Model - View/NCMediaViewerModel.swift | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/iOSClient/Viewer/NCViewerMedia/Model - View/NCMediaViewerModel.swift b/iOSClient/Viewer/NCViewerMedia/Model - View/NCMediaViewerModel.swift index 99ff8f6b4f..69f78b22a4 100644 --- a/iOSClient/Viewer/NCViewerMedia/Model - View/NCMediaViewerModel.swift +++ b/iOSClient/Viewer/NCViewerMedia/Model - View/NCMediaViewerModel.swift @@ -669,8 +669,17 @@ final class NCMediaViewerModel: ObservableObject { } if metadata.classFile == NKTypeClassFile.video.rawValue { + let localURL = await loader.localMediaURL( + for: metadata, + index: index + ) + + guard !Task.isCancelled else { + return + } + setState( - .video(localURL: nil), + .video(localURL: localURL), for: ocId ) return From 055dcdc9a7b87ed20643a6c5d2e623f22cf28b4e Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Wed, 27 May 2026 08:57:34 +0200 Subject: [PATCH 26/54] Hide video resolver errors from logs and UI Signed-off-by: Marino Faggiana --- .../Content/Video/NCVideoViewerContentView.swift | 15 +-------------- 1 file changed, 1 insertion(+), 14 deletions(-) diff --git a/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoViewerContentView.swift b/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoViewerContentView.swift index 1d530563f3..9e413e9db4 100644 --- a/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoViewerContentView.swift +++ b/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoViewerContentView.swift @@ -159,12 +159,6 @@ struct NCVideoViewerContentView: View { Text(NSLocalizedString("_video_not_available_", comment: "")) .font(.headline) - - Text(message) - .font(.caption) - .foregroundStyle(.white.opacity(0.6)) - .multilineTextAlignment(.center) - .padding(.horizontal, 24) } .foregroundStyle(.white) .padding(24) @@ -285,14 +279,7 @@ struct NCVideoViewerContentView: View { guard result.error == .success, let url = result.url else { - nkLog( - tag: NCGlobal.shared.logTagViewer, - emoji: .error, - message: "VIDEO resolve failed ocId \(metadata.ocId), error \(result.error.errorDescription)", - consoleOnly: true - ) - - errorMessage = result.error.errorDescription + errorMessage = "" return } From 86db0ebbdc1adcf6e33ddf17e1aed0bc02bf8bc8 Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Wed, 27 May 2026 08:59:01 +0200 Subject: [PATCH 27/54] Clean video playback VLC fallback Signed-off-by: Marino Faggiana --- .../Content/Video/NCVideoPlaybackController.swift | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoPlaybackController.swift b/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoPlaybackController.swift index d3902c6fa7..79223091eb 100644 --- a/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoPlaybackController.swift +++ b/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoPlaybackController.swift @@ -89,7 +89,7 @@ final class NCVideoPlaybackController: ObservableObject { if url.isFileURL, !isValidLocalFile(url: url) { - engine = .failed(message: "Video file is not available.") + engine = .failed(message: "") return } @@ -101,7 +101,6 @@ final class NCVideoPlaybackController: ObservableObject { ) { resolveWithVLC( url: url, - reason: "direct legacy format \(resolvedVideoExtension(url: url, fileName: fileName))", token: token ) return @@ -198,7 +197,6 @@ final class NCVideoPlaybackController: ObservableObject { case .failed: self.resolveWithVLC( url: url, - reason: item.error?.localizedDescription ?? "AVFoundation failed.", token: token ) @@ -208,7 +206,6 @@ final class NCVideoPlaybackController: ObservableObject { @unknown default: self.resolveWithVLC( url: url, - reason: "AVFoundation returned an unknown status.", token: token ) } @@ -234,7 +231,6 @@ final class NCVideoPlaybackController: ObservableObject { private func resolveWithVLC( url: URL, - reason: String, token: UUID ) { guard isCurrentLoad( From fc8d5db651dec46a0460c96d12adbe61b5e82a3e Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Wed, 27 May 2026 09:09:47 +0200 Subject: [PATCH 28/54] source improvements Signed-off-by: Marino Faggiana --- Nextcloud.xcodeproj/project.pbxproj | 22 ++--- .../Image}/NCImageZoomView.swift | 0 .../NCVideoAVPlayerViewController.swift | 2 +- .../Video/VLC/NCVideoVLCViewController.swift | 2 +- .../NCMediaViewerModel.swift | 6 +- .../NCMediaViewerView.swift | 0 .../NCMediaViewerHostingController.swift | 2 +- .../NCMediaViewerPresenter.swift | 82 ++++++++++++++++++- ...t => NCMediaViewerFloatingTitleView.swift} | 2 +- 9 files changed, 99 insertions(+), 19 deletions(-) rename iOSClient/Viewer/NCViewerMedia/{Views => Content/Image}/NCImageZoomView.swift (100%) rename iOSClient/Viewer/NCViewerMedia/{Model - View => Core}/NCMediaViewerModel.swift (99%) rename iOSClient/Viewer/NCViewerMedia/{Model - View => Core}/NCMediaViewerView.swift (100%) rename iOSClient/Viewer/NCViewerMedia/Views/{NCViewerFloatingTitleView.swift => NCMediaViewerFloatingTitleView.swift} (99%) diff --git a/Nextcloud.xcodeproj/project.pbxproj b/Nextcloud.xcodeproj/project.pbxproj index 23c4fe746c..6dfbe208de 100644 --- a/Nextcloud.xcodeproj/project.pbxproj +++ b/Nextcloud.xcodeproj/project.pbxproj @@ -648,7 +648,7 @@ F78F74342163757000C2ADAD /* NCTrash.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = F78F74332163757000C2ADAD /* NCTrash.storyboard */; }; F78F74362163781100C2ADAD /* NCTrash.swift in Sources */ = {isa = PBXBuildFile; fileRef = F78F74352163781100C2ADAD /* NCTrash.swift */; }; F790110E21415BF600D7B136 /* NCViewerRichDocument.swift in Sources */ = {isa = PBXBuildFile; fileRef = F790110D21415BF600D7B136 /* NCViewerRichDocument.swift */; }; - F79377052FBD86AF00DE56DE /* NCViewerFloatingTitleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F79377042FBD86AE00DE56DE /* NCViewerFloatingTitleView.swift */; }; + F79377052FBD86AF00DE56DE /* NCMediaViewerFloatingTitleView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F79377042FBD86AE00DE56DE /* NCMediaViewerFloatingTitleView.swift */; }; F793E59D28B761E7005E4B02 /* NCNetworking.swift in Sources */ = {isa = PBXBuildFile; fileRef = F75A9EE523796C6F0044CFCE /* NCNetworking.swift */; }; F7948DE72FBAE53000253D1C /* NCVideoAVPlayerPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7948DE62FBAE52F00253D1C /* NCVideoAVPlayerPresenter.swift */; }; F7948DE92FBAEC5400253D1C /* NCVideoAVPlayerViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7948DE82FBAEC5300253D1C /* NCVideoAVPlayerViewController.swift */; }; @@ -1622,7 +1622,7 @@ F790110D21415BF600D7B136 /* NCViewerRichDocument.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCViewerRichDocument.swift; sourceTree = ""; }; F79131C628AFB86E00577277 /* eu */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = eu; path = eu.lproj/Localizable.strings; sourceTree = ""; }; F79131C728AFB86E00577277 /* eu */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = eu; path = eu.lproj/InfoPlist.strings; sourceTree = ""; }; - F79377042FBD86AE00DE56DE /* NCViewerFloatingTitleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCViewerFloatingTitleView.swift; sourceTree = ""; }; + F79377042FBD86AE00DE56DE /* NCMediaViewerFloatingTitleView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCMediaViewerFloatingTitleView.swift; sourceTree = ""; }; F7948DE62FBAE52F00253D1C /* NCVideoAVPlayerPresenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCVideoAVPlayerPresenter.swift; sourceTree = ""; }; F7948DE82FBAEC5300253D1C /* NCVideoAVPlayerViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCVideoAVPlayerViewController.swift; sourceTree = ""; }; F794E13C2BBBFF2E003693D7 /* NCMainTabBarController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCMainTabBarController.swift; sourceTree = ""; }; @@ -2389,9 +2389,9 @@ F716DA682FA5F137006A6703 /* Content */ = { isa = PBXGroup; children = ( - F78448AE2FB1BE9000F2909A /* Video */, - F74E3EE52FB07F3000252FA0 /* Audio */, F74E3EE42FB07F2500252FA0 /* Image */, + F74E3EE52FB07F3000252FA0 /* Audio */, + F78448AE2FB1BE9000F2909A /* Video */, ); path = Content; sourceTree = ""; @@ -2519,13 +2519,13 @@ path = NCViewerDirectEditing; sourceTree = ""; }; - F749ED342FAF0EE200CE8DFA /* Model - View */ = { + F749ED342FAF0EE200CE8DFA /* Core */ = { isa = PBXGroup; children = ( F7CDB5BB2FA33CA300F72306 /* NCMediaViewerView.swift */, F7CDB5B82FA33CA300F72306 /* NCMediaViewerModel.swift */, ); - path = "Model - View"; + path = Core; sourceTree = ""; }; F74D3DB81BAC1941000BAE4B /* Networking */ = { @@ -2556,6 +2556,7 @@ isa = PBXGroup; children = ( F7CDB5B62FA33CA300F72306 /* NCImageViewerContentView.swift */, + F716DA642FA4E878006A6703 /* NCImageZoomView.swift */, F7EDBB4A2FA89F6500098C42 /* NCLivePhotoViewerContentView.swift */, ); path = Image; @@ -2839,8 +2840,8 @@ F78448AE2FB1BE9000F2909A /* Video */ = { isa = PBXGroup; children = ( - F78448A32FB1BE9000F2909A /* NCVideoPlaybackController.swift */, F78448A82FB1BE9000F2909A /* NCVideoViewerContentView.swift */, + F78448A32FB1BE9000F2909A /* NCVideoPlaybackController.swift */, F7547FE52FB76C1800E372C3 /* NCVideoControlsView.swift */, F78448BF2FB1C78900F2909A /* AVPlayer */, F78448C02FB1C79A00F2909A /* VLC */, @@ -3186,7 +3187,7 @@ children = ( F7EDBB5B2FA8DBE600098C42 /* NCMediaViewerPresenter.swift */, F7EDBB512FA8CACA00098C42 /* NCMediaViewerHostingController.swift */, - F749ED342FAF0EE200CE8DFA /* Model - View */, + F749ED342FAF0EE200CE8DFA /* Core */, F7CDB5CE2FA33DED00F72306 /* Loading */, F7CDB5D02FA33E3500F72306 /* Views */, F716DA682FA5F137006A6703 /* Content */, @@ -3208,8 +3209,7 @@ children = ( F716DA662FA5F019006A6703 /* NCMediaViewerPagingView.swift */, F7CDB5B92FA33CA300F72306 /* NCMediaViewerPageView.swift */, - F716DA642FA4E878006A6703 /* NCImageZoomView.swift */, - F79377042FBD86AE00DE56DE /* NCViewerFloatingTitleView.swift */, + F79377042FBD86AE00DE56DE /* NCMediaViewerFloatingTitleView.swift */, F749ED302FADD62400CE8DFA /* NCMediaViewerDetailView.swift */, ); path = Views; @@ -4732,7 +4732,7 @@ F7CDB5C62FA33CA300F72306 /* NCMediaViewerView.swift in Sources */, F7CDB5CC2FA33CA300F72306 /* NCNextcloudMediaViewerLoader.swift in Sources */, F76341182EBE0BC60056F538 /* NCNetworking+NextcloudKitDelegate.swift in Sources */, - F79377052FBD86AF00DE56DE /* NCViewerFloatingTitleView.swift in Sources */, + F79377052FBD86AF00DE56DE /* NCMediaViewerFloatingTitleView.swift in Sources */, F78A18B823CDE2B300F681F3 /* NCViewerRichWorkspace.swift in Sources */, F7948DE72FBAE53000253D1C /* NCVideoAVPlayerPresenter.swift in Sources */, F34E1AD92ECC839100FA10C3 /* EmojiTextField.swift in Sources */, diff --git a/iOSClient/Viewer/NCViewerMedia/Views/NCImageZoomView.swift b/iOSClient/Viewer/NCViewerMedia/Content/Image/NCImageZoomView.swift similarity index 100% rename from iOSClient/Viewer/NCViewerMedia/Views/NCImageZoomView.swift rename to iOSClient/Viewer/NCViewerMedia/Content/Image/NCImageZoomView.swift diff --git a/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoAVPlayerViewController.swift b/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoAVPlayerViewController.swift index 518b1495dd..d41a75aef2 100644 --- a/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoAVPlayerViewController.swift +++ b/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoAVPlayerViewController.swift @@ -53,7 +53,7 @@ final class NCVideoAVPlayerViewController: UIViewController { internal let playerContainerView = NCVideoAVPlayerLayerView() internal let controlsView = NCVideoControlsView() - private let floatingTitleView = NCViewerFloatingTitleView() + private let floatingTitleView = NCMediaViewerFloatingTitleView() private lazy var floatingTitleDateFormatter: DateFormatter = { let formatter = DateFormatter() diff --git a/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCViewController.swift b/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCViewController.swift index d25cced7cb..ab90e8360e 100644 --- a/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCViewController.swift +++ b/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCViewController.swift @@ -33,7 +33,7 @@ final class NCVideoVLCViewController: UIViewController { internal let drawableView = UIView() internal let controlsView = NCVideoControlsView() - private let floatingTitleView = NCViewerFloatingTitleView() + private let floatingTitleView = NCMediaViewerFloatingTitleView() private lazy var floatingTitleDateFormatter: DateFormatter = { let formatter = DateFormatter() diff --git a/iOSClient/Viewer/NCViewerMedia/Model - View/NCMediaViewerModel.swift b/iOSClient/Viewer/NCViewerMedia/Core/NCMediaViewerModel.swift similarity index 99% rename from iOSClient/Viewer/NCViewerMedia/Model - View/NCMediaViewerModel.swift rename to iOSClient/Viewer/NCViewerMedia/Core/NCMediaViewerModel.swift index 69f78b22a4..258cf4352c 100644 --- a/iOSClient/Viewer/NCViewerMedia/Model - View/NCMediaViewerModel.swift +++ b/iOSClient/Viewer/NCViewerMedia/Core/NCMediaViewerModel.swift @@ -731,7 +731,7 @@ final class NCMediaViewerModel: ObservableObject { case .idle, .loadingMetadata, .metadataMissing, - .video(_), + .video, .deleted, .checkingLocalFile: return nil @@ -884,7 +884,7 @@ private extension NCMediaViewerPageState { .checkingLocalFile, .image, .audio, - .video(_), + .video, .downloading, .ready, .deleted, @@ -906,7 +906,7 @@ private extension NCMediaViewerPageState { case .image(_, .some, _, _), .audio, - .video(_), + .video, .loadingMetadata, .metadataMissing, .checkingLocalFile, diff --git a/iOSClient/Viewer/NCViewerMedia/Model - View/NCMediaViewerView.swift b/iOSClient/Viewer/NCViewerMedia/Core/NCMediaViewerView.swift similarity index 100% rename from iOSClient/Viewer/NCViewerMedia/Model - View/NCMediaViewerView.swift rename to iOSClient/Viewer/NCViewerMedia/Core/NCMediaViewerView.swift diff --git a/iOSClient/Viewer/NCViewerMedia/NCMediaViewerHostingController.swift b/iOSClient/Viewer/NCViewerMedia/NCMediaViewerHostingController.swift index 92bdf59c0a..0d27c16ccb 100644 --- a/iOSClient/Viewer/NCViewerMedia/NCMediaViewerHostingController.swift +++ b/iOSClient/Viewer/NCViewerMedia/NCMediaViewerHostingController.swift @@ -21,7 +21,7 @@ final class NCMediaViewerHostingController: UIHostingController() private var transferDelegate: NCMediaViewerTransferDelegate? private weak var currentNavigationBar: UINavigationBar? - private let floatingTitleView = NCViewerFloatingTitleView() + private let floatingTitleView = NCMediaViewerFloatingTitleView() private lazy var floatingTitleDateFormatter: DateFormatter = { let formatter = DateFormatter() diff --git a/iOSClient/Viewer/NCViewerMedia/NCMediaViewerPresenter.swift b/iOSClient/Viewer/NCViewerMedia/NCMediaViewerPresenter.swift index a3293a5c42..037fb7b1cd 100644 --- a/iOSClient/Viewer/NCViewerMedia/NCMediaViewerPresenter.swift +++ b/iOSClient/Viewer/NCViewerMedia/NCMediaViewerPresenter.swift @@ -8,6 +8,86 @@ import UIKit // MARK: - Media Viewer Presenter +/// Media viewer flow legend. +/// +/// This file is the UIKit entry point for the media viewer flow. +/// +/// Source order and responsibilities: +/// +/// 1. `NCMediaViewerPresenter` +/// UIKit entry point. Creates the initial model, builds the hosting controller, +/// presents the SwiftUI viewer, and manages opening/closing transitions. +/// +/// 2. `NCMediaViewerHostingController` +/// UIKit container for the SwiftUI viewer. Owns the navigation bar, toolbar +/// actions, detail presentation, and close/info buttons. +/// +/// 3. `NCMediaViewerView` +/// SwiftUI root view. Hosts the paging view and observes the viewer model. +/// +/// 4. `NCMediaViewerModel` +/// Central state coordinator. Owns the selected index, visible page window, +/// page states, metadata cache, prefetching, and routes media into image, +/// audio, video, or generic states. +/// +/// 5. `NCNextcloudMediaViewerLoader` +/// Loader layer. Resolves metadata, preview URLs, local media URLs, full media +/// downloads, and Live Photo companion media. +/// +/// 6. `NCMediaViewerPagingView` +/// UIKit-backed horizontal pager hosted from SwiftUI. Owns the collection view, +/// paging coordinator, visible cells, selected index updates, and page navigation. +/// +/// 7. `NCMediaViewerPageView` +/// Per-page SwiftUI renderer. Switches on `NCMediaViewerPageState` and routes +/// each page to the correct content view. +/// +/// 8. Image flow: +/// `NCMediaViewerPageView` +/// -> `NCImageViewerContentView` +/// -> `NCImageZoomView` +/// -> `NCLivePhotoViewerContentView` when Live Photo data is available. +/// +/// 9. Audio flow: +/// `NCMediaViewerPageView` +/// -> `NCAudioViewerContentView`. +/// Audio playback stays inside SwiftUI and uses a local media URL plus an +/// optional preview image as artwork. +/// +/// 10. Video flow: +/// `NCMediaViewerPageView` +/// -> `NCVideoViewerContentView` +/// -> `NCVideoPlaybackController`. +/// The video content view is only the SwiftUI trigger/bridge for fullscreen +/// playback. It resolves the playback URL and asks the playback controller to +/// choose the engine. +/// +/// 11. `NCVideoPlaybackController` +/// Chooses the playback engine. It tries AVFoundation when possible and falls +/// back to VLC for unsupported or legacy formats. +/// +/// 12. AVPlayer flow: +/// `NCVideoPlaybackController` +/// -> `NCVideoAVPlayerPresenter` +/// -> `NCVideoAVPlayerViewController` +/// -> `NCVideoControlsView` / `NCVideoAVPlayerViewControls`. +/// +/// 13. VLC flow: +/// `NCVideoPlaybackController` +/// -> `NCVideoVLCPresenter` +/// -> `NCVideoVLCViewController` +/// -> `NCVideoControlsView` / `NCVideoVLCViewControls`. +/// +/// 14. Detail flow: +/// `NCMediaViewerHostingController` +/// -> `NCMediaViewerDetailView`. +/// Displays file information, camera/lens metadata, EXIF values, and location. +/// +/// High-level rule: +/// `NCMediaViewerPresenter` starts and closes the viewer, but it does not resolve, +/// download, classify, or play media. Those responsibilities belong to the model, +/// loader, page view, and dedicated media content flows. + /// Presents the media viewer as a fullscreen overlay with optional thumbnail transitions. @MainActor final class NCMediaViewerPresenter: NSObject { @@ -378,7 +458,7 @@ final class NCMediaViewerPresenter: NSObject { case .audio(_, let previewURL): return imageFromURL(previewURL) - case .video(_): + case .video: return nil case .ready(let localURL, let previewURL): diff --git a/iOSClient/Viewer/NCViewerMedia/Views/NCViewerFloatingTitleView.swift b/iOSClient/Viewer/NCViewerMedia/Views/NCMediaViewerFloatingTitleView.swift similarity index 99% rename from iOSClient/Viewer/NCViewerMedia/Views/NCViewerFloatingTitleView.swift rename to iOSClient/Viewer/NCViewerMedia/Views/NCMediaViewerFloatingTitleView.swift index c3cef44e4d..3e2d727adf 100644 --- a/iOSClient/Viewer/NCViewerMedia/Views/NCViewerFloatingTitleView.swift +++ b/iOSClient/Viewer/NCViewerMedia/Views/NCMediaViewerFloatingTitleView.swift @@ -4,7 +4,7 @@ import UIKit -final class NCViewerFloatingTitleView: UIView { +final class NCMediaViewerFloatingTitleView: UIView { private let primaryLabel = UILabel() private let secondaryLabel = UILabel() private let stackView = UIStackView() From b9cbaf76eba4a8cd92cc384c8535e40e6627d282 Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Wed, 27 May 2026 09:10:48 +0200 Subject: [PATCH 29/54] cleaning Signed-off-by: Marino Faggiana --- iOSClient/Viewer/NCViewerMedia/NCMediaViewerPresenter.swift | 3 --- 1 file changed, 3 deletions(-) diff --git a/iOSClient/Viewer/NCViewerMedia/NCMediaViewerPresenter.swift b/iOSClient/Viewer/NCViewerMedia/NCMediaViewerPresenter.swift index 037fb7b1cd..34e4703b20 100644 --- a/iOSClient/Viewer/NCViewerMedia/NCMediaViewerPresenter.swift +++ b/iOSClient/Viewer/NCViewerMedia/NCMediaViewerPresenter.swift @@ -6,8 +6,6 @@ import SwiftUI import NextcloudKit import UIKit -// MARK: - Media Viewer Presenter - /// Media viewer flow legend. /// /// This file is the UIKit entry point for the media viewer flow. @@ -88,7 +86,6 @@ import UIKit /// download, classify, or play media. Those responsibilities belong to the model, /// loader, page view, and dedicated media content flows. -/// Presents the media viewer as a fullscreen overlay with optional thumbnail transitions. @MainActor final class NCMediaViewerPresenter: NSObject { static let shared = NCMediaViewerPresenter() From 0a26743c893110407b499e7034a1dbdbae672561 Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Wed, 27 May 2026 09:21:11 +0200 Subject: [PATCH 30/54] rename class Signed-off-by: Marino Faggiana --- Nextcloud.xcodeproj/project.pbxproj | 20 +++++++++---------- .../Collection Common/Cell/NCCellMain.swift | 6 +++--- .../Cell/NCRecommendationsCell.swift | 4 ++-- ...ionViewCommon+CollectionViewDelegate.swift | 4 ++-- ...tionViewCommon+TransitionSourceBlink.swift | 6 +++--- .../NCCollectionViewCommon.swift | 2 +- .../NCSectionFirstHeader.swift | 2 +- .../NCMedia+CollectionViewDelegate.swift | 10 +++++----- iOSClient/Select/NCSelect.swift | 2 +- iOSClient/Viewer/NCViewer.swift | 2 +- ...ce.swift => NCMediaViewerAppearance.swift} | 0 ...ft => NCMediaViewerTransitionSource.swift} | 2 +- .../Helpers/Notification+Extension.swift | 4 ++++ .../NCMediaViewerPresenter.swift | 14 ++++++------- 14 files changed, 41 insertions(+), 37 deletions(-) rename iOSClient/Viewer/NCViewerMedia/Helpers/{NCViewerAppearance.swift => NCMediaViewerAppearance.swift} (100%) rename iOSClient/Viewer/NCViewerMedia/Helpers/{NCViewerTransitionSource.swift => NCMediaViewerTransitionSource.swift} (92%) diff --git a/Nextcloud.xcodeproj/project.pbxproj b/Nextcloud.xcodeproj/project.pbxproj index 6dfbe208de..cfcfc4a7e1 100644 --- a/Nextcloud.xcodeproj/project.pbxproj +++ b/Nextcloud.xcodeproj/project.pbxproj @@ -910,9 +910,9 @@ F7ED547C25EEA65400956C55 /* QRCodeReader in Frameworks */ = {isa = PBXBuildFile; productRef = F7ED547B25EEA65400956C55 /* QRCodeReader */; }; F7EDBB4B2FA89F6800098C42 /* NCLivePhotoViewerContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7EDBB4A2FA89F6500098C42 /* NCLivePhotoViewerContentView.swift */; }; F7EDBB522FA8CACD00098C42 /* NCMediaViewerHostingController.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7EDBB512FA8CACA00098C42 /* NCMediaViewerHostingController.swift */; }; - F7EDBB552FA8CEBE00098C42 /* NCViewerTransitionSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7EDBB542FA8CEBE00098C42 /* NCViewerTransitionSource.swift */; }; - F7EDBB562FA8CEC900098C42 /* NCViewerTransitionSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7EDBB542FA8CEBE00098C42 /* NCViewerTransitionSource.swift */; }; - F7EDBB582FA8D00200098C42 /* NCViewerAppearance.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7EDBB572FA8CFFF00098C42 /* NCViewerAppearance.swift */; }; + F7EDBB552FA8CEBE00098C42 /* NCMediaViewerTransitionSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7EDBB542FA8CEBE00098C42 /* NCMediaViewerTransitionSource.swift */; }; + F7EDBB562FA8CEC900098C42 /* NCMediaViewerTransitionSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7EDBB542FA8CEBE00098C42 /* NCMediaViewerTransitionSource.swift */; }; + F7EDBB582FA8D00200098C42 /* NCMediaViewerAppearance.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7EDBB572FA8CFFF00098C42 /* NCMediaViewerAppearance.swift */; }; F7EDBB5C2FA8DBE800098C42 /* NCMediaViewerPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7EDBB5B2FA8DBE600098C42 /* NCMediaViewerPresenter.swift */; }; F7EDE4D6262D7B9600414FE6 /* NCListCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = F78ACD4121903CE00088454D /* NCListCell.swift */; }; F7EDE4DB262D7BA200414FE6 /* NCCellMain.swift in Sources */ = {isa = PBXBuildFile; fileRef = 370D26AE248A3D7A00121797 /* NCCellMain.swift */; }; @@ -1859,8 +1859,8 @@ F7E98C1527E0D0FC001F9F19 /* NCManageDatabase+Video.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NCManageDatabase+Video.swift"; sourceTree = ""; }; F7EDBB4A2FA89F6500098C42 /* NCLivePhotoViewerContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCLivePhotoViewerContentView.swift; sourceTree = ""; }; F7EDBB512FA8CACA00098C42 /* NCMediaViewerHostingController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCMediaViewerHostingController.swift; sourceTree = ""; }; - F7EDBB542FA8CEBE00098C42 /* NCViewerTransitionSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCViewerTransitionSource.swift; sourceTree = ""; }; - F7EDBB572FA8CFFF00098C42 /* NCViewerAppearance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCViewerAppearance.swift; sourceTree = ""; }; + F7EDBB542FA8CEBE00098C42 /* NCMediaViewerTransitionSource.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCMediaViewerTransitionSource.swift; sourceTree = ""; }; + F7EDBB572FA8CFFF00098C42 /* NCMediaViewerAppearance.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCMediaViewerAppearance.swift; sourceTree = ""; }; F7EDBB5B2FA8DBE600098C42 /* NCMediaViewerPresenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCMediaViewerPresenter.swift; sourceTree = ""; }; F7EDE508262DA9D600414FE6 /* NCSelectCommandViewSelect.xib */ = {isa = PBXFileReference; lastKnownFileType = file.xib; path = NCSelectCommandViewSelect.xib; sourceTree = ""; }; F7EDE513262DC2CD00414FE6 /* NCSelectCommandViewSelect+CreateFolder.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; path = "NCSelectCommandViewSelect+CreateFolder.xib"; sourceTree = ""; }; @@ -3354,8 +3354,8 @@ F7EDBB592FA8D09E00098C42 /* Helpers */ = { isa = PBXGroup; children = ( - F7EDBB572FA8CFFF00098C42 /* NCViewerAppearance.swift */, - F7EDBB542FA8CEBE00098C42 /* NCViewerTransitionSource.swift */, + F7EDBB572FA8CFFF00098C42 /* NCMediaViewerAppearance.swift */, + F7EDBB542FA8CEBE00098C42 /* NCMediaViewerTransitionSource.swift */, F74E3EEA2FB0AD7800252FA0 /* Notification+Extension.swift */, ); path = Helpers; @@ -4446,7 +4446,7 @@ F76340F82EBDE9760056F538 /* NCManageDatabaseCore.swift in Sources */, F79ED0F12D2FCA5B00A389D9 /* NCSectionFirstHeader.swift in Sources */, F79B646126CA661600838ACA /* UIControl+Extension.swift in Sources */, - F7EDBB562FA8CEC900098C42 /* NCViewerTransitionSource.swift in Sources */, + F7EDBB562FA8CEC900098C42 /* NCMediaViewerTransitionSource.swift in Sources */, F77C973A2953143A00FDDD09 /* NCCameraRoll.swift in Sources */, F740BEF02A35C2AD00E9B6D5 /* UILabel+Extension.swift in Sources */, F7C30E01291BD2610017149B /* NCNetworkingE2EERename.swift in Sources */, @@ -4754,7 +4754,7 @@ F743C89E2E5B25A1000173A9 /* UIScene+Extension.swift in Sources */, F72944F52A8424F800246839 /* NCEndToEndMetadataV1.swift in Sources */, F710D2022405826100A6033D /* NCContextMenuViewer.swift in Sources */, - F7EDBB582FA8D00200098C42 /* NCViewerAppearance.swift in Sources */, + F7EDBB582FA8D00200098C42 /* NCMediaViewerAppearance.swift in Sources */, F74E3EEB2FB0AD8500252FA0 /* Notification+Extension.swift in Sources */, F765E9CD295C585800A09ED8 /* NCUploadScanDocument.swift in Sources */, F741C2242B6B9FD600E849BB /* NCMediaSelectTabBar.swift in Sources */, @@ -4778,7 +4778,7 @@ F77BB746289984CA0090FC19 /* UIViewController+Extension.swift in Sources */, F700510522DF6A89003A3356 /* NCShare.swift in Sources */, F72D1007210B6882009C96B7 /* NCPushNotificationEncryption.m in Sources */, - F7EDBB552FA8CEBE00098C42 /* NCViewerTransitionSource.swift in Sources */, + F7EDBB552FA8CEBE00098C42 /* NCMediaViewerTransitionSource.swift in Sources */, F71638942FA0F65A00A913B7 /* NCMoreModel.swift in Sources */, F76882362C0DD1E7001CF441 /* NCAcknowledgementsView.swift in Sources */, F785EE9D246196DF00B3F945 /* NCNetworkingE2EE.swift in Sources */, diff --git a/iOSClient/Main/Collection Common/Cell/NCCellMain.swift b/iOSClient/Main/Collection Common/Cell/NCCellMain.swift index 20826a546f..769e007dbc 100644 --- a/iOSClient/Main/Collection Common/Cell/NCCellMain.swift +++ b/iOSClient/Main/Collection Common/Cell/NCCellMain.swift @@ -15,7 +15,7 @@ protocol NCCellMainProtocol { var infoLbl: UILabel? { get set } func selected(_ status: Bool, isEditMode: Bool, color: UIColor) - func viewerTransitionSource() -> NCViewerTransitionSource? + func viewerTransitionSource() -> NCMediaViewerTransitionSource? } extension NCCellMainProtocol { @@ -40,7 +40,7 @@ extension NCCellMainProtocol { set {} } - func viewerTransitionSource() -> NCViewerTransitionSource? { + func viewerTransitionSource() -> NCMediaViewerTransitionSource? { guard let imageView = previewImg, let image = imageView.image, let window = imageView.window else { @@ -48,7 +48,7 @@ extension NCCellMainProtocol { } let sourceFrame = imageView.convert(imageView.bounds, to: window) - return NCViewerTransitionSource(image: image, sourceFrame: sourceFrame, cornerRadius: imageView.layer.cornerRadius) + return NCMediaViewerTransitionSource(image: image, sourceFrame: sourceFrame, cornerRadius: imageView.layer.cornerRadius) } } diff --git a/iOSClient/Main/Collection Common/Cell/NCRecommendationsCell.swift b/iOSClient/Main/Collection Common/Cell/NCRecommendationsCell.swift index dc15fba8aa..eff565f4ca 100644 --- a/iOSClient/Main/Collection Common/Cell/NCRecommendationsCell.swift +++ b/iOSClient/Main/Collection Common/Cell/NCRecommendationsCell.swift @@ -25,7 +25,7 @@ class NCRecommendationsCell: UICollectionViewCell, UIGestureRecognizerDelegate { } } - func viewerTransitionSource() -> NCViewerTransitionSource? { + func viewerTransitionSource() -> NCMediaViewerTransitionSource? { guard let imageView = image, let image = imageView.image, let window = imageView.window else { @@ -33,7 +33,7 @@ class NCRecommendationsCell: UICollectionViewCell, UIGestureRecognizerDelegate { } let sourceFrame = imageView.convert(imageView.bounds, to: window) - return NCViewerTransitionSource(image: image, sourceFrame: sourceFrame, cornerRadius: imageView.layer.cornerRadius) + return NCMediaViewerTransitionSource(image: image, sourceFrame: sourceFrame, cornerRadius: imageView.layer.cornerRadius) } override func awakeFromNib() { diff --git a/iOSClient/Main/Collection Common/NCCollectionViewCommon+CollectionViewDelegate.swift b/iOSClient/Main/Collection Common/NCCollectionViewCommon+CollectionViewDelegate.swift index 9febece15c..b0750ff381 100644 --- a/iOSClient/Main/Collection Common/NCCollectionViewCommon+CollectionViewDelegate.swift +++ b/iOSClient/Main/Collection Common/NCCollectionViewCommon+CollectionViewDelegate.swift @@ -10,7 +10,7 @@ import LucidBanner extension NCCollectionViewCommon: UICollectionViewDelegate { @MainActor - func didSelectMetadata(_ metadata: tableMetadata, withOcIds: Bool, viewerTransitionSource: NCViewerTransitionSource?) async { + func didSelectMetadata(_ metadata: tableMetadata, withOcIds: Bool, viewerTransitionSource: NCMediaViewerTransitionSource?) async { let capabilities = await NKCapabilities.shared.getCapabilities(for: session.account) if metadata.e2eEncrypted { @@ -141,7 +141,7 @@ extension NCCollectionViewCommon: UICollectionViewDelegate { guard let metadata = self.dataSource.getMetadata(indexPath: indexPath) else { return } - var viewerTransitionSource: NCViewerTransitionSource? + var viewerTransitionSource: NCMediaViewerTransitionSource? if self.isEditMode { if let index = self.fileSelect.firstIndex(of: metadata.ocId) { diff --git a/iOSClient/Main/Collection Common/NCCollectionViewCommon+TransitionSourceBlink.swift b/iOSClient/Main/Collection Common/NCCollectionViewCommon+TransitionSourceBlink.swift index eba1594c0c..be85c23e9f 100644 --- a/iOSClient/Main/Collection Common/NCCollectionViewCommon+TransitionSourceBlink.swift +++ b/iOSClient/Main/Collection Common/NCCollectionViewCommon+TransitionSourceBlink.swift @@ -15,7 +15,7 @@ extension NCCollectionViewCommon { /// /// - Parameter ocId: Nextcloud file identifier of the media item. /// - Returns: Transition source if the item can be resolved. - func viewerTransitionSource(for ocId: String) -> NCViewerTransitionSource? { + func viewerTransitionSource(for ocId: String) -> NCMediaViewerTransitionSource? { guard let indexPath = dataSource.getIndexPathMetadata(ocId: ocId), let window = collectionView.window else { return nil @@ -41,7 +41,7 @@ extension NCCollectionViewCommon { to: window ) - return NCViewerTransitionSource( + return NCMediaViewerTransitionSource( image: image, sourceFrame: sourceFrame, cornerRadius: imageView.layer.cornerRadius @@ -57,7 +57,7 @@ extension NCCollectionViewCommon { to: window ) - return NCViewerTransitionSource( + return NCMediaViewerTransitionSource( image: UIImage(), sourceFrame: sourceFrame, cornerRadius: 6 diff --git a/iOSClient/Main/Collection Common/NCCollectionViewCommon.swift b/iOSClient/Main/Collection Common/NCCollectionViewCommon.swift index 5368d0ca3b..a6665c2893 100644 --- a/iOSClient/Main/Collection Common/NCCollectionViewCommon.swift +++ b/iOSClient/Main/Collection Common/NCCollectionViewCommon.swift @@ -787,7 +787,7 @@ extension NCCollectionViewCommon: NCSectionFirstHeaderDelegate { } } - func tapRecommendations(with metadata: tableMetadata, viewerTransitionSource: NCViewerTransitionSource?) { + func tapRecommendations(with metadata: tableMetadata, viewerTransitionSource: NCMediaViewerTransitionSource?) { Task { await didSelectMetadata(metadata, withOcIds: false, viewerTransitionSource: viewerTransitionSource) } diff --git a/iOSClient/Main/Collection Common/Section Header Footer/NCSectionFirstHeader.swift b/iOSClient/Main/Collection Common/Section Header Footer/NCSectionFirstHeader.swift index 2c34123719..ea90e9cdef 100644 --- a/iOSClient/Main/Collection Common/Section Header Footer/NCSectionFirstHeader.swift +++ b/iOSClient/Main/Collection Common/Section Header Footer/NCSectionFirstHeader.swift @@ -8,7 +8,7 @@ import NextcloudKit protocol NCSectionFirstHeaderDelegate: AnyObject { func tapRichWorkspace(_ sender: Any) - func tapRecommendations(with metadata: tableMetadata, viewerTransitionSource: NCViewerTransitionSource?) + func tapRecommendations(with metadata: tableMetadata, viewerTransitionSource: NCMediaViewerTransitionSource?) } class NCSectionFirstHeader: UICollectionReusableView, UIGestureRecognizerDelegate { diff --git a/iOSClient/Media/NCMedia+CollectionViewDelegate.swift b/iOSClient/Media/NCMedia+CollectionViewDelegate.swift index f9be4e9ffe..66904485e3 100644 --- a/iOSClient/Media/NCMedia+CollectionViewDelegate.swift +++ b/iOSClient/Media/NCMedia+CollectionViewDelegate.swift @@ -23,14 +23,14 @@ extension NCMedia: UICollectionViewDelegate { tabBarSelect.selectCount = fileSelect.count } else if let metadata = await self.database.getMetadataFromOcIdAsync(metadata.ocId) { let image = utility.getImage(ocId: metadata.ocId, etag: metadata.etag, ext: global.previewExt1024, userId: metadata.userId, urlBase: metadata.urlBase) - var viewerTransitionSource: NCViewerTransitionSource? + var viewerTransitionSource: NCMediaViewerTransitionSource? let ocIds = dataSource.metadatas.map { $0.ocId } if let imageView = cell.imageItem, let image = imageView.image, let window = imageView.window { let sourceFrame = imageView.convert(imageView.bounds, to: window) - viewerTransitionSource = NCViewerTransitionSource(image: image, sourceFrame: sourceFrame, cornerRadius: imageView.layer.cornerRadius) + viewerTransitionSource = NCMediaViewerTransitionSource(image: image, sourceFrame: sourceFrame, cornerRadius: imageView.layer.cornerRadius) } if let vc = await NCViewer().getViewerController(metadata: metadata, ocIds: ocIds, image: image, delegate: self, viewerTransitionSource: viewerTransitionSource) { @@ -50,7 +50,7 @@ extension NCMedia: UICollectionViewDelegate { /// /// - Parameter ocId: Nextcloud file identifier of the media item. /// - Returns: Transition source if the item can be resolved. - func viewerTransitionSource(for ocId: String) -> NCViewerTransitionSource? { + func viewerTransitionSource(for ocId: String) -> NCMediaViewerTransitionSource? { guard let indexPath = self.dataSource.indexPath(forOcId: ocId), let window = collectionView.window else { return nil @@ -76,7 +76,7 @@ extension NCMedia: UICollectionViewDelegate { to: window ) - return NCViewerTransitionSource( + return NCMediaViewerTransitionSource( image: image, sourceFrame: sourceFrame, cornerRadius: imageView.layer.cornerRadius @@ -92,7 +92,7 @@ extension NCMedia: UICollectionViewDelegate { to: window ) - return NCViewerTransitionSource( + return NCMediaViewerTransitionSource( image: UIImage(), sourceFrame: sourceFrame, cornerRadius: 6 diff --git a/iOSClient/Select/NCSelect.swift b/iOSClient/Select/NCSelect.swift index 209ba3d4a5..14757fc7aa 100644 --- a/iOSClient/Select/NCSelect.swift +++ b/iOSClient/Select/NCSelect.swift @@ -292,7 +292,7 @@ class NCSelect: UIViewController, UIGestureRecognizerDelegate, UIAdaptivePresent } func tapRichWorkspace(_ sender: Any) { } - func tapRecommendations(with metadata: tableMetadata, viewerTransitionSource: NCViewerTransitionSource?) { } + func tapRecommendations(with metadata: tableMetadata, viewerTransitionSource: NCMediaViewerTransitionSource?) { } // MARK: - Push metadata diff --git a/iOSClient/Viewer/NCViewer.swift b/iOSClient/Viewer/NCViewer.swift index c503d50c55..8c6a46371a 100644 --- a/iOSClient/Viewer/NCViewer.swift +++ b/iOSClient/Viewer/NCViewer.swift @@ -14,7 +14,7 @@ class NCViewer: NSObject { private var viewerQuickLook: NCViewerQuickLook? @MainActor - func getViewerController(metadata: tableMetadata, ocIds: [String]? = nil, image: UIImage? = nil, delegate: UIViewController? = nil, viewerTransitionSource: NCViewerTransitionSource?) async -> UIViewController? { + func getViewerController(metadata: tableMetadata, ocIds: [String]? = nil, image: UIImage? = nil, delegate: UIViewController? = nil, viewerTransitionSource: NCMediaViewerTransitionSource?) async -> UIViewController? { let session = NCSession.shared.getSession(account: metadata.account) // Set Last Opening Date await self.database.setLocalFileLastOpeningDateAsync(metadata: metadata) diff --git a/iOSClient/Viewer/NCViewerMedia/Helpers/NCViewerAppearance.swift b/iOSClient/Viewer/NCViewerMedia/Helpers/NCMediaViewerAppearance.swift similarity index 100% rename from iOSClient/Viewer/NCViewerMedia/Helpers/NCViewerAppearance.swift rename to iOSClient/Viewer/NCViewerMedia/Helpers/NCMediaViewerAppearance.swift diff --git a/iOSClient/Viewer/NCViewerMedia/Helpers/NCViewerTransitionSource.swift b/iOSClient/Viewer/NCViewerMedia/Helpers/NCMediaViewerTransitionSource.swift similarity index 92% rename from iOSClient/Viewer/NCViewerMedia/Helpers/NCViewerTransitionSource.swift rename to iOSClient/Viewer/NCViewerMedia/Helpers/NCMediaViewerTransitionSource.swift index 4da0cf91cb..83a9dd0a82 100644 --- a/iOSClient/Viewer/NCViewerMedia/Helpers/NCViewerTransitionSource.swift +++ b/iOSClient/Viewer/NCViewerMedia/Helpers/NCMediaViewerTransitionSource.swift @@ -5,7 +5,7 @@ import UIKit // MARK: - Viewer Transition Source -struct NCViewerTransitionSource { +struct NCMediaViewerTransitionSource { let image: UIImage let sourceFrame: CGRect diff --git a/iOSClient/Viewer/NCViewerMedia/Helpers/Notification+Extension.swift b/iOSClient/Viewer/NCViewerMedia/Helpers/Notification+Extension.swift index f35b84eb12..08898babf0 100644 --- a/iOSClient/Viewer/NCViewerMedia/Helpers/Notification+Extension.swift +++ b/iOSClient/Viewer/NCViewerMedia/Helpers/Notification+Extension.swift @@ -5,5 +5,9 @@ import Foundation extension Notification.Name { + // Global media viewer playback stop notification. + // Use only for viewer-wide teardown or destructive state changes. + // Do not use it for normal video-to-video navigation because it dismisses + // all active audio/video playback controllers. static let ncMediaViewerStopPlayback = Notification.Name("ncMediaViewerStopPlayback") } diff --git a/iOSClient/Viewer/NCViewerMedia/NCMediaViewerPresenter.swift b/iOSClient/Viewer/NCViewerMedia/NCMediaViewerPresenter.swift index 34e4703b20..d27986add7 100644 --- a/iOSClient/Viewer/NCViewerMedia/NCMediaViewerPresenter.swift +++ b/iOSClient/Viewer/NCViewerMedia/NCMediaViewerPresenter.swift @@ -92,10 +92,10 @@ final class NCMediaViewerPresenter: NSObject { private var navigationController: UINavigationController? private weak var viewerContainerView: UIView? - private var currentViewerTransitionSource: NCViewerTransitionSource? + private var currentViewerTransitionSource: NCMediaViewerTransitionSource? private weak var currentModel: NCMediaViewerModel? - private var closingTransitionSourceProvider: ((_ ocId: String) -> NCViewerTransitionSource?)? + private var closingTransitionSourceProvider: ((_ ocId: String) -> NCMediaViewerTransitionSource?)? private var forcedClosingOcId: String? private let openingAnimationDuration: TimeInterval = 0.28 @@ -115,10 +115,10 @@ final class NCMediaViewerPresenter: NSObject { /// Shows the media viewer above the current window. func show( model: NCMediaViewerModel, - viewerTransitionSource: NCViewerTransitionSource?, + viewerTransitionSource: NCMediaViewerTransitionSource?, from sourceView: UIView? = nil, contextMenuController: NCMainTabBarController? = nil, - closingTransitionSourceProvider: ((_ ocId: String) -> NCViewerTransitionSource?)? = nil + closingTransitionSourceProvider: ((_ ocId: String) -> NCMediaViewerTransitionSource?)? = nil ) { guard let window = sourceView?.window ?? activeWindow() else { return @@ -348,7 +348,7 @@ final class NCMediaViewerPresenter: NSObject { /// Animates the source thumbnail into the fullscreen viewer. private func animateOpening( - viewerTransitionSource: NCViewerTransitionSource, + viewerTransitionSource: NCMediaViewerTransitionSource, in window: UIWindow, viewerView: UIView ) { @@ -395,7 +395,7 @@ final class NCMediaViewerPresenter: NSObject { /// Animates the fullscreen viewer back into the current thumbnail frame. private func animateClosing( - viewerTransitionSource: NCViewerTransitionSource, + viewerTransitionSource: NCMediaViewerTransitionSource, closingImage: UIImage, in window: UIWindow, viewerView: UIView @@ -432,7 +432,7 @@ final class NCMediaViewerPresenter: NSObject { // MARK: - Closing Source /// Returns the transition source for the currently selected item. - private func currentClosingTransitionSource() -> NCViewerTransitionSource? { + private func currentClosingTransitionSource() -> NCMediaViewerTransitionSource? { let ocId = forcedClosingOcId ?? currentModel?.selectedOcId guard let ocId else { From a0abfc533515af9e360404b4e62a9e0c59bced4d Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Wed, 27 May 2026 09:27:46 +0200 Subject: [PATCH 31/54] cleaning source Signed-off-by: Marino Faggiana --- .../Content/Audio/NCAudioViewerContentView.swift | 3 +++ .../Content/Image/NCLivePhotoViewerContentView.swift | 1 + .../Content/Video/NCVideoViewerContentView.swift | 9 +++------ .../Viewer/NCViewerMedia/Core/NCMediaViewerModel.swift | 2 ++ .../NCViewerMedia/NCMediaViewerHostingController.swift | 3 +++ .../Viewer/NCViewerMedia/NCMediaViewerPresenter.swift | 1 + .../NCViewerMedia/Views/NCMediaViewerPagingView.swift | 8 ++++++++ 7 files changed, 21 insertions(+), 6 deletions(-) diff --git a/iOSClient/Viewer/NCViewerMedia/Content/Audio/NCAudioViewerContentView.swift b/iOSClient/Viewer/NCViewerMedia/Content/Audio/NCAudioViewerContentView.swift index 3edf72538f..8d455c1cb0 100644 --- a/iOSClient/Viewer/NCViewerMedia/Content/Audio/NCAudioViewerContentView.swift +++ b/iOSClient/Viewer/NCViewerMedia/Content/Audio/NCAudioViewerContentView.swift @@ -146,6 +146,9 @@ struct NCAudioViewerContentView: View { 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 + // audio page-to-page state changes. .onReceive(NotificationCenter.default.publisher(for: .ncMediaViewerStopPlayback)) { _ in NCAudioViewerPlaybackRegistry.shared.stopAll() } diff --git a/iOSClient/Viewer/NCViewerMedia/Content/Image/NCLivePhotoViewerContentView.swift b/iOSClient/Viewer/NCViewerMedia/Content/Image/NCLivePhotoViewerContentView.swift index c961566a69..1d0e5c8e58 100644 --- a/iOSClient/Viewer/NCViewerMedia/Content/Image/NCLivePhotoViewerContentView.swift +++ b/iOSClient/Viewer/NCViewerMedia/Content/Image/NCLivePhotoViewerContentView.swift @@ -71,6 +71,7 @@ struct NCLivePhotoViewerContentView: View { isPlayingLivePhoto = true } ) + // Stop Live Photo playback when the media viewer requests a global playback stop. .onReceive(NotificationCenter.default.publisher(for: .ncMediaViewerStopPlayback)) { _ in stopLivePhotoPlayback() } diff --git a/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoViewerContentView.swift b/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoViewerContentView.swift index 9e413e9db4..d5420276e1 100644 --- a/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoViewerContentView.swift +++ b/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoViewerContentView.swift @@ -251,8 +251,7 @@ struct NCVideoViewerContentView: View { url: localURL, autoplay: true, expectedTaskIdentifier: expectedTaskIdentifier, - expectedLoadGeneration: expectedLoadGeneration, - source: "local" + expectedLoadGeneration: expectedLoadGeneration ) return } @@ -287,8 +286,7 @@ struct NCVideoViewerContentView: View { url: url, autoplay: result.autoplay, expectedTaskIdentifier: expectedTaskIdentifier, - expectedLoadGeneration: expectedLoadGeneration, - source: "resolved" + expectedLoadGeneration: expectedLoadGeneration ) } @@ -297,8 +295,7 @@ struct NCVideoViewerContentView: View { url: URL, autoplay: Bool, expectedTaskIdentifier: String, - expectedLoadGeneration: UUID, - source: String + expectedLoadGeneration: UUID ) { guard expectedTaskIdentifier == taskIdentifier else { return diff --git a/iOSClient/Viewer/NCViewerMedia/Core/NCMediaViewerModel.swift b/iOSClient/Viewer/NCViewerMedia/Core/NCMediaViewerModel.swift index 258cf4352c..c0909bc878 100644 --- a/iOSClient/Viewer/NCViewerMedia/Core/NCMediaViewerModel.swift +++ b/iOSClient/Viewer/NCViewerMedia/Core/NCMediaViewerModel.swift @@ -163,6 +163,8 @@ final class NCMediaViewerModel: ObservableObject { @MainActor func markPageAsDeleted(ocId: String) { + // Stop any active playback before marking the page as deleted. + // This is a destructive state change, so the global playback stop is intentional. NotificationCenter.default.post( name: .ncMediaViewerStopPlayback, object: nil diff --git a/iOSClient/Viewer/NCViewerMedia/NCMediaViewerHostingController.swift b/iOSClient/Viewer/NCViewerMedia/NCMediaViewerHostingController.swift index 0d27c16ccb..1f837f413d 100644 --- a/iOSClient/Viewer/NCViewerMedia/NCMediaViewerHostingController.swift +++ b/iOSClient/Viewer/NCViewerMedia/NCMediaViewerHostingController.swift @@ -185,6 +185,9 @@ final class NCMediaViewerHostingController: UIHostingController Date: Wed, 27 May 2026 11:30:18 +0200 Subject: [PATCH 32/54] Fix AVPlayer controls after PiP return Signed-off-by: Marino Faggiana --- .../Video/AVPlayer/NCVideoAVPlayerViewController.swift | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoAVPlayerViewController.swift b/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoAVPlayerViewController.swift index d41a75aef2..68f70ea501 100644 --- a/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoAVPlayerViewController.swift +++ b/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoAVPlayerViewController.swift @@ -829,11 +829,16 @@ extension NCVideoAVPlayerViewController: AVPictureInPictureControllerDelegate { func pictureInPictureControllerDidStopPictureInPicture( _ pictureInPictureController: AVPictureInPictureController ) { - updatePlayPauseButton() updateProgressControls() updateSeekingState() showControls(animated: false) + + if shouldKeepControlsVisible { + stopControlsHideTimer() + } else { + scheduleControlsHide() + } } func pictureInPictureController( From e52c2c4d373193832767c05f7ae5e0c43845e9aa Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Wed, 27 May 2026 15:20:56 +0200 Subject: [PATCH 33/54] Protect slider from page swipe gestures Signed-off-by: Marino Faggiana --- .../NCVideoAVPlayerViewController.swift | 27 ++++++++++++++----- .../Content/Video/NCVideoControlsView.swift | 9 ------- .../Video/VLC/NCVideoVLCViewController.swift | 27 ++++++++++++++----- 3 files changed, 42 insertions(+), 21 deletions(-) diff --git a/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoAVPlayerViewController.swift b/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoAVPlayerViewController.swift index 68f70ea501..d1aa1503e5 100644 --- a/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoAVPlayerViewController.swift +++ b/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoAVPlayerViewController.swift @@ -737,6 +737,22 @@ final class NCVideoAVPlayerViewController: UIViewController { || bottomControlsFrame.contains(location) } + internal func controlsGestureProtectedFrameContains(_ location: CGPoint) -> Bool { + let bottomControlsFrame = controlsView.bottomControlsView.convert( + controlsView.bottomControlsView.bounds, + to: view + ) + + let protectedFrame = CGRect( + x: 0, + y: bottomControlsFrame.minY - 12, + width: view.bounds.width, + height: bottomControlsFrame.height + 24 + ) + + return protectedFrame.contains(location) + } + private func configureAudioSession() { do { try AVAudioSession.sharedInstance().setCategory( @@ -880,13 +896,10 @@ extension NCVideoAVPlayerViewController: UIGestureRecognizerDelegate { return false } - guard controlsVisible else { - return true - } - let location = touch.location(in: view) - if controlsHitFramesContain(location) { + if controlsHitFramesContain(location) || + controlsGestureProtectedFrameContains(location) { return false } @@ -902,7 +915,9 @@ extension NCVideoAVPlayerViewController: UIGestureRecognizerDelegate { return false } - guard !isScrubbing else { + let location = gestureRecognizer.location(in: view) + + guard !controlsGestureProtectedFrameContains(location) else { return false } diff --git a/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoControlsView.swift b/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoControlsView.swift index 2cb166d99c..fcf7c54565 100644 --- a/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoControlsView.swift +++ b/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoControlsView.swift @@ -495,15 +495,6 @@ private struct NCVideoControlsSwiftUIView: View { .clipShape(Capsule()) .shadow(color: .black.opacity(0.16), radius: 18, x: 0, y: 5) .contentShape(Capsule()) - .simultaneousGesture( - DragGesture(minimumDistance: 0) - .onChanged { _ in - onScrubBegan() - } - .onEnded { _ in - onScrubEnded(state.progress) - } - ) } private var topActions: some View { diff --git a/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCViewController.swift b/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCViewController.swift index ab90e8360e..cd4acc1c64 100644 --- a/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCViewController.swift +++ b/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCViewController.swift @@ -786,6 +786,22 @@ final class NCVideoVLCViewController: UIViewController { || bottomControlsFrame.contains(location) } + internal func controlsGestureProtectedFrameContains(_ location: CGPoint) -> Bool { + let bottomControlsFrame = controlsView.bottomControlsView.convert( + controlsView.bottomControlsView.bounds, + to: view + ) + + let protectedFrame = CGRect( + x: 0, + y: bottomControlsFrame.minY - 12, + width: view.bounds.width, + height: bottomControlsFrame.height + 24 + ) + + return protectedFrame.contains(location) + } + private func configureAudioSession() { do { try AVAudioSession.sharedInstance().setCategory( @@ -843,13 +859,10 @@ extension NCVideoVLCViewController: UIGestureRecognizerDelegate { _ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch ) -> Bool { - guard controlsVisible else { - return true - } - let location = touch.location(in: view) - if controlsHitFramesContain(location) { + if controlsHitFramesContain(location) || + controlsGestureProtectedFrameContains(location) { return false } @@ -861,7 +874,9 @@ extension NCVideoVLCViewController: UIGestureRecognizerDelegate { return true } - guard !isScrubbing else { + let location = gestureRecognizer.location(in: view) + + guard !controlsGestureProtectedFrameContains(location) else { return false } From 175f0ab67b5bab08465f5e52f00b3b7068a6a017 Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Wed, 27 May 2026 15:45:29 +0200 Subject: [PATCH 34/54] Remove swipe inhibition logic Signed-off-by: Marino Faggiana --- .../NCVideoAVPlayerViewController.swift | 40 +------------------ .../Video/VLC/NCVideoVLCViewController.swift | 36 +---------------- 2 files changed, 4 insertions(+), 72 deletions(-) diff --git a/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoAVPlayerViewController.swift b/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoAVPlayerViewController.swift index d1aa1503e5..61e7e33b7c 100644 --- a/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoAVPlayerViewController.swift +++ b/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoAVPlayerViewController.swift @@ -424,9 +424,6 @@ final class NCVideoAVPlayerViewController: UIViewController { return } - guard !isScrubbing else { - return - } switch gesture.direction { case .left: @@ -737,21 +734,6 @@ final class NCVideoAVPlayerViewController: UIViewController { || bottomControlsFrame.contains(location) } - internal func controlsGestureProtectedFrameContains(_ location: CGPoint) -> Bool { - let bottomControlsFrame = controlsView.bottomControlsView.convert( - controlsView.bottomControlsView.bounds, - to: view - ) - - let protectedFrame = CGRect( - x: 0, - y: bottomControlsFrame.minY - 12, - width: view.bounds.width, - height: bottomControlsFrame.height + 24 - ) - - return protectedFrame.contains(location) - } private func configureAudioSession() { do { @@ -878,7 +860,6 @@ extension NCVideoAVPlayerViewController: AVPictureInPictureControllerDelegate { // MARK: - Gesture Delegate extension NCVideoAVPlayerViewController: UIGestureRecognizerDelegate { - // Keep AVPlayer touches compatible with viewer gestures. func gestureRecognizer( _ gestureRecognizer: UIGestureRecognizer, @@ -887,23 +868,12 @@ extension NCVideoAVPlayerViewController: UIGestureRecognizerDelegate { true } - // Do not let background taps steal control touches. + // Keep viewer gestures disabled while Picture in Picture is active. func gestureRecognizer( _ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch ) -> Bool { - guard !isPictureInPictureActive else { - return false - } - - let location = touch.location(in: view) - - if controlsHitFramesContain(location) || - controlsGestureProtectedFrameContains(location) { - return false - } - - return true + !isPictureInPictureActive } func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { @@ -915,12 +885,6 @@ extension NCVideoAVPlayerViewController: UIGestureRecognizerDelegate { return false } - let location = gestureRecognizer.location(in: view) - - guard !controlsGestureProtectedFrameContains(location) else { - return false - } - let velocity = (gestureRecognizer as? UIPanGestureRecognizer)?.velocity(in: view) ?? .zero guard velocity.y > 0 else { diff --git a/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCViewController.swift b/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCViewController.swift index cd4acc1c64..b55af819f1 100644 --- a/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCViewController.swift +++ b/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCViewController.swift @@ -424,9 +424,6 @@ final class NCVideoVLCViewController: UIViewController { @objc private func handleSwipe(_ gesture: UISwipeGestureRecognizer) { - guard !isScrubbing else { - return - } switch gesture.direction { case .left: guard canGoNext else { @@ -786,22 +783,6 @@ final class NCVideoVLCViewController: UIViewController { || bottomControlsFrame.contains(location) } - internal func controlsGestureProtectedFrameContains(_ location: CGPoint) -> Bool { - let bottomControlsFrame = controlsView.bottomControlsView.convert( - controlsView.bottomControlsView.bounds, - to: view - ) - - let protectedFrame = CGRect( - x: 0, - y: bottomControlsFrame.minY - 12, - width: view.bounds.width, - height: bottomControlsFrame.height + 24 - ) - - return protectedFrame.contains(location) - } - private func configureAudioSession() { do { try AVAudioSession.sharedInstance().setCategory( @@ -854,19 +835,12 @@ extension NCVideoVLCViewController: UIGestureRecognizerDelegate { true } - // Do not let background taps steal control touches. + // Allow fullscreen gestures to remain available over the VLC drawable. func gestureRecognizer( _ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch ) -> Bool { - let location = touch.location(in: view) - - if controlsHitFramesContain(location) || - controlsGestureProtectedFrameContains(location) { - return false - } - - return true + true } func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { @@ -874,12 +848,6 @@ extension NCVideoVLCViewController: UIGestureRecognizerDelegate { return true } - let location = gestureRecognizer.location(in: view) - - guard !controlsGestureProtectedFrameContains(location) else { - return false - } - let velocity = (gestureRecognizer as? UIPanGestureRecognizer)?.velocity(in: view) ?? .zero guard velocity.y > 0 else { From 88822a45a813a947fbacea65bcd876bf64b04fd4 Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Wed, 27 May 2026 16:14:28 +0200 Subject: [PATCH 35/54] fix Signed-off-by: Marino Faggiana --- .../Viewer/NCViewerMedia/Content/Video/NCVideoControlsView.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoControlsView.swift b/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoControlsView.swift index fcf7c54565..3f63b75efc 100644 --- a/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoControlsView.swift +++ b/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoControlsView.swift @@ -302,7 +302,6 @@ final class NCVideoControlsView: UIView { return } state.progress = progress - updateHostedView() delegate?.videoControls(self, didScrubTo: progress) }, onScrubEnded: { [weak self] progress in From e6da6cd58316003d6fdd9e5c91809de12dad930e Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Wed, 27 May 2026 16:18:09 +0200 Subject: [PATCH 36/54] Improve video controls scrub handling Signed-off-by: Marino Faggiana --- .../Content/Video/NCVideoControlsView.swift | 20 ++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) diff --git a/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoControlsView.swift b/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoControlsView.swift index 3f63b75efc..d0dd734474 100644 --- a/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoControlsView.swift +++ b/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoControlsView.swift @@ -74,9 +74,9 @@ final class NCVideoControlsView: UIView { fileprivate static let centerControlsWidth: CGFloat = 220 fileprivate static let centerControlsHeight: CGFloat = 76 - fileprivate static let bottomControlsHeight: CGFloat = 46 + fileprivate static let bottomControlsHeight: CGFloat = 52 fileprivate static let bottomControlsHorizontalInset: CGFloat = 28 - fileprivate static let bottomControlsBottomInset: CGFloat = 28 + fileprivate static let bottomControlsBottomInset: CGFloat = 30 fileprivate static let topActionsHeight: CGFloat = 46 fileprivate static let topActionsHorizontalInset: CGFloat = 28 fileprivate static let topActionsButtonSize: CGFloat = 38 @@ -383,6 +383,8 @@ private struct NCVideoControlsSwiftUIView: View { let onAddExternalSubtitle: () -> Void let onAudioTrackSelected: (_ index: Int32) -> Void + @State private var currentScrubProgress: Double? + var body: some View { GeometryReader { proxy in ZStack { @@ -469,15 +471,23 @@ private struct NCVideoControlsSwiftUIView: View { Slider( value: Binding( - get: { Double(state.progress) }, - set: { onScrubChanged(Float($0)) } + get: { + currentScrubProgress ?? Double(state.progress) + }, + set: { progress in + currentScrubProgress = progress + onScrubChanged(Float(progress)) + } ), in: 0...1, onEditingChanged: { isEditing in if isEditing { + currentScrubProgress = Double(state.progress) onScrubBegan() } else { - onScrubEnded(state.progress) + let progress = Float(currentScrubProgress ?? Double(state.progress)) + currentScrubProgress = nil + onScrubEnded(progress) } } ) From 8282e9c111417fffa014b518624cf58dfe6c9d12 Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Wed, 27 May 2026 16:27:50 +0200 Subject: [PATCH 37/54] Clean up unused video controls Signed-off-by: Marino Faggiana --- .../NCVideoAVPlayerViewController.swift | 29 ++++++++++++++++--- .../Content/Video/NCVideoControlsView.swift | 20 ------------- .../Video/VLC/NCVideoVLCViewController.swift | 25 +++++++++++++--- 3 files changed, 46 insertions(+), 28 deletions(-) diff --git a/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoAVPlayerViewController.swift b/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoAVPlayerViewController.swift index 61e7e33b7c..83179dc717 100644 --- a/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoAVPlayerViewController.swift +++ b/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoAVPlayerViewController.swift @@ -860,20 +860,41 @@ extension NCVideoAVPlayerViewController: AVPictureInPictureControllerDelegate { // MARK: - Gesture Delegate extension NCVideoAVPlayerViewController: UIGestureRecognizerDelegate { - // Keep AVPlayer touches compatible with viewer gestures. + // Keep AVPlayer touches compatible with viewer gestures, but isolate visible controls from global gestures. func gestureRecognizer( _ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer ) -> Bool { - true + guard controlsVisible else { + return true + } + + let firstGestureIsInsideControls = gestureRecognizer.view?.isDescendant(of: controlsView) == true + let secondGestureIsInsideControls = otherGestureRecognizer.view?.isDescendant(of: controlsView) == true + + if firstGestureIsInsideControls || secondGestureIsInsideControls { + return false + } + + return true } - // Keep viewer gestures disabled while Picture in Picture is active. + // Keep global viewer gestures disabled while Picture in Picture is active or when visible controls receive the touch. func gestureRecognizer( _ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch ) -> Bool { - !isPictureInPictureActive + guard !isPictureInPictureActive else { + return false + } + + guard controlsVisible else { + return true + } + + let location = touch.location(in: view) + + return !controlsHitFramesContain(location) } func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { diff --git a/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoControlsView.swift b/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoControlsView.swift index d0dd734474..2ffd0184de 100644 --- a/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoControlsView.swift +++ b/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoControlsView.swift @@ -12,8 +12,6 @@ protocol NCVideoControlsViewDelegate: AnyObject { func videoControlsDidTapPlayPause(_ controlsView: NCVideoControlsView) func videoControlsDidTapSeekForward(_ controlsView: NCVideoControlsView) func videoControlsDidTapPictureInPicture(_ controlsView: NCVideoControlsView) - func videoControlsDidTapSubtitle(_ controlsView: NCVideoControlsView) - func videoControlsDidTapAudio(_ controlsView: NCVideoControlsView) func videoControlsDidTapAddExternalSubtitle(_ controlsView: NCVideoControlsView) func videoControls(_ controlsView: NCVideoControlsView, didSelectSubtitleTrackIndex index: Int32) func videoControls(_ controlsView: NCVideoControlsView, didSelectAudioTrackIndex index: Int32) @@ -25,10 +23,6 @@ protocol NCVideoControlsViewDelegate: AnyObject { extension NCVideoControlsViewDelegate { func videoControlsDidTapPictureInPicture(_ controlsView: NCVideoControlsView) { } - func videoControlsDidTapSubtitle(_ controlsView: NCVideoControlsView) { } - - func videoControlsDidTapAudio(_ controlsView: NCVideoControlsView) { } - func videoControlsDidTapAddExternalSubtitle(_ controlsView: NCVideoControlsView) { } func videoControls(_ controlsView: NCVideoControlsView, didSelectSubtitleTrackIndex index: Int32) { } @@ -318,18 +312,6 @@ final class NCVideoControlsView: UIView { } delegate?.videoControlsDidTapPictureInPicture(self) }, - onSubtitle: { [weak self] in - guard let self else { - return - } - delegate?.videoControlsDidTapSubtitle(self) - }, - onAudio: { [weak self] in - guard let self else { - return - } - delegate?.videoControlsDidTapAudio(self) - }, onSubtitleTrackSelected: { [weak self] index in guard let self else { return @@ -377,8 +359,6 @@ private struct NCVideoControlsSwiftUIView: View { let onScrubChanged: (Float) -> Void let onScrubEnded: (Float) -> Void let onPictureInPicture: () -> Void - let onSubtitle: () -> Void - let onAudio: () -> Void let onSubtitleTrackSelected: (_ index: Int32) -> Void let onAddExternalSubtitle: () -> Void let onAudioTrackSelected: (_ index: Int32) -> Void diff --git a/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCViewController.swift b/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCViewController.swift index b55af819f1..97ec32ec4b 100644 --- a/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCViewController.swift +++ b/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCViewController.swift @@ -827,20 +827,37 @@ extension NCVideoVLCViewController: VLCMediaPlayerDelegate { // MARK: - Gesture Delegate extension NCVideoVLCViewController: UIGestureRecognizerDelegate { - // Keep VLC drawable touches compatible with viewer gestures. + // Keep VLC drawable touches compatible with viewer gestures, but isolate visible controls from global gestures. func gestureRecognizer( _ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer ) -> Bool { - true + guard controlsVisible else { + return true + } + + let firstGestureIsInsideControls = gestureRecognizer.view?.isDescendant(of: controlsView) == true + let secondGestureIsInsideControls = otherGestureRecognizer.view?.isDescendant(of: controlsView) == true + + if firstGestureIsInsideControls || secondGestureIsInsideControls { + return false + } + + return true } - // Allow fullscreen gestures to remain available over the VLC drawable. + // Keep global viewer gestures disabled when visible controls receive the touch. func gestureRecognizer( _ gestureRecognizer: UIGestureRecognizer, shouldReceive touch: UITouch ) -> Bool { - true + guard controlsVisible else { + return true + } + + let location = touch.location(in: view) + + return !controlsHitFramesContain(location) } func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { From 02dd9ad14016e9b78a86d6adab63655388cb2d44 Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Wed, 27 May 2026 17:06:45 +0200 Subject: [PATCH 38/54] Constrain - close pan gesture filtering Signed-off-by: Marino Faggiana --- .../Video/AVPlayer/NCVideoAVPlayerViewController.swift | 8 ++++---- .../Content/Video/VLC/NCVideoVLCViewController.swift | 6 ++++-- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoAVPlayerViewController.swift b/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoAVPlayerViewController.swift index 83179dc717..2786ae039c 100644 --- a/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoAVPlayerViewController.swift +++ b/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoAVPlayerViewController.swift @@ -70,6 +70,7 @@ 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? private var itemStatusObservation: NSKeyValueObservation? @@ -411,6 +412,7 @@ final class NCVideoAVPlayerViewController: UIViewController { action: #selector(handleClosePan(_:)) ) closePanGesture.delegate = self + self.closePanGesture = closePanGesture view.addGestureRecognizer(closePanGesture) } @@ -424,7 +426,6 @@ final class NCVideoAVPlayerViewController: UIViewController { return } - switch gesture.direction { case .left: guard canGoNext else { @@ -734,7 +735,6 @@ final class NCVideoAVPlayerViewController: UIViewController { || bottomControlsFrame.contains(location) } - private func configureAudioSession() { do { try AVAudioSession.sharedInstance().setCategory( @@ -898,7 +898,7 @@ extension NCVideoAVPlayerViewController: UIGestureRecognizerDelegate { } func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { - guard gestureRecognizer is UIPanGestureRecognizer else { + guard gestureRecognizer === closePanGesture else { return true } @@ -906,7 +906,7 @@ extension NCVideoAVPlayerViewController: UIGestureRecognizerDelegate { return false } - let velocity = (gestureRecognizer as? UIPanGestureRecognizer)?.velocity(in: view) ?? .zero + let velocity = closePanGesture?.velocity(in: view) ?? .zero guard velocity.y > 0 else { return false diff --git a/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCViewController.swift b/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCViewController.swift index 97ec32ec4b..9f6b4f0461 100644 --- a/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCViewController.swift +++ b/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCViewController.swift @@ -52,6 +52,7 @@ final class NCVideoVLCViewController: UIViewController { internal var controlsHideTimer: Timer? internal var controlsVisible = false internal var isScrubbing = false + private weak var closePanGesture: UIPanGestureRecognizer? internal var shouldKeepControlsVisible: Bool { mediaPlayer.state != .playing && !mediaPlayer.isPlaying @@ -382,6 +383,7 @@ final class NCVideoVLCViewController: UIViewController { action: #selector(handleClosePan(_:)) ) closePanGesture.delegate = self + self.closePanGesture = closePanGesture view.addGestureRecognizer(swipeLeft) view.addGestureRecognizer(swipeRight) @@ -861,11 +863,11 @@ extension NCVideoVLCViewController: UIGestureRecognizerDelegate { } func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { - guard gestureRecognizer is UIPanGestureRecognizer else { + guard gestureRecognizer === closePanGesture else { return true } - let velocity = (gestureRecognizer as? UIPanGestureRecognizer)?.velocity(in: view) ?? .zero + let velocity = closePanGesture?.velocity(in: view) ?? .zero guard velocity.y > 0 else { return false From 487c1e5cb09983a74d51837809b2ec96b846cd83 Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Fri, 29 May 2026 09:19:03 +0200 Subject: [PATCH 39/54] Media viewer: refine video playback cover and chrome-aware background handling Media viewer: refine video playback cover and chrome-aware background handling - Extract video playback cover and URL resolver into dedicated files - Split AVPlayer and VLC presentation logic into dedicated extensions - Add chrome-aware viewer background resolution - Align AVPlayer fullscreen background with chrome visibility - Pass chrome visibility state through AV/VLC presenters - Keep video cover stable while playback engine becomes ready - Remove obsolete fullscreen transition overlay logic Signed-off-by: Marino Faggiana --- .../AVPlayer/NCVideoPlaybackCoverView.swift | 74 +++++++++++++++++++ .../Video/NCVideoPlaybackCoverView 2.swift | 74 +++++++++++++++++++ 2 files changed, 148 insertions(+) create mode 100644 iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoPlaybackCoverView.swift create mode 100644 iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoPlaybackCoverView 2.swift diff --git a/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoPlaybackCoverView.swift b/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoPlaybackCoverView.swift new file mode 100644 index 0000000000..97aad01420 --- /dev/null +++ b/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoPlaybackCoverView.swift @@ -0,0 +1,74 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Marino Faggiana +// SPDX-License-Identifier: GPL-3.0-or-later + +import SwiftUI + +struct NCVideoPlaybackCoverView: View { + let previewURL: URL? + let backgroundStyle: NCViewerBackgroundStyle = .system + let isPlayEnabled: Bool + let isLaunchingPlayback: Bool + let onToggleChrome: (() -> Void)? + let onPlay: () -> Void + + var body: some View { + ZStack { + if let previewURL { + AsyncImage(url: previewURL) { phase in + switch phase { + case .success(let image): + image + .resizable() + .scaledToFit() + + case .failure, + .empty: + Color.ncViewerBackground(backgroundStyle) + + @unknown default: + Color.ncViewerBackground(backgroundStyle) + } + } + .ignoresSafeArea() + } else { + Color.ncViewerBackground(backgroundStyle) + .ignoresSafeArea() + } + + Color.clear + .contentShape(Rectangle()) + .ignoresSafeArea() + .onTapGesture { + onToggleChrome?() + } + + Button { + guard isPlayEnabled else { + return + } + + onPlay() + } label: { + Image(systemName: "play.fill") + .font(.system(size: 36, weight: .regular)) + .foregroundStyle(isPlayEnabled ? .black : .black.opacity(0.35)) + .frame(width: 62, height: 62) + .background(.white.opacity(isPlayEnabled ? 0.92 : 0.45)) + .clipShape(Circle()) + .shadow( + color: .black.opacity(isPlayEnabled ? 0.16 : 0.08), + radius: 14, + x: 0, + y: 4 + ) + } + .buttonStyle(.plain) + .disabled(!isPlayEnabled || isLaunchingPlayback) + .opacity(isLaunchingPlayback ? 0 : 1) + .scaleEffect(isLaunchingPlayback ? 1.12 : 1) + .animation(.easeInOut(duration: 0.14), value: isLaunchingPlayback) + .accessibilityLabel(Text(NSLocalizedString("_play_", comment: ""))) + } + } +} diff --git a/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoPlaybackCoverView 2.swift b/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoPlaybackCoverView 2.swift new file mode 100644 index 0000000000..97aad01420 --- /dev/null +++ b/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoPlaybackCoverView 2.swift @@ -0,0 +1,74 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Marino Faggiana +// SPDX-License-Identifier: GPL-3.0-or-later + +import SwiftUI + +struct NCVideoPlaybackCoverView: View { + let previewURL: URL? + let backgroundStyle: NCViewerBackgroundStyle = .system + let isPlayEnabled: Bool + let isLaunchingPlayback: Bool + let onToggleChrome: (() -> Void)? + let onPlay: () -> Void + + var body: some View { + ZStack { + if let previewURL { + AsyncImage(url: previewURL) { phase in + switch phase { + case .success(let image): + image + .resizable() + .scaledToFit() + + case .failure, + .empty: + Color.ncViewerBackground(backgroundStyle) + + @unknown default: + Color.ncViewerBackground(backgroundStyle) + } + } + .ignoresSafeArea() + } else { + Color.ncViewerBackground(backgroundStyle) + .ignoresSafeArea() + } + + Color.clear + .contentShape(Rectangle()) + .ignoresSafeArea() + .onTapGesture { + onToggleChrome?() + } + + Button { + guard isPlayEnabled else { + return + } + + onPlay() + } label: { + Image(systemName: "play.fill") + .font(.system(size: 36, weight: .regular)) + .foregroundStyle(isPlayEnabled ? .black : .black.opacity(0.35)) + .frame(width: 62, height: 62) + .background(.white.opacity(isPlayEnabled ? 0.92 : 0.45)) + .clipShape(Circle()) + .shadow( + color: .black.opacity(isPlayEnabled ? 0.16 : 0.08), + radius: 14, + x: 0, + y: 4 + ) + } + .buttonStyle(.plain) + .disabled(!isPlayEnabled || isLaunchingPlayback) + .opacity(isLaunchingPlayback ? 0 : 1) + .scaleEffect(isLaunchingPlayback ? 1.12 : 1) + .animation(.easeInOut(duration: 0.14), value: isLaunchingPlayback) + .accessibilityLabel(Text(NSLocalizedString("_play_", comment: ""))) + } + } +} From 8f3fc45ef9ab95051005ac316b4039db6343a7cd Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Fri, 29 May 2026 09:19:16 +0200 Subject: [PATCH 40/54] Media viewer: refine video playback cover and chrome-aware background handling Media viewer: refine video playback cover and chrome-aware background handling - Extract video playback cover and URL resolver into dedicated files - Split AVPlayer and VLC presentation logic into dedicated extensions - Add chrome-aware viewer background resolution - Align AVPlayer fullscreen background with chrome visibility - Pass chrome visibility state through AV/VLC presenters - Keep video cover stable while playback engine becomes ready - Remove obsolete fullscreen transition overlay logic Signed-off-by: Marino Faggiana --- Nextcloud.xcodeproj/project.pbxproj | 16 + .../AVPlayer/NCVideoAVPlayerPresenter.swift | 9 +- .../NCVideoAVPlayerViewController.swift | 42 +- .../NCVideoAVPlayerViewControls.swift | 5 + .../NCVideoViewerContentView+AVPlayer.swift | 64 +++ .../Video/NCVideoPlaybackCoverView 2.swift | 74 --- .../NCVideoPlaybackCoverView.swift | 0 .../Content/Video/NCVideoURLResolver.swift | 92 +++ .../Video/NCVideoViewerContentView.swift | 540 +++++++----------- .../Video/VLC/NCVideoVLCPresenter.swift | 9 +- .../Video/VLC/NCVideoVLCViewController.swift | 50 +- .../VLC/NCVideoViewerContentView+VLC.swift | 64 +++ .../Core/NCMediaViewerModel.swift | 59 +- .../Helpers/NCMediaViewerAppearance.swift | 21 +- .../Views/NCMediaViewerPageView.swift | 69 ++- .../Views/NCMediaViewerPagingView.swift | 32 +- 16 files changed, 661 insertions(+), 485 deletions(-) create mode 100644 iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoViewerContentView+AVPlayer.swift delete mode 100644 iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoPlaybackCoverView 2.swift rename iOSClient/Viewer/NCViewerMedia/Content/Video/{AVPlayer => }/NCVideoPlaybackCoverView.swift (100%) create mode 100644 iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoURLResolver.swift create mode 100644 iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoViewerContentView+VLC.swift diff --git a/Nextcloud.xcodeproj/project.pbxproj b/Nextcloud.xcodeproj/project.pbxproj index cfcfc4a7e1..1d7c27b6b5 100644 --- a/Nextcloud.xcodeproj/project.pbxproj +++ b/Nextcloud.xcodeproj/project.pbxproj @@ -706,6 +706,10 @@ F7A8D74128F18254008BBE1C /* UIColor+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = F70CEF5523E9C7E50007035B /* UIColor+Extension.swift */; }; F7A8D74228F18261008BBE1C /* NCUtility.swift in Sources */ = {isa = PBXBuildFile; fileRef = F70BFC7320E0FA7C00C67599 /* NCUtility.swift */; }; F7A8D74428F1827B008BBE1C /* ThreadSafeDictionary.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7245923289BB50B00474787 /* ThreadSafeDictionary.swift */; }; + F7A98A4E2FC97414009E6313 /* NCVideoURLResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7A98A4D2FC97414009E6313 /* NCVideoURLResolver.swift */; }; + F7A98A502FC9744A009E6313 /* NCVideoPlaybackCoverView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7A98A4F2FC9744A009E6313 /* NCVideoPlaybackCoverView.swift */; }; + F7A98A522FC97464009E6313 /* NCVideoViewerContentView+AVPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7A98A512FC97464009E6313 /* NCVideoViewerContentView+AVPlayer.swift */; }; + F7A98A542FC9746C009E6313 /* NCVideoViewerContentView+VLC.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7A98A532FC9746C009E6313 /* NCVideoViewerContentView+VLC.swift */; }; F7AC1CB028AB94490032D99F /* Array+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7AC1CAF28AB94490032D99F /* Array+Extension.swift */; }; F7AC934A296193050002BC0F /* Reasons to use Nextcloud.pdf in Resources */ = {isa = PBXBuildFile; fileRef = F7AC9349296193050002BC0F /* Reasons to use Nextcloud.pdf */; }; F7AE00F5230D5F9E007ACF8A /* NCLoginProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7AE00F4230D5F9E007ACF8A /* NCLoginProvider.swift */; }; @@ -1648,6 +1652,10 @@ F7A573682E190377009C9257 /* NCShareExtensionData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCShareExtensionData.swift; sourceTree = ""; }; F7A7FDDB2C2DBD6200E9A93A /* NCDeepLinkHandler.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NCDeepLinkHandler.swift; sourceTree = ""; }; F7A846DD2BB01ACB0024816F /* NCTrashCellProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCTrashCellProtocol.swift; sourceTree = ""; }; + F7A98A4D2FC97414009E6313 /* NCVideoURLResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCVideoURLResolver.swift; sourceTree = ""; }; + F7A98A4F2FC9744A009E6313 /* NCVideoPlaybackCoverView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCVideoPlaybackCoverView.swift; sourceTree = ""; }; + F7A98A512FC97464009E6313 /* NCVideoViewerContentView+AVPlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NCVideoViewerContentView+AVPlayer.swift"; sourceTree = ""; }; + F7A98A532FC9746C009E6313 /* NCVideoViewerContentView+VLC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NCVideoViewerContentView+VLC.swift"; sourceTree = ""; }; F7AA41B827C7CF4600494705 /* ca */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ca; path = ca.lproj/InfoPlist.strings; sourceTree = ""; }; F7AA41B927C7CF4B00494705 /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/InfoPlist.strings"; sourceTree = ""; }; F7AA41BA27C7CF5000494705 /* zh-Hant-TW */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hant-TW"; path = "zh-Hant-TW.lproj/InfoPlist.strings"; sourceTree = ""; }; @@ -2842,6 +2850,8 @@ children = ( F78448A82FB1BE9000F2909A /* NCVideoViewerContentView.swift */, F78448A32FB1BE9000F2909A /* NCVideoPlaybackController.swift */, + F7A98A4F2FC9744A009E6313 /* NCVideoPlaybackCoverView.swift */, + F7A98A4D2FC97414009E6313 /* NCVideoURLResolver.swift */, F7547FE52FB76C1800E372C3 /* NCVideoControlsView.swift */, F78448BF2FB1C78900F2909A /* AVPlayer */, F78448C02FB1C79A00F2909A /* VLC */, @@ -2852,6 +2862,7 @@ F78448BF2FB1C78900F2909A /* AVPlayer */ = { isa = PBXGroup; children = ( + F7A98A512FC97464009E6313 /* NCVideoViewerContentView+AVPlayer.swift */, F7948DE62FBAE52F00253D1C /* NCVideoAVPlayerPresenter.swift */, F7948DE82FBAEC5300253D1C /* NCVideoAVPlayerViewController.swift */, F7FAAC212FB773CA00DCA45B /* NCVideoAVPlayerViewControls.swift */, @@ -2862,6 +2873,7 @@ F78448C02FB1C79A00F2909A /* VLC */ = { isa = PBXGroup; children = ( + F7A98A532FC9746C009E6313 /* NCVideoViewerContentView+VLC.swift */, F7635D8C2FB1F81D007F658D /* NCVideoVLCPresenter.swift */, F78448BD2FB1C33B00F2909A /* NCVideoVLCViewController.swift */, F7547FE22FB7429200E372C3 /* NCVideoVLCViewControls.swift */, @@ -4756,6 +4768,7 @@ F710D2022405826100A6033D /* NCContextMenuViewer.swift in Sources */, F7EDBB582FA8D00200098C42 /* NCMediaViewerAppearance.swift in Sources */, F74E3EEB2FB0AD8500252FA0 /* Notification+Extension.swift in Sources */, + F7A98A502FC9744A009E6313 /* NCVideoPlaybackCoverView.swift in Sources */, F765E9CD295C585800A09ED8 /* NCUploadScanDocument.swift in Sources */, F741C2242B6B9FD600E849BB /* NCMediaSelectTabBar.swift in Sources */, F7BF9D822934CA21009EE9A6 /* NCManageDatabase+LayoutForView.swift in Sources */, @@ -4912,6 +4925,7 @@ F74B91E92F51D45A0050813D /* ErrorBannerView.swift in Sources */, F7E8A391295DC5E0006CB2D0 /* View+Extension.swift in Sources */, F7CB77642F5843E500DE649A /* UIFont+Extension.swift in Sources */, + F7A98A4E2FC97414009E6313 /* NCVideoURLResolver.swift in Sources */, F79B869B265E19D40085C0E0 /* NSMutableAttributedString+Extension.swift in Sources */, F7B7504B2397D38F004E13EC /* UIImage+Extension.swift in Sources */, AF3FDCC22796ECC300710F60 /* NCTrash+CollectionView.swift in Sources */, @@ -4954,6 +4968,7 @@ F7635D8D2FB1F820007F658D /* NCVideoVLCPresenter.swift in Sources */, F749ED312FADD62600CE8DFA /* NCMediaViewerDetailView.swift in Sources */, F768822A2C0DD1E7001CF441 /* NCSettingsModel.swift in Sources */, + F7A98A522FC97464009E6313 /* NCVideoViewerContentView+AVPlayer.swift in Sources */, F737DA9D2B7B893C0063BAFC /* NCPasscode.swift in Sources */, F77C97392953131000FDDD09 /* NCCameraRoll.swift in Sources */, F7EDBB5C2FA8DBE800098C42 /* NCMediaViewerPresenter.swift in Sources */, @@ -5026,6 +5041,7 @@ F71D2FB72E09BBD700B751CC /* NCAutoUploadModel.swift in Sources */, F38F71252B6BBDC300473CDC /* NCCollectionViewCommonSelectTabBar.swift in Sources */, F7E4D9C422ED929B003675FD /* NCShareCommentsCell.swift in Sources */, + F7A98A542FC9746C009E6313 /* NCVideoViewerContentView+VLC.swift in Sources */, F73EFF9B2DB11EC900FD434C /* NCFiles+UIScrollViewDelegate.swift in Sources */, F7327E202B73A42F00A462C7 /* NCNetworking+Download.swift in Sources */, F76882332C0DD1E7001CF441 /* NCDisplayModel.swift in Sources */, diff --git a/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoAVPlayerPresenter.swift b/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoAVPlayerPresenter.swift index ed3822c21c..9823397ef3 100644 --- a/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoAVPlayerPresenter.swift +++ b/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoAVPlayerPresenter.swift @@ -20,6 +20,8 @@ enum NCVideoAVPlayerPresenter { metadata: tableMetadata, url: URL, userAgent: String?, + shouldAutoPlay: Bool = true, + isChromeHidden: Bool = false, contextMenuController: NCMainTabBarController?, canGoPrevious: Bool = false, canGoNext: Bool = false, @@ -33,6 +35,8 @@ enum NCVideoAVPlayerPresenter { metadata: metadata, url: url, userAgent: userAgent, + shouldAutoPlay: shouldAutoPlay, + isChromeHidden: isChromeHidden, contextMenuController: contextMenuController ) currentViewController.canGoPrevious = canGoPrevious @@ -53,6 +57,8 @@ enum NCVideoAVPlayerPresenter { metadata: metadata, url: url, userAgent: userAgent, + shouldAutoPlay: shouldAutoPlay, + isChromeHidden: isChromeHidden, contextMenuController: contextMenuController ) currentViewController.canGoPrevious = canGoPrevious @@ -90,6 +96,8 @@ enum NCVideoAVPlayerPresenter { metadata: metadata, url: url, userAgent: userAgent, + shouldAutoPlay: shouldAutoPlay, + isChromeHidden: isChromeHidden, contextMenuController: contextMenuController ) viewController.canGoPrevious = canGoPrevious @@ -106,7 +114,6 @@ enum NCVideoAVPlayerPresenter { ) navigationController.modalPresentationStyle = .fullScreen - navigationController.modalTransitionStyle = .crossDissolve navigationController.navigationBar.prefersLargeTitles = false navigationController.navigationBar.barStyle = .black navigationController.navigationBar.tintColor = .white diff --git a/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoAVPlayerViewController.swift b/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoAVPlayerViewController.swift index 2786ae039c..ace7d3b776 100644 --- a/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoAVPlayerViewController.swift +++ b/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoAVPlayerViewController.swift @@ -38,6 +38,8 @@ final class NCVideoAVPlayerViewController: UIViewController { private var metadata: tableMetadata private var url: URL private var userAgent: String? + private var shouldAutoPlay: Bool + private var isChromeHidden: Bool private weak var contextMenuController: NCMainTabBarController? // MARK: - Paging Callbacks @@ -121,11 +123,15 @@ final class NCVideoAVPlayerViewController: UIViewController { metadata: tableMetadata, url: URL, userAgent: String?, + shouldAutoPlay: Bool = true, + isChromeHidden: Bool = false, contextMenuController: NCMainTabBarController? ) { self.metadata = metadata self.url = url self.userAgent = userAgent + self.shouldAutoPlay = shouldAutoPlay + self.isChromeHidden = isChromeHidden self.contextMenuController = contextMenuController super.init( @@ -134,7 +140,6 @@ final class NCVideoAVPlayerViewController: UIViewController { ) modalPresentationStyle = .fullScreen - modalTransitionStyle = .crossDissolve } required init?(coder: NSCoder) { @@ -151,12 +156,14 @@ final class NCVideoAVPlayerViewController: UIViewController { // MARK: - Lifecycle override func loadView() { + let initialBackgroundColor = viewerBackgroundColor + let rootView = UIView() - rootView.backgroundColor = .black + rootView.backgroundColor = initialBackgroundColor rootView.isOpaque = true rootView.clipsToBounds = true - playerContainerView.backgroundColor = .black + playerContainerView.backgroundColor = initialBackgroundColor playerContainerView.isOpaque = true playerContainerView.clipsToBounds = true playerContainerView.translatesAutoresizingMaskIntoConstraints = false @@ -189,7 +196,7 @@ final class NCVideoAVPlayerViewController: UIViewController { override func viewDidLoad() { super.viewDidLoad() - view.backgroundColor = .black + view.backgroundColor = viewerBackgroundColor configureNavigationItem() updateTitleLabel(metadata: metadata) @@ -239,6 +246,8 @@ final class NCVideoAVPlayerViewController: UIViewController { metadata: tableMetadata, url: URL, userAgent: String?, + shouldAutoPlay: Bool = true, + isChromeHidden: Bool = false, contextMenuController: NCMainTabBarController? ) { let urlChanged = self.url != url @@ -250,7 +259,9 @@ final class NCVideoAVPlayerViewController: UIViewController { self.metadata = metadata self.url = url self.userAgent = userAgent + self.shouldAutoPlay = shouldAutoPlay self.contextMenuController = contextMenuController + updateViewerBackground(isChromeHidden: isChromeHidden) updateTitleLabel(metadata: metadata) refreshMoreMenu() @@ -263,6 +274,24 @@ final class NCVideoAVPlayerViewController: UIViewController { updateProgressControls() } + private var viewerBackgroundColor: UIColor { + UIColor.ncViewerBackground( + ncViewerBackgroundStyle( + for: metadata, + isChromeHidden: isChromeHidden + ) + ) + } + + @MainActor + internal func updateViewerBackground(isChromeHidden: Bool) { + self.isChromeHidden = isChromeHidden + + let backgroundColor = viewerBackgroundColor + view.backgroundColor = backgroundColor + playerContainerView.backgroundColor = backgroundColor + } + // MARK: - Navigation private func configureNavigationItem() { @@ -685,6 +714,11 @@ final class NCVideoAVPlayerViewController: UIViewController { return } + if shouldAutoPlay, + player.timeControlStatus != .playing { + player.play() + } + if !controlsVisible, !isPictureInPictureActive { showControls(animated: false) diff --git a/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoAVPlayerViewControls.swift b/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoAVPlayerViewControls.swift index 457798ed3a..f421911db5 100644 --- a/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoAVPlayerViewControls.swift +++ b/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoAVPlayerViewControls.swift @@ -46,6 +46,7 @@ extension NCVideoAVPlayerViewController { func showControls(animated: Bool) { guard !isPictureInPictureActive else { + updateViewerBackground(isChromeHidden: true) setControlsVisible( false, animated: false @@ -57,6 +58,8 @@ extension NCVideoAVPlayerViewController { return } + updateViewerBackground(isChromeHidden: false) + setNavigationBarVisible( true, animated: animated @@ -74,6 +77,8 @@ extension NCVideoAVPlayerViewController { return } + updateViewerBackground(isChromeHidden: true) + setNavigationBarVisible( false, animated: animated diff --git a/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoViewerContentView+AVPlayer.swift b/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoViewerContentView+AVPlayer.swift new file mode 100644 index 0000000000..5ea7c349b1 --- /dev/null +++ b/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoViewerContentView+AVPlayer.swift @@ -0,0 +1,64 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Marino Faggiana +// SPDX-License-Identifier: GPL-3.0-or-later + +import Foundation + +extension NCVideoViewerContentView { + @MainActor + func requestAVPlayerPresentation(url: URL) { + hasRequestedPlayback = true + presentAVPlayerIfSelected(url: url) + } + + @MainActor + func presentAVPlayerIfSelected(url: URL) { + guard isSelected else { + return + } + + guard presentedAVPlayerURL != url else { + return + } + + presentedAVPlayerURL = url + + NCVideoAVPlayerPresenter.present( + metadata: metadata, + url: url, + userAgent: userAgent, + shouldAutoPlay: true, + isChromeHidden: isChromeHidden, + contextMenuController: contextMenuController, + canGoPrevious: canGoPrevious, + canGoNext: canGoNext, + onPrevious: goToPreviousPageFromAVPlayer, + onNext: goToNextPageFromAVPlayer, + onClose: closeFromFullscreenVideo + ) + } + + @MainActor + func goToPreviousPageFromAVPlayer() { + performFullscreenPageTransition( + dismissPlayer: { + NCVideoAVPlayerPresenter.dismiss() + }, + changePage: { + onPreviousPage?() + } + ) + } + + @MainActor + func goToNextPageFromAVPlayer() { + performFullscreenPageTransition( + dismissPlayer: { + NCVideoAVPlayerPresenter.dismiss() + }, + changePage: { + onNextPage?() + } + ) + } +} diff --git a/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoPlaybackCoverView 2.swift b/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoPlaybackCoverView 2.swift deleted file mode 100644 index 97aad01420..0000000000 --- a/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoPlaybackCoverView 2.swift +++ /dev/null @@ -1,74 +0,0 @@ -// SPDX-FileCopyrightText: Nextcloud GmbH -// SPDX-FileCopyrightText: 2026 Marino Faggiana -// SPDX-License-Identifier: GPL-3.0-or-later - -import SwiftUI - -struct NCVideoPlaybackCoverView: View { - let previewURL: URL? - let backgroundStyle: NCViewerBackgroundStyle = .system - let isPlayEnabled: Bool - let isLaunchingPlayback: Bool - let onToggleChrome: (() -> Void)? - let onPlay: () -> Void - - var body: some View { - ZStack { - if let previewURL { - AsyncImage(url: previewURL) { phase in - switch phase { - case .success(let image): - image - .resizable() - .scaledToFit() - - case .failure, - .empty: - Color.ncViewerBackground(backgroundStyle) - - @unknown default: - Color.ncViewerBackground(backgroundStyle) - } - } - .ignoresSafeArea() - } else { - Color.ncViewerBackground(backgroundStyle) - .ignoresSafeArea() - } - - Color.clear - .contentShape(Rectangle()) - .ignoresSafeArea() - .onTapGesture { - onToggleChrome?() - } - - Button { - guard isPlayEnabled else { - return - } - - onPlay() - } label: { - Image(systemName: "play.fill") - .font(.system(size: 36, weight: .regular)) - .foregroundStyle(isPlayEnabled ? .black : .black.opacity(0.35)) - .frame(width: 62, height: 62) - .background(.white.opacity(isPlayEnabled ? 0.92 : 0.45)) - .clipShape(Circle()) - .shadow( - color: .black.opacity(isPlayEnabled ? 0.16 : 0.08), - radius: 14, - x: 0, - y: 4 - ) - } - .buttonStyle(.plain) - .disabled(!isPlayEnabled || isLaunchingPlayback) - .opacity(isLaunchingPlayback ? 0 : 1) - .scaleEffect(isLaunchingPlayback ? 1.12 : 1) - .animation(.easeInOut(duration: 0.14), value: isLaunchingPlayback) - .accessibilityLabel(Text(NSLocalizedString("_play_", comment: ""))) - } - } -} diff --git a/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoPlaybackCoverView.swift b/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoPlaybackCoverView.swift similarity index 100% rename from iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoPlaybackCoverView.swift rename to iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoPlaybackCoverView.swift diff --git a/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoURLResolver.swift b/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoURLResolver.swift new file mode 100644 index 0000000000..b20963be3c --- /dev/null +++ b/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoURLResolver.swift @@ -0,0 +1,92 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Marino Faggiana +// SPDX-License-Identifier: GPL-3.0-or-later + +import Foundation +import NextcloudKit + +struct NCVideoURLResolver { + private let utilityFileSystem = NCUtilityFileSystem() + + func getVideoURL( + metadata: tableMetadata + ) async -> (url: URL?, autoplay: Bool, error: NKError) { + if !metadata.url.isEmpty { + if metadata.url.hasPrefix("/") { + return ( + url: URL(fileURLWithPath: metadata.url), + autoplay: true, + error: .success + ) + } else { + return ( + url: URL(string: metadata.url), + autoplay: true, + error: .success + ) + } + } + + if utilityFileSystem.fileProviderStorageExists(metadata) { + let localPath = utilityFileSystem.getDirectoryProviderStorageOcId( + metadata.ocId, + fileName: metadata.fileNameView, + userId: metadata.userId, + urlBase: metadata.urlBase + ) + + return ( + url: URL(fileURLWithPath: localPath), + autoplay: true, + error: .success + ) + } + + return await getDirectDownloadURL(metadata: metadata) + } + + private func getDirectDownloadURL( + metadata: tableMetadata + ) async -> (url: URL?, autoplay: Bool, error: NKError) { + await withCheckedContinuation { continuation in + NextcloudKit.shared.getDirectDownload( + fileId: metadata.fileId, + account: metadata.account + ) { task in + Task { + let identifier = await NCNetworking.shared.networkingTasks.createIdentifier( + account: metadata.account, + path: metadata.fileId, + name: "getDirectDownload" + ) + + await NCNetworking.shared.networkingTasks.track( + identifier: identifier, + task: task + ) + } + } completion: { _, urlString, _, error in + guard error == .success, + let urlString, + let url = URL(string: urlString) else { + continuation.resume( + returning: ( + url: nil, + autoplay: false, + error: error + ) + ) + return + } + + continuation.resume( + returning: ( + url: url, + autoplay: true, + error: error + ) + ) + } + } + } +} diff --git a/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoViewerContentView.swift b/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoViewerContentView.swift index d5420276e1..bfafbd180b 100644 --- a/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoViewerContentView.swift +++ b/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoViewerContentView.swift @@ -10,22 +10,26 @@ import NextcloudKit struct NCVideoViewerContentView: View { let metadata: tableMetadata let localURL: URL? + let previewURL: URL? let userAgent: String? let isSelected: Bool + let isChromeHidden: Bool let contextMenuController: NCMainTabBarController? let navigationBar: UINavigationBar? let canGoPrevious: Bool let canGoNext: Bool let onPreviousPage: (() -> Void)? let onNextPage: (() -> Void)? + let onToggleChrome: (() -> Void)? let onClose: ((_ ocId: String?) -> Void)? @ObservedObject private var playback = NCVideoPlaybackController.shared @State private var errorMessage: String? - @State private var presentedAVPlayerURL: URL? - @State private var resolvedVideoURL: URL? - @State private var presentedVLCURL: URL? + @State var presentedAVPlayerURL: URL? + @State var presentedVLCURL: URL? + @State var hasRequestedPlayback = false + @State var isLaunchingPlayback = false @State private var loadGeneration = UUID() private let resolver = NCVideoURLResolver() @@ -36,99 +40,43 @@ struct NCVideoViewerContentView: View { init( metadata: tableMetadata, localURL: URL?, + previewURL: URL? = nil, userAgent: String? = nil, isSelected: Bool = true, + isChromeHidden: Bool = false, contextMenuController: NCMainTabBarController? = nil, navigationBar: UINavigationBar? = nil, canGoPrevious: Bool = false, canGoNext: Bool = false, onPreviousPage: (() -> Void)? = nil, onNextPage: (() -> Void)? = nil, + onToggleChrome: (() -> Void)? = nil, onClose: ((_ ocId: String?) -> Void)? = nil ) { self.metadata = metadata self.localURL = localURL + self.previewURL = previewURL self.userAgent = userAgent self.isSelected = isSelected + self.isChromeHidden = isChromeHidden self.contextMenuController = contextMenuController self.navigationBar = navigationBar self.canGoPrevious = canGoPrevious self.canGoNext = canGoNext self.onPreviousPage = onPreviousPage self.onNextPage = onNextPage + self.onToggleChrome = onToggleChrome self.onClose = onClose } var body: some View { ZStack { - Color.black + videoBackgroundColor .ignoresSafeArea() - if let errorMessage { - failedView(errorMessage) - } else { - switch playback.engine { - case .loading: - EmptyView() - - case .avFoundation(let url): - if isSelected, - isCurrentPlaybackVideo() { - Color.clear - .ignoresSafeArea() - .allowsHitTesting(false) - .onAppear { - presentAVPlayerIfSelected(url: url) - } - .onChange(of: url) { _, newURL in - presentedAVPlayerURL = nil - presentAVPlayerIfSelected(url: newURL) - } - .onChange(of: isSelected) { _, selected in - guard selected else { - return - } - - presentAVPlayerIfSelected(url: url) - } - } else { - EmptyView() - } - - case .vlc(let url): - if isSelected, - isCurrentPlaybackVideo() { - Color.clear - .ignoresSafeArea() - .allowsHitTesting(false) - .onAppear { - presentVLCIfSelected(url: url) - } - .onChange(of: url) { _, newURL in - presentedVLCURL = nil - presentVLCIfSelected(url: newURL) - } - .onChange(of: isSelected) { _, selected in - guard selected else { - return - } - - presentVLCIfSelected(url: url) - } - } else { - EmptyView() - } - - case .failed(let message): - if isSelected { - failedView(message) - } else { - EmptyView() - } - } - } + contentView } - .background(Color.black) + .background(videoBackgroundColor) .task(id: taskIdentifier) { await loadVideoIfSelected() } @@ -151,8 +99,88 @@ struct NCVideoViewerContentView: View { // Ignore layout-driven disappear events. } } +} + +// MARK: - Main Content + +private extension NCVideoViewerContentView { + var videoBackgroundColor: Color { + isChromeHidden ? .black : Color.ncViewerBackground(.system) + } + + @ViewBuilder + var contentView: some View { + if let errorMessage { + failedView(errorMessage) + } else if !hasRequestedPlayback { + if case .failed(let message) = playback.engine { + failedView(message) + } else { + NCVideoPlaybackCoverView( + previewURL: previewURL, + isPlayEnabled: isPlaybackCoverPlayEnabled, + isLaunchingPlayback: isLaunchingPlayback, + onToggleChrome: onToggleChrome, + onPlay: playFromCover + ) + } + } else { + requestedPlaybackView + } + } - private func failedView(_ message: String) -> some View { + @ViewBuilder + var requestedPlaybackView: some View { + switch playback.engine { + case .loading: + videoBackgroundColor + .ignoresSafeArea() + .allowsHitTesting(false) + + case .avFoundation(let url): + if isSelected, + isCurrentPlaybackVideo() { + playbackPresentationPlaceholder( + url: url, + onURLChanged: { newURL in + presentedAVPlayerURL = nil + presentAVPlayerIfSelected(url: newURL) + }, + onSelectionRestored: { + presentAVPlayerIfSelected(url: url) + } + ) + } else { + EmptyView() + } + + case .vlc(let url): + if isSelected, + isCurrentPlaybackVideo() { + playbackPresentationPlaceholder( + url: url, + onURLChanged: { newURL in + presentedVLCURL = nil + presentVLCIfSelected(url: newURL) + }, + onSelectionRestored: { + presentVLCIfSelected(url: url) + } + ) + } else { + EmptyView() + } + + case .failed(let message): + if isSelected { + failedView(message) + } else { + EmptyView() + } + } + } + + func failedView(_ message: String) -> some View { VStack(spacing: 12) { Image(systemName: "video.slash") .font(.system(size: 44, weight: .regular)) @@ -163,28 +191,93 @@ struct NCVideoViewerContentView: View { .foregroundStyle(.white) .padding(24) } +} + +// MARK: - Playback Cover + +private extension NCVideoViewerContentView { + var isPlaybackCoverPlayEnabled: Bool { + guard isSelected, + isCurrentPlaybackVideo() else { + return false + } + + switch playback.engine { + case .avFoundation, + .vlc: + return true - // MARK: - Loading + case .loading, + .failed: + return false + } + } @MainActor - private func stopPlaybackForDeselection() { - presentedAVPlayerURL = nil - resolvedVideoURL = nil - presentedVLCURL = nil + func playFromCover() { + guard isPlaybackCoverPlayEnabled, + !isLaunchingPlayback else { + return + } - NCVideoAVPlayerPresenter.dismiss() - NCVideoVLCPresenter.dismiss() - playback.stop() + isLaunchingPlayback = true + + switch playback.engine { + case .avFoundation(let url): + requestAVPlayerPresentation(url: url) + + case .vlc(let url): + requestVLCPresentation(url: url) + + case .loading, + .failed: + isLaunchingPlayback = false + } } - private var taskIdentifier: String { + func playbackPresentationPlaceholder( + url: URL, + onURLChanged: @escaping (_ newURL: URL) -> Void, + onSelectionRestored: @escaping () -> Void + ) -> some View { + videoBackgroundColor + .ignoresSafeArea() + .allowsHitTesting(false) + .onAppear { + onSelectionRestored() + } + .onChange(of: url) { _, newURL in + onURLChanged(newURL) + } + .onChange(of: isSelected) { _, selected in + guard selected else { + return + } + + onSelectionRestored() + } + } +} + +// MARK: - Loading + +private extension NCVideoViewerContentView { + var taskIdentifier: String { let localIdentifier = localURL?.absoluteString ?? "remote" return "\(metadata.ocId)|\(metadata.etag)|\(localIdentifier)" } - // Single entry point for selected video loading. @MainActor - private func loadVideoIfSelected() async { + func stopPlaybackForDeselection() { + resetPlaybackPresentationState() + + NCVideoAVPlayerPresenter.dismiss() + NCVideoVLCPresenter.dismiss() + playback.stop() + } + + @MainActor + func loadVideoIfSelected() async { let expectedTaskIdentifier = taskIdentifier let expectedLoadGeneration = loadGeneration @@ -208,9 +301,8 @@ struct NCVideoViewerContentView: View { ) } - // Avoid loading transient pages during fast swipes. @MainActor - private func waitForStableSelection( + func waitForStableSelection( expectedTaskIdentifier: String, expectedLoadGeneration: UUID ) async -> Bool { @@ -240,7 +332,7 @@ struct NCVideoViewerContentView: View { } @MainActor - private func resolveAndLoadVideo( + func resolveAndLoadVideo( expectedTaskIdentifier: String, expectedLoadGeneration: UUID ) async { @@ -291,7 +383,7 @@ struct NCVideoViewerContentView: View { } @MainActor - private func loadResolvedVideo( + func loadResolvedVideo( url: URL, autoplay: Bool, expectedTaskIdentifier: String, @@ -309,7 +401,7 @@ struct NCVideoViewerContentView: View { return } - resolvedVideoURL = url + hasRequestedPlayback = false playback.loadVideo( metadata: metadata, @@ -321,7 +413,7 @@ struct NCVideoViewerContentView: View { ) } - private func httpHeaders(for url: URL) -> [String: String] { + func httpHeaders(for url: URL) -> [String: String] { guard !url.isFileURL else { return [:] } @@ -335,11 +427,12 @@ struct NCVideoViewerContentView: View { "User-Agent": userAgent ] } +} - // MARK: - Playback Selection +// MARK: - Playback Selection - // Loading or failed engines are not reusable. - private func isCurrentPlaybackVideo() -> Bool { +private extension NCVideoViewerContentView { + func isCurrentPlaybackVideo() -> Bool { switch playback.engine { case .avFoundation, .vlc: @@ -364,9 +457,12 @@ struct NCVideoViewerContentView: View { ) } - // Reveal without changing play/pause state. @MainActor - private func revealCurrentPlaybackIfNeeded() { + func revealCurrentPlaybackIfNeeded() { + guard hasRequestedPlayback else { + return + } + switch playback.engine { case .avFoundation(let url): presentAVPlayerIfSelected(url: url) @@ -379,111 +475,40 @@ struct NCVideoViewerContentView: View { break } } +} - @MainActor - private func presentAVPlayerIfSelected(url: URL) { - guard isSelected else { - return - } - - guard presentedAVPlayerURL != url else { - return - } - - presentedAVPlayerURL = url - - NCVideoAVPlayerPresenter.present( - metadata: metadata, - url: url, - userAgent: userAgent, - contextMenuController: contextMenuController, - canGoPrevious: canGoPrevious, - canGoNext: canGoNext, - onPrevious: goToPreviousPageFromAVPlayer, - onNext: goToNextPageFromAVPlayer, - onClose: closeFromFullscreenVideo - ) - - NCVideoFullscreenTransitionOverlay.hide() - } - - @MainActor - private func goToPreviousPageFromAVPlayer() { - NCVideoFullscreenTransitionOverlay.show() - presentedAVPlayerURL = nil - NCVideoAVPlayerPresenter.dismiss() - onPreviousPage?() - NCVideoFullscreenTransitionOverlay.hideAfterDelay() - } +// MARK: - Fullscreen Playback State +extension NCVideoViewerContentView { @MainActor - private func goToNextPageFromAVPlayer() { - NCVideoFullscreenTransitionOverlay.show() - presentedAVPlayerURL = nil - NCVideoAVPlayerPresenter.dismiss() - onNextPage?() - NCVideoFullscreenTransitionOverlay.hideAfterDelay() + func closeFromFullscreenVideo(ocId: String?) { + resetPlaybackPresentationState() } @MainActor - private func closeFromFullscreenVideo(ocId: String?) { + func resetPlaybackPresentationState() { presentedAVPlayerURL = nil presentedVLCURL = nil - playback.stop() - NCVideoFullscreenTransitionOverlay.hide() - onClose?(ocId) + hasRequestedPlayback = false + isLaunchingPlayback = false } @MainActor - private func presentVLCIfSelected(url: URL) { - guard isSelected else { - return - } - - guard presentedVLCURL != url else { - return - } - - presentedVLCURL = url - - NCVideoVLCPresenter.present( - metadata: metadata, - url: url, - userAgent: userAgent, - contextMenuController: contextMenuController, - canGoPrevious: canGoPrevious, - canGoNext: canGoNext, - onPrevious: goToPreviousPageFromVLC, - onNext: goToNextPageFromVLC, - onClose: closeFromFullscreenVideo - ) - - NCVideoFullscreenTransitionOverlay.hide() - } - - @MainActor - private func goToPreviousPageFromVLC() { - NCVideoFullscreenTransitionOverlay.show() - presentedVLCURL = nil - NCVideoVLCPresenter.dismiss() - onPreviousPage?() - NCVideoFullscreenTransitionOverlay.hideAfterDelay() - } - - @MainActor - private func goToNextPageFromVLC() { - NCVideoFullscreenTransitionOverlay.show() - presentedVLCURL = nil - NCVideoVLCPresenter.dismiss() - onNextPage?() - NCVideoFullscreenTransitionOverlay.hideAfterDelay() + func performFullscreenPageTransition( + dismissPlayer: @escaping () -> Void, + changePage: @escaping () -> Void + ) { + resetPlaybackPresentationState() + dismissPlayer() + changePage() } +} - // MARK: - In-Flight Resolution Cache +// MARK: - URL Resolution - // Share direct-link resolution between duplicated SwiftUI page instances. +private extension NCVideoViewerContentView { @MainActor - private func resolvedVideoURL( + func resolvedVideoURL( taskIdentifier: String ) async -> (url: URL?, autoplay: Bool, error: NKError) { if let existingTask = Self.resolvingTasks[taskIdentifier] { @@ -501,10 +526,12 @@ struct NCVideoViewerContentView: View { return result } +} - // MARK: - Helpers +// MARK: - Helpers - private var resolvedFileName: String { +private extension NCVideoViewerContentView { + var resolvedFileName: String { if !metadata.fileNameView.isEmpty { return metadata.fileNameView } @@ -512,150 +539,3 @@ struct NCVideoViewerContentView: View { return metadata.fileName } } - -// MARK: - Fullscreen Video Transition Overlay - -@MainActor -private enum NCVideoFullscreenTransitionOverlay { - private static weak var overlayView: UIView? - private static var hideTask: Task? - - static func show() { - hideTask?.cancel() - - guard let window = keyWindow else { - return - } - - let overlayView = overlayView ?? makeOverlayView(in: window) - window.bringSubviewToFront(overlayView) - overlayView.frame = window.bounds - overlayView.alpha = 1 - overlayView.isHidden = false - } - - static func hide() { - hideTask?.cancel() - hideTask = nil - - overlayView?.removeFromSuperview() - overlayView = nil - } - - static func hideAfterDelay() { - hideTask?.cancel() - hideTask = Task { @MainActor in - try? await Task.sleep(for: .milliseconds(100)) - hide() - } - } - - private static func makeOverlayView(in window: UIWindow) -> UIView { - let view = UIView(frame: window.bounds) - view.backgroundColor = .black - view.isUserInteractionEnabled = false - view.autoresizingMask = [ - .flexibleWidth, - .flexibleHeight - ] - window.addSubview(view) - overlayView = view - return view - } - - private static var keyWindow: UIWindow? { - UIApplication.shared.connectedScenes - .compactMap { $0 as? UIWindowScene } - .filter { $0.activationState == .foregroundActive } - .flatMap { $0.windows } - .first { $0.isKeyWindow } - } -} - -// MARK: - Video URL Resolution - -struct NCVideoURLResolver { - private let utilityFileSystem = NCUtilityFileSystem() - - func getVideoURL( - metadata: tableMetadata - ) async -> (url: URL?, autoplay: Bool, error: NKError) { - if !metadata.url.isEmpty { - if metadata.url.hasPrefix("/") { - return ( - url: URL(fileURLWithPath: metadata.url), - autoplay: true, - error: .success - ) - } else { - return ( - url: URL(string: metadata.url), - autoplay: true, - error: .success - ) - } - } - - if utilityFileSystem.fileProviderStorageExists(metadata) { - let localPath = utilityFileSystem.getDirectoryProviderStorageOcId( - metadata.ocId, - fileName: metadata.fileNameView, - userId: metadata.userId, - urlBase: metadata.urlBase - ) - - return ( - url: URL(fileURLWithPath: localPath), - autoplay: true, - error: .success - ) - } - - return await getDirectDownloadURL(metadata: metadata) - } - - private func getDirectDownloadURL( - metadata: tableMetadata - ) async -> (url: URL?, autoplay: Bool, error: NKError) { - await withCheckedContinuation { continuation in - NextcloudKit.shared.getDirectDownload( - fileId: metadata.fileId, - account: metadata.account - ) { task in - Task { - let identifier = await NCNetworking.shared.networkingTasks.createIdentifier( - account: metadata.account, - path: metadata.fileId, - name: "getDirectDownload" - ) - - await NCNetworking.shared.networkingTasks.track( - identifier: identifier, - task: task - ) - } - } completion: { _, urlString, _, error in - guard error == .success, - let urlString, - let url = URL(string: urlString) else { - continuation.resume( - returning: ( - url: nil, - autoplay: false, - error: error - ) - ) - return - } - - continuation.resume( - returning: ( - url: url, - autoplay: false, - error: error - ) - ) - } - } - } -} diff --git a/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCPresenter.swift b/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCPresenter.swift index 55883ae56a..0ad982db4d 100644 --- a/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCPresenter.swift +++ b/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCPresenter.swift @@ -20,6 +20,8 @@ enum NCVideoVLCPresenter { metadata: tableMetadata, url: URL, userAgent: String?, + shouldAutoPlay: Bool = true, + isChromeHidden: Bool = false, contextMenuController: NCMainTabBarController?, canGoPrevious: Bool = false, canGoNext: Bool = false, @@ -33,6 +35,8 @@ enum NCVideoVLCPresenter { metadata: metadata, url: url, userAgent: userAgent, + shouldAutoPlay: shouldAutoPlay, + isChromeHidden: isChromeHidden, contextMenuController: contextMenuController ) currentViewController.onPrevious = onPrevious @@ -52,6 +56,8 @@ enum NCVideoVLCPresenter { metadata: metadata, url: url, userAgent: userAgent, + shouldAutoPlay: shouldAutoPlay, + isChromeHidden: isChromeHidden, contextMenuController: contextMenuController ) currentViewController.onPrevious = onPrevious @@ -89,6 +95,8 @@ enum NCVideoVLCPresenter { metadata: metadata, url: url, userAgent: userAgent, + shouldAutoPlay: shouldAutoPlay, + isChromeHidden: isChromeHidden, contextMenuController: contextMenuController ) viewController.onPrevious = onPrevious @@ -105,7 +113,6 @@ enum NCVideoVLCPresenter { ) navigationController.modalPresentationStyle = .fullScreen - navigationController.modalTransitionStyle = .crossDissolve navigationController.navigationBar.prefersLargeTitles = false navigationController.navigationBar.barStyle = .black navigationController.navigationBar.tintColor = .white diff --git a/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCViewController.swift b/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCViewController.swift index 9f6b4f0461..483b7102e2 100644 --- a/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCViewController.swift +++ b/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCViewController.swift @@ -18,6 +18,8 @@ final class NCVideoVLCViewController: UIViewController { private var metadata: tableMetadata private var url: URL private var userAgent: String? + private var shouldAutoPlay: Bool + private var isChromeHidden: Bool private weak var contextMenuController: NCMainTabBarController? // MARK: - Paging Callbacks @@ -92,11 +94,15 @@ final class NCVideoVLCViewController: UIViewController { metadata: tableMetadata, url: URL, userAgent: String?, + shouldAutoPlay: Bool = true, + isChromeHidden: Bool = false, contextMenuController: NCMainTabBarController? ) { self.metadata = metadata self.url = url self.userAgent = userAgent + self.shouldAutoPlay = shouldAutoPlay + self.isChromeHidden = isChromeHidden self.contextMenuController = contextMenuController super.init( @@ -105,7 +111,6 @@ final class NCVideoVLCViewController: UIViewController { ) modalPresentationStyle = .fullScreen - modalTransitionStyle = .crossDissolve } required init?(coder: NSCoder) { @@ -121,12 +126,14 @@ final class NCVideoVLCViewController: UIViewController { // MARK: - Lifecycle override func loadView() { + let backgroundColor = viewerBackgroundColor + let rootView = UIView() - rootView.backgroundColor = .black + rootView.backgroundColor = backgroundColor rootView.isOpaque = true rootView.clipsToBounds = true - drawableView.backgroundColor = .black + drawableView.backgroundColor = backgroundColor drawableView.isOpaque = true drawableView.clipsToBounds = true drawableView.translatesAutoresizingMaskIntoConstraints = false @@ -160,7 +167,7 @@ final class NCVideoVLCViewController: UIViewController { override func viewDidLoad() { super.viewDidLoad() - view.backgroundColor = .black + view.backgroundColor = viewerBackgroundColor configureNavigationItem() updateTitleLabel(metadata: metadata) @@ -210,6 +217,8 @@ final class NCVideoVLCViewController: UIViewController { metadata: tableMetadata, url: URL, userAgent: String?, + shouldAutoPlay: Bool = true, + isChromeHidden: Bool = false, contextMenuController: NCMainTabBarController? ) { let urlChanged = self.url != url @@ -221,7 +230,10 @@ final class NCVideoVLCViewController: UIViewController { self.metadata = metadata self.url = url self.userAgent = userAgent + self.shouldAutoPlay = shouldAutoPlay + self.isChromeHidden = isChromeHidden self.contextMenuController = contextMenuController + updateViewerBackgroundIfNeeded() updateTitleLabel(metadata: metadata) refreshVLCTrackMenuItemsWhenPlayerIsActive() @@ -234,6 +246,25 @@ final class NCVideoVLCViewController: UIViewController { updatePlayPauseButton() } + private var viewerBackgroundColor: UIColor { + UIColor.ncViewerBackground( + ncViewerBackgroundStyle( + for: metadata, + isChromeHidden: isChromeHidden + ) + ) + } + + private func updateViewerBackgroundIfNeeded() { + guard !controlsVisible else { + return + } + + let backgroundColor = viewerBackgroundColor + view.backgroundColor = backgroundColor + drawableView.backgroundColor = backgroundColor + } + // MARK: - Navigation private func configureNavigationItem() { @@ -489,6 +520,11 @@ final class NCVideoVLCViewController: UIViewController { } mediaPlayer.media = media + + if shouldAutoPlay { + mediaPlayer.play() + } + updatePlayPauseButton() updateProgressControls() clearVLCTrackMenuItems() @@ -515,9 +551,15 @@ final class NCVideoVLCViewController: UIViewController { return } + if let currentDrawable = mediaPlayer.drawable as? UIView, + currentDrawable === drawableView { + return + } + mediaPlayer.drawable = drawableView } + private func handleMediaPlayerStateChange() { updatePlayPauseButton() updateProgressControls() diff --git a/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoViewerContentView+VLC.swift b/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoViewerContentView+VLC.swift new file mode 100644 index 0000000000..605622608c --- /dev/null +++ b/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoViewerContentView+VLC.swift @@ -0,0 +1,64 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Marino Faggiana +// SPDX-License-Identifier: GPL-3.0-or-later + +import Foundation + +extension NCVideoViewerContentView { + @MainActor + func requestVLCPresentation(url: URL) { + hasRequestedPlayback = true + presentVLCIfSelected(url: url) + } + + @MainActor + func presentVLCIfSelected(url: URL) { + guard isSelected else { + return + } + + guard presentedVLCURL != url else { + return + } + + presentedVLCURL = url + + NCVideoVLCPresenter.present( + metadata: metadata, + url: url, + userAgent: userAgent, + shouldAutoPlay: true, + isChromeHidden: isChromeHidden, + contextMenuController: contextMenuController, + canGoPrevious: canGoPrevious, + canGoNext: canGoNext, + onPrevious: goToPreviousPageFromVLC, + onNext: goToNextPageFromVLC, + onClose: closeFromFullscreenVideo + ) + } + + @MainActor + func goToPreviousPageFromVLC() { + performFullscreenPageTransition( + dismissPlayer: { + NCVideoVLCPresenter.dismiss() + }, + changePage: { + onPreviousPage?() + } + ) + } + + @MainActor + func goToNextPageFromVLC() { + performFullscreenPageTransition( + dismissPlayer: { + NCVideoVLCPresenter.dismiss() + }, + changePage: { + onNextPage?() + } + ) + } +} diff --git a/iOSClient/Viewer/NCViewerMedia/Core/NCMediaViewerModel.swift b/iOSClient/Viewer/NCViewerMedia/Core/NCMediaViewerModel.swift index c0909bc878..e613adf33a 100644 --- a/iOSClient/Viewer/NCViewerMedia/Core/NCMediaViewerModel.swift +++ b/iOSClient/Viewer/NCViewerMedia/Core/NCMediaViewerModel.swift @@ -14,7 +14,7 @@ enum NCMediaViewerPageState { case checkingLocalFile case image(previewURL: URL?, localURL: URL?, livePhotoURL: URL?, progress: Double?) case audio(localURL: URL, previewURL: URL?) - case video(localURL: URL?) + case video(localURL: URL?, previewURL: URL?) case downloading(previewURL: URL?, progress: Double?) case ready(localURL: URL, previewURL: URL?) case deleted @@ -414,8 +414,24 @@ final class NCMediaViewerModel: ObservableObject { ) async { switch metadata.classFile { case NKTypeClassFile.video.rawValue: + var videoPreviewURL = previewURL + + if videoPreviewURL == nil { + videoPreviewURL = await loader.previewURL( + for: metadata, + index: index + ) + + guard !Task.isCancelled else { + return + } + } + setState( - .video(localURL: localURL), + .video( + localURL: localURL, + previewURL: videoPreviewURL + ), for: ocId ) @@ -477,8 +493,8 @@ final class NCMediaViewerModel: ObservableObject { ) async { var previewURL = previewURL - if metadata.classFile == NKTypeClassFile.image.rawValue, - previewURL == nil { + if previewURL == nil, + shouldLoadPreview(for: metadata) { previewURL = await loader.previewURL( for: metadata, index: index @@ -492,7 +508,10 @@ final class NCMediaViewerModel: ObservableObject { switch metadata.classFile { case NKTypeClassFile.video.rawValue: setState( - .video(localURL: nil), + .video( + localURL: nil, + previewURL: previewURL + ), for: ocId ) return @@ -643,8 +662,7 @@ final class NCMediaViewerModel: ObservableObject { let previewURL: URL? - if metadata.classFile == NKTypeClassFile.image.rawValue || - metadata.classFile == NKTypeClassFile.audio.rawValue { + if shouldLoadPreview(for: metadata) { previewURL = await loader.previewURL( for: metadata, index: index @@ -681,7 +699,10 @@ final class NCMediaViewerModel: ObservableObject { } setState( - .video(localURL: localURL), + .video( + localURL: localURL, + previewURL: previewURL + ), for: ocId ) return @@ -726,6 +747,7 @@ final class NCMediaViewerModel: ObservableObject { return previewURL case .audio(_, let previewURL), + .video(_, let previewURL), .ready(_, let previewURL), .failed(let previewURL, _): return previewURL @@ -733,13 +755,24 @@ final class NCMediaViewerModel: ObservableObject { case .idle, .loadingMetadata, .metadataMissing, - .video, .deleted, .checkingLocalFile: return nil } } + private func shouldLoadPreview(for metadata: tableMetadata) -> Bool { + switch metadata.classFile { + case NKTypeClassFile.image.rawValue, + NKTypeClassFile.audio.rawValue, + NKTypeClassFile.video.rawValue: + return true + + default: + return false + } + } + private func setMetadata(_ metadata: tableMetadata, for ocId: String) { updatePage(ocId: ocId) { page in page.metadata = metadata @@ -782,7 +815,10 @@ final class NCMediaViewerModel: ObservableObject { ) } else if metadata.classFile == NKTypeClassFile.video.rawValue { setState( - .video(localURL: localURL), + .video( + localURL: localURL, + previewURL: previewURL + ), for: ocId ) } else if metadata.classFile == NKTypeClassFile.audio.rawValue { @@ -906,6 +942,9 @@ private extension NCMediaViewerPageState { case .downloading: return true + case .video(nil, nil): + return true + case .image(_, .some, _, _), .audio, .video, diff --git a/iOSClient/Viewer/NCViewerMedia/Helpers/NCMediaViewerAppearance.swift b/iOSClient/Viewer/NCViewerMedia/Helpers/NCMediaViewerAppearance.swift index 7ba1e186c6..ddd2ec1fd7 100644 --- a/iOSClient/Viewer/NCViewerMedia/Helpers/NCMediaViewerAppearance.swift +++ b/iOSClient/Viewer/NCViewerMedia/Helpers/NCMediaViewerAppearance.swift @@ -54,18 +54,17 @@ extension Color { // MARK: - Viewer Background Resolution func ncViewerBackgroundStyle(for metadata: tableMetadata?) -> NCViewerBackgroundStyle { - guard let metadata else { - return .system - } + .system +} - switch metadata.classFile { - case NKTypeClassFile.image.rawValue: - return .system - case NKTypeClassFile.video.rawValue: +// MARK: - Viewer Chrome-Aware Background Resolution +func ncViewerBackgroundStyle( + for metadata: tableMetadata?, + isChromeHidden: Bool +) -> NCViewerBackgroundStyle { + if isChromeHidden { return .black - case NKTypeClassFile.audio.rawValue: - return .system - default: - return .system } + + return ncViewerBackgroundStyle(for: metadata) } diff --git a/iOSClient/Viewer/NCViewerMedia/Views/NCMediaViewerPageView.swift b/iOSClient/Viewer/NCViewerMedia/Views/NCMediaViewerPageView.swift index 122aeebdac..254b254a49 100644 --- a/iOSClient/Viewer/NCViewerMedia/Views/NCMediaViewerPageView.swift +++ b/iOSClient/Viewer/NCViewerMedia/Views/NCMediaViewerPageView.swift @@ -51,8 +51,11 @@ struct NCMediaViewerPageView: View { livePhotoURL: livePhotoURL ) - case .video(let localURL): - videoStateView(localURL: localURL) + case .video(let localURL, let previewURL): + videoStateView( + localURL: localURL, + previewURL: previewURL + ) case .audio(let localURL, let previewURL): audioStateView( @@ -90,22 +93,10 @@ struct NCMediaViewerPageView: View { } private var backgroundStyle: NCViewerBackgroundStyle { - if isChromeHidden { - return .black - } - - guard let metadata = page.metadata else { - return .system - } - - switch metadata.classFile { - case NKTypeClassFile.audio.rawValue, - NKTypeClassFile.video.rawValue: - return .black - - default: - return ncViewerBackgroundStyle(for: metadata) - } + ncViewerBackgroundStyle( + for: page.metadata, + isChromeHidden: isChromeHidden + ) } // Neighbor pages must not consume auto-play. @@ -202,18 +193,24 @@ struct NCMediaViewerPageView: View { } @ViewBuilder - private func videoStateView(localURL: URL?) -> some View { + private func videoStateView( + localURL: URL?, + previewURL: URL? + ) -> some View { if let metadata = page.metadata { NCVideoViewerContentView( metadata: metadata, localURL: localURL, + previewURL: previewURL, isSelected: isSelected, + isChromeHidden: isChromeHidden, contextMenuController: contextMenuController, navigationBar: navigationBar, canGoPrevious: canGoPrevious, canGoNext: canGoNext, onPreviousPage: goToPreviousPageFromVideo, onNextPage: goToNextPageFromVideo, + onToggleChrome: onToggleChrome, onClose: onClose ) .id("\(page.ocId)-remote") @@ -254,7 +251,10 @@ struct NCMediaViewerPageView: View { switch page.metadata?.classFile { case NKTypeClassFile.video.rawValue: if isSelected { - videoStateView(localURL: nil) + videoStateView( + localURL: nil, + previewURL: previewURL + ) } else { Color.ncViewerBackground(backgroundStyle) .ignoresSafeArea() @@ -279,13 +279,28 @@ struct NCMediaViewerPageView: View { localURL: URL, previewURL: URL? ) -> some View { - if page.metadata != nil { - imageContentView( - previewURL: previewURL, - localURL: localURL, - livePhotoURL: nil, - backgroundStyle: backgroundStyle - ) + if let metadata = page.metadata { + switch metadata.classFile { + case NKTypeClassFile.video.rawValue: + videoStateView( + localURL: localURL, + previewURL: previewURL + ) + + case NKTypeClassFile.audio.rawValue: + audioStateView( + localURL: localURL, + previewURL: previewURL + ) + + default: + imageContentView( + previewURL: previewURL, + localURL: localURL, + livePhotoURL: nil, + backgroundStyle: backgroundStyle + ) + } } else { metadataMissingView } diff --git a/iOSClient/Viewer/NCViewerMedia/Views/NCMediaViewerPagingView.swift b/iOSClient/Viewer/NCViewerMedia/Views/NCMediaViewerPagingView.swift index 1b22ff1788..ba4a3729ec 100644 --- a/iOSClient/Viewer/NCViewerMedia/Views/NCMediaViewerPagingView.swift +++ b/iOSClient/Viewer/NCViewerMedia/Views/NCMediaViewerPagingView.swift @@ -218,24 +218,12 @@ final class NCMediaViewerPagingCoordinator: NSObject, // MARK: - Background private func backgroundColor(for page: NCMediaViewerPageModel?) -> UIColor { - guard !model.isChromeHidden else { - return .black - } - - guard let metadata = page?.metadata else { - return UIColor.ncViewerBackground(.system) - } - - switch metadata.classFile { - case NKTypeClassFile.audio.rawValue, - NKTypeClassFile.video.rawValue: - return .black - - default: - return UIColor.ncViewerBackground( - ncViewerBackgroundStyle(for: metadata) + UIColor.ncViewerBackground( + ncViewerBackgroundStyle( + for: page?.metadata, + isChromeHidden: model.isChromeHidden ) - } + ) } func updateCollectionBackground(for index: Int? = nil) { @@ -375,8 +363,7 @@ final class NCMediaViewerPagingCoordinator: NSObject, // 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. Keep the fullscreen transition overlay in - // sync when this is used for video navigation. + // audio, AVPlayer, and VLC pages. NotificationCenter.default.post( name: .ncMediaViewerStopPlayback, object: nil @@ -491,10 +478,9 @@ final class NCMediaViewerPagingCoordinator: NSObject, func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { isUserPaging = true - // 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. Keep the fullscreen transition overlay in - // sync when this is used for video navigation. + // Stop the current media playback before manual page navigation. + // This is intentionally broad because dragging can move across image, + // audio, AVPlayer, and VLC pages. NotificationCenter.default.post( name: .ncMediaViewerStopPlayback, object: nil From d4d31351f3454a3e74cad5e78bd072c8eb379d08 Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Fri, 29 May 2026 09:26:36 +0200 Subject: [PATCH 41/54] Documentation Signed-off-by: Marino Faggiana --- .../NCMediaViewerPresenter.swift | 54 ++++++++++++------- 1 file changed, 35 insertions(+), 19 deletions(-) diff --git a/iOSClient/Viewer/NCViewerMedia/NCMediaViewerPresenter.swift b/iOSClient/Viewer/NCViewerMedia/NCMediaViewerPresenter.swift index 02992d33bc..12153850dc 100644 --- a/iOSClient/Viewer/NCViewerMedia/NCMediaViewerPresenter.swift +++ b/iOSClient/Viewer/NCViewerMedia/NCMediaViewerPresenter.swift @@ -25,8 +25,8 @@ import UIKit /// /// 4. `NCMediaViewerModel` /// Central state coordinator. Owns the selected index, visible page window, -/// page states, metadata cache, prefetching, and routes media into image, -/// audio, video, or generic states. +/// page states, metadata cache, prefetching, autoplay requests, and routes +/// media into image, audio, video, or generic states. /// /// 5. `NCNextcloudMediaViewerLoader` /// Loader layer. Resolves metadata, preview URLs, local media URLs, full media @@ -34,49 +34,65 @@ import UIKit /// /// 6. `NCMediaViewerPagingView` /// UIKit-backed horizontal pager hosted from SwiftUI. Owns the collection view, -/// paging coordinator, visible cells, selected index updates, and page navigation. +/// paging coordinator, visible cells, selected index updates, page navigation, +/// and chrome-aware page background updates. /// /// 7. `NCMediaViewerPageView` -/// Per-page SwiftUI renderer. Switches on `NCMediaViewerPageState` and routes -/// each page to the correct content view. +/// Per-page SwiftUI renderer. Switches on `NCMediaViewerPageState`, applies +/// the chrome-aware background style, and routes each page to the correct +/// content view. /// -/// 8. Image flow: +/// 8. Appearance flow: +/// `NCMediaViewerAppearance` centralizes viewer background resolution. +/// The normal viewer background follows the system appearance. When chrome is +/// hidden, the viewer enters cinema mode and uses a black background. +/// +/// 9. Image flow: /// `NCMediaViewerPageView` /// -> `NCImageViewerContentView` /// -> `NCImageZoomView` /// -> `NCLivePhotoViewerContentView` when Live Photo data is available. /// -/// 9. Audio flow: -/// `NCMediaViewerPageView` -/// -> `NCAudioViewerContentView`. -/// Audio playback stays inside SwiftUI and uses a local media URL plus an -/// optional preview image as artwork. +/// 10. Audio flow: +/// `NCMediaViewerPageView` +/// -> `NCAudioViewerContentView`. +/// Audio playback stays inside SwiftUI and uses a local media URL plus an +/// optional preview image as artwork. /// -/// 10. Video flow: +/// 11. Video SwiftUI flow: /// `NCMediaViewerPageView` /// -> `NCVideoViewerContentView` +/// -> `NCVideoPlaybackCoverView` +/// -> `NCVideoURLResolver` /// -> `NCVideoPlaybackController`. -/// The video content view is only the SwiftUI trigger/bridge for fullscreen -/// playback. It resolves the playback URL and asks the playback controller to -/// choose the engine. +/// The video content view is the SwiftUI trigger/bridge for fullscreen +/// playback. It displays the preview cover, resolves the playback URL, and +/// asks the playback controller to choose the engine. /// -/// 11. `NCVideoPlaybackController` +/// 12. `NCVideoPlaybackController` /// Chooses the playback engine. It tries AVFoundation when possible and falls /// back to VLC for unsupported or legacy formats. /// -/// 12. AVPlayer flow: +/// 13. AVPlayer flow: /// `NCVideoPlaybackController` +/// -> `NCVideoViewerContentView+AVPlayer` /// -> `NCVideoAVPlayerPresenter` /// -> `NCVideoAVPlayerViewController` /// -> `NCVideoControlsView` / `NCVideoAVPlayerViewControls`. +/// AVPlayer uses the shared controls view and updates its background according +/// to chrome visibility: system appearance when controls are visible, black +/// cinema mode when controls are hidden. /// -/// 13. VLC flow: +/// 14. VLC flow: /// `NCVideoPlaybackController` +/// -> `NCVideoViewerContentView+VLC` /// -> `NCVideoVLCPresenter` /// -> `NCVideoVLCViewController` /// -> `NCVideoControlsView` / `NCVideoVLCViewControls`. +/// VLC uses the same presentation structure as AVPlayer, while the VLC renderer +/// may still draw its own black surface during playback initialization. /// -/// 14. Detail flow: +/// 15. Detail flow: /// `NCMediaViewerHostingController` /// -> `NCMediaViewerDetailView`. /// Displays file information, camera/lens metadata, EXIF values, and location. From c4118f38e3e3a5fddd0a2ff38931f2a5a15df0b3 Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Fri, 29 May 2026 09:26:49 +0200 Subject: [PATCH 42/54] clean Signed-off-by: Marino Faggiana --- .../Content/Video/VLC/NCVideoVLCViewController.swift | 1 - 1 file changed, 1 deletion(-) diff --git a/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCViewController.swift b/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCViewController.swift index 483b7102e2..6bd0b222cf 100644 --- a/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCViewController.swift +++ b/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCViewController.swift @@ -559,7 +559,6 @@ final class NCVideoVLCViewController: UIViewController { mediaPlayer.drawable = drawableView } - private func handleMediaPlayerStateChange() { updatePlayPauseButton() updateProgressControls() From c1f5c9ad491e0125a9d1b301b127adc5097a6a5d Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Fri, 29 May 2026 09:58:28 +0200 Subject: [PATCH 43/54] Remove artificial video selection debounce before preparing playback Signed-off-by: Marino Faggiana --- .../Video/NCVideoViewerContentView.swift | 18 ++++++------------ 1 file changed, 6 insertions(+), 12 deletions(-) diff --git a/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoViewerContentView.swift b/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoViewerContentView.swift index bfafbd180b..dd13b465e6 100644 --- a/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoViewerContentView.swift +++ b/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoViewerContentView.swift @@ -281,7 +281,7 @@ private extension NCVideoViewerContentView { let expectedTaskIdentifier = taskIdentifier let expectedLoadGeneration = loadGeneration - guard await waitForStableSelection( + guard isStableSelection( expectedTaskIdentifier: expectedTaskIdentifier, expectedLoadGeneration: expectedLoadGeneration ) else { @@ -302,21 +302,15 @@ private extension NCVideoViewerContentView { } @MainActor - func waitForStableSelection( + func isStableSelection( expectedTaskIdentifier: String, expectedLoadGeneration: UUID - ) async -> Bool { - guard isSelected else { - return false - } - - do { - try await Task.sleep(for: .milliseconds(150)) - } catch { + ) -> Bool { + guard !Task.isCancelled else { return false } - guard !Task.isCancelled else { + guard isSelected else { return false } @@ -328,7 +322,7 @@ private extension NCVideoViewerContentView { return false } - return isSelected + return true } @MainActor From 91ebb8ec90cedad743c293bd6ced56d5260e140d Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Fri, 29 May 2026 10:38:23 +0200 Subject: [PATCH 44/54] fix autolpay button state Signed-off-by: Marino Faggiana --- .../NCVideoAVPlayerViewController.swift | 50 ++++++++++++++++--- 1 file changed, 42 insertions(+), 8 deletions(-) diff --git a/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoAVPlayerViewController.swift b/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoAVPlayerViewController.swift index ace7d3b776..098e0af01b 100644 --- a/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoAVPlayerViewController.swift +++ b/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoAVPlayerViewController.swift @@ -80,13 +80,14 @@ final class NCVideoAVPlayerViewController: UIViewController { private var playbackEndObserver: NSObjectProtocol? private var timeObserverToken: Any? private var preparedURL: URL? + internal var isPlaybackRequested = false var isPictureInPictureActive: Bool { pictureInPictureController?.isPictureInPictureActive == true } internal var shouldKeepControlsVisible: Bool { - player.timeControlStatus != .playing + player.timeControlStatus != .playing && !isPlaybackRequested } internal func setNavigationBarVisible( @@ -551,6 +552,8 @@ final class NCVideoAVPlayerViewController: UIViewController { // MARK: - Playback private func start() { + isPlaybackRequested = shouldAutoPlay + guard preparedURL != url else { updatePlayPauseButton() updateProgressControls() @@ -559,6 +562,7 @@ final class NCVideoAVPlayerViewController: UIViewController { } preparedURL = url + updatePlayPauseButton() let item = AVPlayerItem(asset: makeAsset()) @@ -570,17 +574,21 @@ final class NCVideoAVPlayerViewController: UIViewController { updatePlayPauseButton() updateProgressControls() updateSeekingState() - } private func stop() { preparedURL = nil + isPlaybackRequested = false + player.pause() cleanupObservers() player.replaceCurrentItem(with: nil) + playerContainerView.player = nil + pictureInPictureController?.delegate = nil pictureInPictureController = nil + updatePlayPauseButton() updateProgressControls() } @@ -707,16 +715,20 @@ final class NCVideoAVPlayerViewController: UIViewController { private func handleCurrentItemStatusChange() { updateProgressControls() - updatePlayPauseButton() updateSeekingState() guard player.currentItem?.status == .readyToPlay else { + updatePlayPauseButton() return } if shouldAutoPlay, player.timeControlStatus != .playing { + isPlaybackRequested = true + updatePlayPauseButton() player.play() + } else { + updatePlayPauseButton() } if !controlsVisible, @@ -727,11 +739,29 @@ final class NCVideoAVPlayerViewController: UIViewController { } private func handleTimeControlStatusChange() { + switch player.timeControlStatus { + case .playing, + .waitingToPlayAtSpecifiedRate: + isPlaybackRequested = true + + case .paused: + if player.currentItem?.status == .readyToPlay || + player.currentItem?.status == .failed || + player.currentItem == nil { + isPlaybackRequested = false + } + + @unknown default: + break + } + updatePlayPauseButton() guard player.timeControlStatus == .playing else { - showControls(animated: false) - stopControlsHideTimer() + if !isPlaybackRequested { + showControls(animated: false) + stopControlsHideTimer() + } return } @@ -741,6 +771,8 @@ final class NCVideoAVPlayerViewController: UIViewController { } private func handlePlaybackEnded() { + isPlaybackRequested = false + updatePlayPauseButton() updateProgressControls() showControls(animated: true) @@ -789,9 +821,11 @@ final class NCVideoAVPlayerViewController: UIViewController { } internal func updatePlayPauseButton() { - controlsView.updatePlayPauseButton( - isPlaying: player.timeControlStatus == .playing - ) + let isPlaying = player.timeControlStatus == .playing || + player.timeControlStatus == .waitingToPlayAtSpecifiedRate || + isPlaybackRequested + + controlsView.updatePlayPauseButton(isPlaying: isPlaying) } internal func updateProgressControls() { From 497b220f9b75975455f3193e0003b5adccd45706 Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Fri, 29 May 2026 10:40:51 +0200 Subject: [PATCH 45/54] Align VLC play pause button state with requested playback state Signed-off-by: Marino Faggiana --- .../Video/VLC/NCVideoVLCViewController.swift | 28 ++++++++++++++++--- .../Video/VLC/NCVideoVLCViewControls.swift | 16 +++++++++-- 2 files changed, 37 insertions(+), 7 deletions(-) diff --git a/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCViewController.swift b/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCViewController.swift index 6bd0b222cf..1598d7b321 100644 --- a/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCViewController.swift +++ b/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCViewController.swift @@ -54,10 +54,11 @@ final class NCVideoVLCViewController: UIViewController { internal var controlsHideTimer: Timer? internal var controlsVisible = false internal var isScrubbing = false + internal var isPlaybackRequested = false private weak var closePanGesture: UIPanGestureRecognizer? internal var shouldKeepControlsVisible: Bool { - mediaPlayer.state != .playing && !mediaPlayer.isPlaying + mediaPlayer.state != .playing && !mediaPlayer.isPlaying && !isPlaybackRequested } internal func setNavigationBarVisible( @@ -509,6 +510,7 @@ final class NCVideoVLCViewController: UIViewController { // MARK: - Playback private func start() { + isPlaybackRequested = shouldAutoPlay attachDrawable() let media = VLCMedia(url: url) @@ -520,6 +522,7 @@ final class NCVideoVLCViewController: UIViewController { } mediaPlayer.media = media + updatePlayPauseButton() if shouldAutoPlay { mediaPlayer.play() @@ -531,10 +534,11 @@ final class NCVideoVLCViewController: UIViewController { startProgressTimer() showControls(animated: false) stopControlsHideTimer() - } private func stop() { + isPlaybackRequested = false + mediaPlayer.stop() mediaPlayer.media = nil mediaPlayer.drawable = nil @@ -560,13 +564,29 @@ final class NCVideoVLCViewController: UIViewController { } private func handleMediaPlayerStateChange() { + switch mediaPlayer.state { + case .playing: + isPlaybackRequested = true + + case .paused, + .stopped, + .ended, + .error: + isPlaybackRequested = false + + default: + break + } + updatePlayPauseButton() updateProgressControls() refreshVLCTrackMenuItemsWhenPlayerIsActive() guard mediaPlayer.state == .playing else { - showControls(animated: false) - stopControlsHideTimer() + if !isPlaybackRequested { + showControls(animated: false) + stopControlsHideTimer() + } return } diff --git a/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCViewControls.swift b/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCViewControls.swift index 768c735e7e..5ae0690174 100644 --- a/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCViewControls.swift +++ b/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCViewControls.swift @@ -24,7 +24,13 @@ extension NCVideoVLCViewController { } func updatePlayPauseButton() { - controlsView.updatePlayPauseButton(isPlaying: mediaPlayer.isPlaying) + let isPlaying = mediaPlayer.isPlaying || + mediaPlayer.state == .opening || + mediaPlayer.state == .buffering || + mediaPlayer.state == .playing || + isPlaybackRequested + + controlsView.updatePlayPauseButton(isPlaying: isPlaying) } func startProgressTimer() { @@ -177,15 +183,19 @@ extension NCVideoVLCViewController: NCVideoControlsViewDelegate { func videoControlsDidTapPlayPause(_ controlsView: NCVideoControlsView) { showControls(animated: true) - if mediaPlayer.isPlaying { + if mediaPlayer.isPlaying || mediaPlayer.state == .playing { + isPlaybackRequested = false mediaPlayer.pause() + updatePlayPauseButton() showControls(animated: false) stopControlsHideTimer() } else { + isPlaybackRequested = true + updatePlayPauseButton() mediaPlayer.play() + scheduleControlsHide() } - updatePlayPauseButton() updateProgressControls() } From a5ac7fbed19e6e0f1ab8f354ac6b5a495c0f2af0 Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Fri, 29 May 2026 11:04:31 +0200 Subject: [PATCH 46/54] fix color Signed-off-by: Marino Faggiana --- .../Video/AVPlayer/NCVideoAVPlayerViewController.swift | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoAVPlayerViewController.swift b/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoAVPlayerViewController.swift index 098e0af01b..9d5366b6db 100644 --- a/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoAVPlayerViewController.swift +++ b/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoAVPlayerViewController.swift @@ -210,9 +210,15 @@ final class NCVideoAVPlayerViewController: UIViewController { override func viewDidAppear(_ animated: Bool) { super.viewDidAppear(animated) + let shouldPreserveHiddenChromeBackground = isChromeHidden + start() showControls(animated: false) stopControlsHideTimer() + + if shouldPreserveHiddenChromeBackground { + updateViewerBackground(isChromeHidden: true) + } } override func viewDidLayoutSubviews() { From 4f878a9c0546f7fd532a76f484d23fd3905bd162 Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Fri, 29 May 2026 11:50:06 +0200 Subject: [PATCH 47/54] fix Signed-off-by: Marino Faggiana --- .../NCVideoAVPlayerViewController.swift | 24 ++++++++++++------- .../Video/NCVideoViewerContentView.swift | 2 +- 2 files changed, 17 insertions(+), 9 deletions(-) diff --git a/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoAVPlayerViewController.swift b/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoAVPlayerViewController.swift index 9d5366b6db..f53ea8ba26 100644 --- a/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoAVPlayerViewController.swift +++ b/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoAVPlayerViewController.swift @@ -401,26 +401,34 @@ final class NCVideoAVPlayerViewController: UIViewController { } func close() { - stopControlsHideTimer() - stop() + let closeCallback = onClose + let controllerToDismiss = navigationController ?? self NCVideoAVPlayerPresenter.clearCurrent(self) - dismiss(animated: false) { [onClose, metadata] in + controllerToDismiss.dismiss(animated: false) { [weak self] in + self?.stopControlsHideTimer() + self?.stop() + DispatchQueue.main.async { - onClose?(metadata.ocId) + closeCallback?(nil) } } } func closeImmediately() { - stopControlsHideTimer() - stop() + let closeCallback = onClose + let controllerToDismiss = navigationController ?? self NCVideoAVPlayerPresenter.clearCurrent(self) - dismiss(animated: false) { [onClose] in - onClose?(nil) + controllerToDismiss.dismiss(animated: false) { [weak self] in + self?.stopControlsHideTimer() + self?.stop() + + DispatchQueue.main.async { + closeCallback?(nil) + } } } diff --git a/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoViewerContentView.swift b/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoViewerContentView.swift index dd13b465e6..49ddd255ff 100644 --- a/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoViewerContentView.swift +++ b/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoViewerContentView.swift @@ -476,7 +476,7 @@ private extension NCVideoViewerContentView { extension NCVideoViewerContentView { @MainActor func closeFromFullscreenVideo(ocId: String?) { - resetPlaybackPresentationState() + onClose?(ocId) } @MainActor From 10609bc4172d25d86a91dee46425b7be56230174 Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Fri, 29 May 2026 12:01:27 +0200 Subject: [PATCH 48/54] close fix Signed-off-by: Marino Faggiana --- .../NCVideoAVPlayerViewController.swift | 3 +- .../Video/VLC/NCVideoVLCViewController.swift | 29 ++++++++++++------- 2 files changed, 21 insertions(+), 11 deletions(-) diff --git a/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoAVPlayerViewController.swift b/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoAVPlayerViewController.swift index f53ea8ba26..520e377fdd 100644 --- a/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoAVPlayerViewController.swift +++ b/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoAVPlayerViewController.swift @@ -402,6 +402,7 @@ final class NCVideoAVPlayerViewController: UIViewController { func close() { let closeCallback = onClose + let closingOcId = metadata.ocId let controllerToDismiss = navigationController ?? self NCVideoAVPlayerPresenter.clearCurrent(self) @@ -411,7 +412,7 @@ final class NCVideoAVPlayerViewController: UIViewController { self?.stop() DispatchQueue.main.async { - closeCallback?(nil) + closeCallback?(closingOcId) } } } diff --git a/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCViewController.swift b/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCViewController.swift index 1598d7b321..43e43835f4 100644 --- a/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCViewController.swift +++ b/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCViewController.swift @@ -368,28 +368,37 @@ final class NCVideoVLCViewController: UIViewController { } func close() { - stopControlsHideTimer() - stopProgressTimer() - stop() + let closeCallback = onClose + let closingOcId = metadata.ocId + let controllerToDismiss = navigationController ?? self NCVideoVLCPresenter.clearCurrent(self) - dismiss(animated: false) { [onClose, metadata] in + controllerToDismiss.dismiss(animated: false) { [weak self] in + self?.stopControlsHideTimer() + self?.stopProgressTimer() + self?.stop() + DispatchQueue.main.async { - onClose?(metadata.ocId) + closeCallback?(closingOcId) } } } func closeImmediately() { - stopControlsHideTimer() - stopProgressTimer() - stop() + let closeCallback = onClose + let controllerToDismiss = navigationController ?? self NCVideoVLCPresenter.clearCurrent(self) - dismiss(animated: false) { [onClose] in - onClose?(nil) + controllerToDismiss.dismiss(animated: false) { [weak self] in + self?.stopControlsHideTimer() + self?.stopProgressTimer() + self?.stop() + + DispatchQueue.main.async { + closeCallback?(nil) + } } } From 3088c67bf0eee1565d98436a818f5c83ecd94d75 Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Fri, 29 May 2026 16:16:05 +0200 Subject: [PATCH 49/54] Video Playback Engine Signed-off-by: Marino Faggiana --- .../AVPlayer/NCVideoAVPlayerPresenter.swift | 17 ++-- .../NCVideoAVPlayerViewController.swift | 61 ++++++-------- .../NCVideoViewerContentView+AVPlayer.swift | 14 ++-- .../Video/NCVideoPlaybackController.swift | 79 ++++++++++++------- .../Video/NCVideoViewerContentView.swift | 42 +++++----- .../Video/VLC/NCVideoVLCPresenter.swift | 17 ++-- .../Video/VLC/NCVideoVLCViewController.swift | 37 ++++----- .../VLC/NCVideoViewerContentView+VLC.swift | 14 ++-- 8 files changed, 142 insertions(+), 139 deletions(-) diff --git a/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoAVPlayerPresenter.swift b/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoAVPlayerPresenter.swift index 9823397ef3..5b56f828de 100644 --- a/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoAVPlayerPresenter.swift +++ b/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoAVPlayerPresenter.swift @@ -18,9 +18,9 @@ enum NCVideoAVPlayerPresenter { // Presents or updates the single AVPlayer fullscreen controller. static func present( metadata: tableMetadata, - url: URL, + preparedPlayback: NCVideoAVPreparedPlayback, userAgent: String?, - shouldAutoPlay: Bool = true, + shouldAutoPlayOnStart: Bool = true, isChromeHidden: Bool = false, contextMenuController: NCMainTabBarController?, canGoPrevious: Bool = false, @@ -29,13 +29,14 @@ enum NCVideoAVPlayerPresenter { onNext: (() -> Void)? = nil, onClose: ((_ ocId: String?) -> Void)? = nil ) { + let url = preparedPlayback.url if currentURL == url, let currentViewController { currentViewController.update( metadata: metadata, - url: url, + preparedPlayback: preparedPlayback, userAgent: userAgent, - shouldAutoPlay: shouldAutoPlay, + shouldAutoPlayOnStart: shouldAutoPlayOnStart, isChromeHidden: isChromeHidden, contextMenuController: contextMenuController ) @@ -55,9 +56,9 @@ enum NCVideoAVPlayerPresenter { if let currentViewController { currentViewController.update( metadata: metadata, - url: url, + preparedPlayback: preparedPlayback, userAgent: userAgent, - shouldAutoPlay: shouldAutoPlay, + shouldAutoPlayOnStart: shouldAutoPlayOnStart, isChromeHidden: isChromeHidden, contextMenuController: contextMenuController ) @@ -94,9 +95,9 @@ enum NCVideoAVPlayerPresenter { let viewController = NCVideoAVPlayerViewController( metadata: metadata, - url: url, + preparedPlayback: preparedPlayback, userAgent: userAgent, - shouldAutoPlay: shouldAutoPlay, + shouldAutoPlayOnStart: shouldAutoPlayOnStart, isChromeHidden: isChromeHidden, contextMenuController: contextMenuController ) diff --git a/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoAVPlayerViewController.swift b/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoAVPlayerViewController.swift index 520e377fdd..3b3f1f97dd 100644 --- a/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoAVPlayerViewController.swift +++ b/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoAVPlayerViewController.swift @@ -36,9 +36,10 @@ final class NCVideoAVPlayerViewController: UIViewController { // MARK: - Input private var metadata: tableMetadata + private var preparedPlayback: NCVideoAVPreparedPlayback private var url: URL private var userAgent: String? - private var shouldAutoPlay: Bool + private var shouldAutoPlayOnStart: Bool private var isChromeHidden: Bool private weak var contextMenuController: NCMainTabBarController? @@ -67,7 +68,7 @@ final class NCVideoAVPlayerViewController: UIViewController { // MARK: - AVPlayer - internal let player = AVPlayer() + internal var player: AVPlayer internal var controlsHideTimer: Timer? internal var controlsVisible = false @@ -122,16 +123,18 @@ final class NCVideoAVPlayerViewController: UIViewController { init( metadata: tableMetadata, - url: URL, + preparedPlayback: NCVideoAVPreparedPlayback, userAgent: String?, - shouldAutoPlay: Bool = true, + shouldAutoPlayOnStart: Bool = true, isChromeHidden: Bool = false, contextMenuController: NCMainTabBarController? ) { self.metadata = metadata - self.url = url + self.preparedPlayback = preparedPlayback + self.url = preparedPlayback.url + self.player = preparedPlayback.player self.userAgent = userAgent - self.shouldAutoPlay = shouldAutoPlay + self.shouldAutoPlayOnStart = shouldAutoPlayOnStart self.isChromeHidden = isChromeHidden self.contextMenuController = contextMenuController @@ -251,22 +254,24 @@ final class NCVideoAVPlayerViewController: UIViewController { func update( metadata: tableMetadata, - url: URL, + preparedPlayback: NCVideoAVPreparedPlayback, userAgent: String?, - shouldAutoPlay: Bool = true, + shouldAutoPlayOnStart: Bool = true, isChromeHidden: Bool = false, contextMenuController: NCMainTabBarController? ) { - let urlChanged = self.url != url + let urlChanged = self.url != preparedPlayback.url if urlChanged { stop() + self.preparedPlayback = preparedPlayback + self.url = preparedPlayback.url + self.player = preparedPlayback.player } self.metadata = metadata - self.url = url self.userAgent = userAgent - self.shouldAutoPlay = shouldAutoPlay + self.shouldAutoPlayOnStart = shouldAutoPlayOnStart self.contextMenuController = contextMenuController updateViewerBackground(isChromeHidden: isChromeHidden) updateTitleLabel(metadata: metadata) @@ -567,7 +572,7 @@ final class NCVideoAVPlayerViewController: UIViewController { // MARK: - Playback private func start() { - isPlaybackRequested = shouldAutoPlay + isPlaybackRequested = shouldAutoPlayOnStart guard preparedURL != url else { updatePlayPauseButton() @@ -577,15 +582,17 @@ final class NCVideoAVPlayerViewController: UIViewController { } preparedURL = url - updatePlayPauseButton() - - let item = AVPlayerItem(asset: makeAsset()) - - player.replaceCurrentItem(with: item) playerContainerView.player = player + updatePlayPauseButton() configureObservers() configurePictureInPicture() + + if shouldAutoPlayOnStart, + player.timeControlStatus != .playing { + player.play() + } + updatePlayPauseButton() updateProgressControls() updateSeekingState() @@ -597,7 +604,6 @@ final class NCVideoAVPlayerViewController: UIViewController { player.pause() cleanupObservers() - player.replaceCurrentItem(with: nil) playerContainerView.player = nil @@ -608,23 +614,6 @@ final class NCVideoAVPlayerViewController: UIViewController { updateProgressControls() } - private func makeAsset() -> AVURLAsset { - guard let userAgent, - !userAgent.isEmpty, - !url.isFileURL else { - return AVURLAsset(url: url) - } - - return AVURLAsset( - url: url, - options: [ - "AVURLAssetHTTPHeaderFieldsKey": [ - "User-Agent": userAgent - ] - ] - ) - } - private func configurePlayerLayer() { playerContainerView.playerLayer.videoGravity = .resizeAspect playerContainerView.player = player @@ -737,7 +726,7 @@ final class NCVideoAVPlayerViewController: UIViewController { return } - if shouldAutoPlay, + if shouldAutoPlayOnStart, player.timeControlStatus != .playing { isPlaybackRequested = true updatePlayPauseButton() diff --git a/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoViewerContentView+AVPlayer.swift b/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoViewerContentView+AVPlayer.swift index 5ea7c349b1..fb6528ff10 100644 --- a/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoViewerContentView+AVPlayer.swift +++ b/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoViewerContentView+AVPlayer.swift @@ -6,28 +6,28 @@ import Foundation extension NCVideoViewerContentView { @MainActor - func requestAVPlayerPresentation(url: URL) { + func requestAVPlayerPresentation(preparedPlayback: NCVideoAVPreparedPlayback) { hasRequestedPlayback = true - presentAVPlayerIfSelected(url: url) + presentAVPlayerIfSelected(preparedPlayback: preparedPlayback) } @MainActor - func presentAVPlayerIfSelected(url: URL) { + func presentAVPlayerIfSelected(preparedPlayback: NCVideoAVPreparedPlayback) { guard isSelected else { return } - guard presentedAVPlayerURL != url else { + guard presentedAVPlayerURL != preparedPlayback.url else { return } - presentedAVPlayerURL = url + presentedAVPlayerURL = preparedPlayback.url NCVideoAVPlayerPresenter.present( metadata: metadata, - url: url, + preparedPlayback: preparedPlayback, userAgent: userAgent, - shouldAutoPlay: true, + shouldAutoPlayOnStart: true, isChromeHidden: isChromeHidden, contextMenuController: contextMenuController, canGoPrevious: canGoPrevious, diff --git a/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoPlaybackController.swift b/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoPlaybackController.swift index 79223091eb..50cda8d7a9 100644 --- a/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoPlaybackController.swift +++ b/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoPlaybackController.swift @@ -4,14 +4,26 @@ import AVFoundation import Foundation +import MobileVLCKit import NextcloudKit // MARK: - Video Playback Engine +struct NCVideoAVPreparedPlayback { + let url: URL + let player: AVPlayer + let item: AVPlayerItem +} + +struct NCVideoVLCPreparedPlayback { + let url: URL + let media: VLCMedia +} + enum NCVideoPlaybackEngine { case loading - case avFoundation(url: URL) - case vlc(url: URL) + case avFoundation(preparedPlayback: NCVideoAVPreparedPlayback) + case vlc(preparedPlayback: NCVideoVLCPreparedPlayback) case failed(message: String) } @@ -66,14 +78,12 @@ final class NCVideoPlaybackController: ObservableObject { url: URL, fileName: String, userAgent: String?, - httpHeaders: [String: String], - shouldAutoPlay: Bool + httpHeaders: [String: String] ) { if isSameLoadedVideo( metadata: metadata, url: url ) { - resumeCurrentPlaybackIfNeeded(shouldAutoPlay: shouldAutoPlay) return } @@ -101,6 +111,7 @@ final class NCVideoPlaybackController: ObservableObject { ) { resolveWithVLC( url: url, + userAgent: userAgent, token: token ) return @@ -109,8 +120,8 @@ final class NCVideoPlaybackController: ObservableObject { prepareAVFoundation( metadata: metadata, url: url, + userAgent: userAgent, httpHeaders: url.isFileURL ? [:] : httpHeaders, - shouldAutoPlay: shouldAutoPlay, token: token ) } @@ -122,7 +133,7 @@ final class NCVideoPlaybackController: ObservableObject { stop() } - // Releases AVFoundation resources; VLC is owned by its view controller. + // Releases the current prepared playback state and pending AVFoundation probes. func stop() { loadToken = UUID() @@ -146,8 +157,8 @@ final class NCVideoPlaybackController: ObservableObject { private func prepareAVFoundation( metadata: tableMetadata, url: URL, + userAgent: String?, httpHeaders: [String: String], - shouldAutoPlay: Bool, token: UUID ) { let assetOptions: [String: Any]? = httpHeaders.isEmpty @@ -190,13 +201,14 @@ final class NCVideoPlaybackController: ObservableObject { self.resolveWithAVFoundation( url: url, player: player, - shouldAutoPlay: shouldAutoPlay, + item: item, token: token ) case .failed: self.resolveWithVLC( url: url, + userAgent: userAgent, token: token ) @@ -206,6 +218,7 @@ final class NCVideoPlaybackController: ObservableObject { @unknown default: self.resolveWithVLC( url: url, + userAgent: userAgent, token: token ) } @@ -216,21 +229,32 @@ final class NCVideoPlaybackController: ObservableObject { private func resolveWithAVFoundation( url: URL, player: AVPlayer, - shouldAutoPlay: Bool, + item: AVPlayerItem, token: UUID ) { guard loadToken == token, - avProbePlayer === player else { + avProbePlayer === player, + avProbeItem === item else { return } - engine = .avFoundation(url: url) + statusObservation?.invalidate() + statusObservation = nil + + let preparedPlayback = NCVideoAVPreparedPlayback( + url: url, + player: player, + item: item + ) + + engine = .avFoundation(preparedPlayback: preparedPlayback) } // MARK: - VLC private func resolveWithVLC( url: URL, + userAgent: String?, token: UUID ) { guard isCurrentLoad( @@ -247,7 +271,20 @@ final class NCVideoPlaybackController: ObservableObject { avProbePlayer = nil avProbeItem = nil - engine = .vlc(url: url) + let media = VLCMedia(url: url) + + if let userAgent, + !userAgent.isEmpty, + !url.isFileURL { + media.addOption(":http-user-agent=\(userAgent)") + } + + let preparedPlayback = NCVideoVLCPreparedPlayback( + url: url, + media: media + ) + + engine = .vlc(preparedPlayback: preparedPlayback) } // MARK: - State Helpers @@ -268,22 +305,6 @@ final class NCVideoPlaybackController: ObservableObject { loadToken == token && currentURL == url } - private func resumeCurrentPlaybackIfNeeded(shouldAutoPlay: Bool) { - guard shouldAutoPlay else { - return - } - - switch engine { - case .avFoundation: - break - - case .vlc, - .loading, - .failed: - break - } - } - // MARK: - Private Helpers private func configureAudioSession() { diff --git a/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoViewerContentView.swift b/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoViewerContentView.swift index 49ddd255ff..0bb3b9514c 100644 --- a/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoViewerContentView.swift +++ b/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoViewerContentView.swift @@ -137,34 +137,34 @@ private extension NCVideoViewerContentView { .ignoresSafeArea() .allowsHitTesting(false) - case .avFoundation(let url): + case .avFoundation(let preparedPlayback): if isSelected, isCurrentPlaybackVideo() { playbackPresentationPlaceholder( - url: url, - onURLChanged: { newURL in + url: preparedPlayback.url, + onURLChanged: { _ in presentedAVPlayerURL = nil - presentAVPlayerIfSelected(url: newURL) + presentAVPlayerIfSelected(preparedPlayback: preparedPlayback) }, onSelectionRestored: { - presentAVPlayerIfSelected(url: url) + presentAVPlayerIfSelected(preparedPlayback: preparedPlayback) } ) } else { EmptyView() } - case .vlc(let url): + case .vlc(let preparedPlayback): if isSelected, isCurrentPlaybackVideo() { playbackPresentationPlaceholder( - url: url, - onURLChanged: { newURL in + url: preparedPlayback.url, + onURLChanged: { _ in presentedVLCURL = nil - presentVLCIfSelected(url: newURL) + presentVLCIfSelected(preparedPlayback: preparedPlayback) }, onSelectionRestored: { - presentVLCIfSelected(url: url) + presentVLCIfSelected(preparedPlayback: preparedPlayback) } ) } else { @@ -223,11 +223,11 @@ private extension NCVideoViewerContentView { isLaunchingPlayback = true switch playback.engine { - case .avFoundation(let url): - requestAVPlayerPresentation(url: url) + case .avFoundation(let preparedPlayback): + requestAVPlayerPresentation(preparedPlayback: preparedPlayback) - case .vlc(let url): - requestVLCPresentation(url: url) + case .vlc(let preparedPlayback): + requestVLCPresentation(preparedPlayback: preparedPlayback) case .loading, .failed: @@ -335,7 +335,6 @@ private extension NCVideoViewerContentView { if let localURL { loadResolvedVideo( url: localURL, - autoplay: true, expectedTaskIdentifier: expectedTaskIdentifier, expectedLoadGeneration: expectedLoadGeneration ) @@ -370,7 +369,6 @@ private extension NCVideoViewerContentView { loadResolvedVideo( url: url, - autoplay: result.autoplay, expectedTaskIdentifier: expectedTaskIdentifier, expectedLoadGeneration: expectedLoadGeneration ) @@ -379,7 +377,6 @@ private extension NCVideoViewerContentView { @MainActor func loadResolvedVideo( url: URL, - autoplay: Bool, expectedTaskIdentifier: String, expectedLoadGeneration: UUID ) { @@ -402,8 +399,7 @@ private extension NCVideoViewerContentView { url: url, fileName: resolvedFileName, userAgent: userAgent, - httpHeaders: httpHeaders(for: url), - shouldAutoPlay: autoplay + httpHeaders: httpHeaders(for: url) ) } @@ -458,11 +454,11 @@ private extension NCVideoViewerContentView { } switch playback.engine { - case .avFoundation(let url): - presentAVPlayerIfSelected(url: url) + case .avFoundation(let preparedPlayback): + presentAVPlayerIfSelected(preparedPlayback: preparedPlayback) - case .vlc(let url): - presentVLCIfSelected(url: url) + case .vlc(let preparedPlayback): + presentVLCIfSelected(preparedPlayback: preparedPlayback) case .loading, .failed: diff --git a/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCPresenter.swift b/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCPresenter.swift index 0ad982db4d..d03666ac88 100644 --- a/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCPresenter.swift +++ b/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCPresenter.swift @@ -18,9 +18,9 @@ enum NCVideoVLCPresenter { // Presents or updates the single VLC fullscreen controller. static func present( metadata: tableMetadata, - url: URL, + preparedPlayback: NCVideoVLCPreparedPlayback, userAgent: String?, - shouldAutoPlay: Bool = true, + shouldAutoPlayOnStart: Bool = true, isChromeHidden: Bool = false, contextMenuController: NCMainTabBarController?, canGoPrevious: Bool = false, @@ -29,13 +29,14 @@ enum NCVideoVLCPresenter { onNext: (() -> Void)? = nil, onClose: ((_ ocId: String?) -> Void)? = nil ) { + let url = preparedPlayback.url if currentURL == url, let currentViewController { currentViewController.update( metadata: metadata, - url: url, + preparedPlayback: preparedPlayback, userAgent: userAgent, - shouldAutoPlay: shouldAutoPlay, + shouldAutoPlayOnStart: shouldAutoPlayOnStart, isChromeHidden: isChromeHidden, contextMenuController: contextMenuController ) @@ -54,9 +55,9 @@ enum NCVideoVLCPresenter { if let currentViewController { currentViewController.update( metadata: metadata, - url: url, + preparedPlayback: preparedPlayback, userAgent: userAgent, - shouldAutoPlay: shouldAutoPlay, + shouldAutoPlayOnStart: shouldAutoPlayOnStart, isChromeHidden: isChromeHidden, contextMenuController: contextMenuController ) @@ -93,9 +94,9 @@ enum NCVideoVLCPresenter { let viewController = NCVideoVLCViewController( metadata: metadata, - url: url, + preparedPlayback: preparedPlayback, userAgent: userAgent, - shouldAutoPlay: shouldAutoPlay, + shouldAutoPlayOnStart: shouldAutoPlayOnStart, isChromeHidden: isChromeHidden, contextMenuController: contextMenuController ) diff --git a/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCViewController.swift b/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCViewController.swift index 43e43835f4..6ff70ef012 100644 --- a/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCViewController.swift +++ b/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCViewController.swift @@ -16,9 +16,10 @@ final class NCVideoVLCViewController: UIViewController { // MARK: - Input private var metadata: tableMetadata + private var preparedPlayback: NCVideoVLCPreparedPlayback private var url: URL private var userAgent: String? - private var shouldAutoPlay: Bool + private var shouldAutoPlayOnStart: Bool private var isChromeHidden: Bool private weak var contextMenuController: NCMainTabBarController? @@ -93,16 +94,17 @@ final class NCVideoVLCViewController: UIViewController { init( metadata: tableMetadata, - url: URL, + preparedPlayback: NCVideoVLCPreparedPlayback, userAgent: String?, - shouldAutoPlay: Bool = true, + shouldAutoPlayOnStart: Bool = true, isChromeHidden: Bool = false, contextMenuController: NCMainTabBarController? ) { self.metadata = metadata - self.url = url + self.preparedPlayback = preparedPlayback + self.url = preparedPlayback.url self.userAgent = userAgent - self.shouldAutoPlay = shouldAutoPlay + self.shouldAutoPlayOnStart = shouldAutoPlayOnStart self.isChromeHidden = isChromeHidden self.contextMenuController = contextMenuController @@ -216,22 +218,23 @@ final class NCVideoVLCViewController: UIViewController { func update( metadata: tableMetadata, - url: URL, + preparedPlayback: NCVideoVLCPreparedPlayback, userAgent: String?, - shouldAutoPlay: Bool = true, + shouldAutoPlayOnStart: Bool = true, isChromeHidden: Bool = false, contextMenuController: NCMainTabBarController? ) { - let urlChanged = self.url != url + let urlChanged = self.url != preparedPlayback.url if urlChanged { stop() + self.preparedPlayback = preparedPlayback + self.url = preparedPlayback.url } self.metadata = metadata - self.url = url self.userAgent = userAgent - self.shouldAutoPlay = shouldAutoPlay + self.shouldAutoPlayOnStart = shouldAutoPlayOnStart self.isChromeHidden = isChromeHidden self.contextMenuController = contextMenuController updateViewerBackgroundIfNeeded() @@ -519,21 +522,13 @@ final class NCVideoVLCViewController: UIViewController { // MARK: - Playback private func start() { - isPlaybackRequested = shouldAutoPlay + isPlaybackRequested = shouldAutoPlayOnStart attachDrawable() - let media = VLCMedia(url: url) - - if let userAgent, - !userAgent.isEmpty, - !url.isFileURL { - media.addOption(":http-user-agent=\(userAgent)") - } - - mediaPlayer.media = media + mediaPlayer.media = preparedPlayback.media updatePlayPauseButton() - if shouldAutoPlay { + if shouldAutoPlayOnStart { mediaPlayer.play() } diff --git a/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoViewerContentView+VLC.swift b/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoViewerContentView+VLC.swift index 605622608c..dd4e7aa5e8 100644 --- a/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoViewerContentView+VLC.swift +++ b/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoViewerContentView+VLC.swift @@ -6,28 +6,28 @@ import Foundation extension NCVideoViewerContentView { @MainActor - func requestVLCPresentation(url: URL) { + func requestVLCPresentation(preparedPlayback: NCVideoVLCPreparedPlayback) { hasRequestedPlayback = true - presentVLCIfSelected(url: url) + presentVLCIfSelected(preparedPlayback: preparedPlayback) } @MainActor - func presentVLCIfSelected(url: URL) { + func presentVLCIfSelected(preparedPlayback: NCVideoVLCPreparedPlayback) { guard isSelected else { return } - guard presentedVLCURL != url else { + guard presentedVLCURL != preparedPlayback.url else { return } - presentedVLCURL = url + presentedVLCURL = preparedPlayback.url NCVideoVLCPresenter.present( metadata: metadata, - url: url, + preparedPlayback: preparedPlayback, userAgent: userAgent, - shouldAutoPlay: true, + shouldAutoPlayOnStart: true, isChromeHidden: isChromeHidden, contextMenuController: contextMenuController, canGoPrevious: canGoPrevious, From a00f01e20adab7ebcee55382d0d8d05054d05ffa Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Fri, 29 May 2026 16:19:01 +0200 Subject: [PATCH 50/54] fix Signed-off-by: Marino Faggiana --- iOSClient/Supporting Files/en.lproj/Localizable.strings | 1 + 1 file changed, 1 insertion(+) diff --git a/iOSClient/Supporting Files/en.lproj/Localizable.strings b/iOSClient/Supporting Files/en.lproj/Localizable.strings index 18d9ca61bd..d7b137435c 100644 --- a/iOSClient/Supporting Files/en.lproj/Localizable.strings +++ b/iOSClient/Supporting Files/en.lproj/Localizable.strings @@ -695,6 +695,7 @@ "_svg_file_could_not_be_rendered_" = "SVG file could not be rendered"; "_image_file_could_not_be_decoded_" = "Image file could not be decoded"; "_media_not_available_" = "Media not available"; +"_no_assistant_installed_" = "Assistant is not installed on this server. Ask your administrator to install the Assistant app"; // Tip "_tip_pdf_thumbnails_" = "Swipe left from the right edge of the screen to show the thumbnails"; From f24816541fe46b26326e30f94b40547494dec635 Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Sat, 30 May 2026 07:40:08 +0200 Subject: [PATCH 51/54] fix Signed-off-by: Marino Faggiana --- .../Video/VLC/NCVideoVLCViewController.swift | 32 +++++++++++++------ 1 file changed, 22 insertions(+), 10 deletions(-) diff --git a/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCViewController.swift b/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCViewController.swift index 6ff70ef012..725d2dfbb2 100644 --- a/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCViewController.swift +++ b/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCViewController.swift @@ -637,20 +637,30 @@ final class NCVideoVLCViewController: UIViewController { func selectSubtitleTrack(index: Int32) { mediaPlayer.currentVideoSubTitleIndex = index + NCManageDatabase.shared.addVideo( metadata: metadata, currentVideoSubTitleIndex: Int(index) ) - refreshVLCTrackMenuItems() + + Task { @MainActor [weak self] in + try? await Task.sleep(for: .milliseconds(200)) + self?.refreshVLCTrackMenuItemsWhenPlayerIsActive() + } } func selectAudioTrack(index: Int32) { mediaPlayer.currentAudioTrackIndex = index + NCManageDatabase.shared.addVideo( metadata: metadata, currentAudioTrackIndex: Int(index) ) - refreshVLCTrackMenuItems() + + Task { @MainActor [weak self] in + try? await Task.sleep(for: .milliseconds(200)) + self?.refreshVLCTrackMenuItemsWhenPlayerIsActive() + } } func presentExternalSubtitlePicker() { @@ -769,21 +779,23 @@ final class NCVideoVLCViewController: UIViewController { } private func currentSubtitleTrackIndex() -> Int? { - if let data = NCManageDatabase.shared.getVideo(metadata: metadata), - let currentVideoSubTitleIndex = data.currentVideoSubTitleIndex { - return currentVideoSubTitleIndex + let playerIndex = Int(mediaPlayer.currentVideoSubTitleIndex) + + if playerIndex >= 0 { + return playerIndex } - return Int(mediaPlayer.currentVideoSubTitleIndex) + return NCManageDatabase.shared.getVideo(metadata: metadata)?.currentVideoSubTitleIndex } private func currentAudioTrackIndex() -> Int? { - if let data = NCManageDatabase.shared.getVideo(metadata: metadata), - let currentAudioTrackIndex = data.currentAudioTrackIndex { - return currentAudioTrackIndex + let playerIndex = Int(mediaPlayer.currentAudioTrackIndex) + + if playerIndex >= 0 { + return playerIndex } - return Int(mediaPlayer.currentAudioTrackIndex) + return NCManageDatabase.shared.getVideo(metadata: metadata)?.currentAudioTrackIndex } private func makeTrackMenuItems( From 0691d4cffd18830377aeb070fefd5c50fcf9ba93 Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Sat, 30 May 2026 08:34:59 +0200 Subject: [PATCH 52/54] Prefetch local audio pages before selection Signed-off-by: Marino Faggiana --- .../Core/NCMediaViewerModel.swift | 34 +++++++++++++++++-- 1 file changed, 31 insertions(+), 3 deletions(-) diff --git a/iOSClient/Viewer/NCViewerMedia/Core/NCMediaViewerModel.swift b/iOSClient/Viewer/NCViewerMedia/Core/NCMediaViewerModel.swift index e613adf33a..2808edefa8 100644 --- a/iOSClient/Viewer/NCViewerMedia/Core/NCMediaViewerModel.swift +++ b/iOSClient/Viewer/NCViewerMedia/Core/NCMediaViewerModel.swift @@ -709,13 +709,41 @@ final class NCMediaViewerModel: ObservableObject { } if metadata.classFile == NKTypeClassFile.audio.rawValue { + let localURL = await loader.localMediaURL( + for: metadata, + index: index + ) + + guard !Task.isCancelled else { + return + } + + guard let localURL else { + setState( + .downloading( + previewURL: previewURL, + progress: nil + ), + for: ocId + ) + return + } + setState( - .downloading( - previewURL: previewURL, - progress: nil + .audio( + localURL: localURL, + previewURL: previewURL ), for: ocId ) + + await loadAudioPreviewIfNeeded( + metadata: metadata, + localURL: localURL, + currentPreviewURL: previewURL, + for: ocId, + index: index + ) return } } From cfb1f7145e14207eeee1040a3186e4553e32de60 Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Sat, 30 May 2026 08:39:16 +0200 Subject: [PATCH 53/54] cleaning Signed-off-by: Marino Faggiana --- .../Viewer/NCViewerMedia/Core/NCMediaViewerModel.swift | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/iOSClient/Viewer/NCViewerMedia/Core/NCMediaViewerModel.swift b/iOSClient/Viewer/NCViewerMedia/Core/NCMediaViewerModel.swift index 2808edefa8..3baca12859 100644 --- a/iOSClient/Viewer/NCViewerMedia/Core/NCMediaViewerModel.swift +++ b/iOSClient/Viewer/NCViewerMedia/Core/NCMediaViewerModel.swift @@ -964,17 +964,20 @@ private extension NCMediaViewerPageState { case .idle: return true - case .image(_, nil, _, _): + case .downloading: return true - case .downloading: + case .image(_, nil, _, _): return true case .video(nil, nil): return true + case .audio(_, nil): + return true + case .image(_, .some, _, _), - .audio, + .audio(_, .some), .video, .loadingMetadata, .metadataMissing, From db8411bc118b3cb6511d5cb901bbe97151b1c81e Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Sun, 31 May 2026 17:39:59 +0200 Subject: [PATCH 54/54] AirPlay Signed-off-by: Marino Faggiana --- .../NCVideoAVPlayerViewController.swift | 6 ++++ .../Content/Video/NCVideoControlsView.swift | 30 ++++++++++++++++++- 2 files changed, 35 insertions(+), 1 deletion(-) diff --git a/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoAVPlayerViewController.swift b/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoAVPlayerViewController.swift index 3b3f1f97dd..ef1b790e77 100644 --- a/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoAVPlayerViewController.swift +++ b/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoAVPlayerViewController.swift @@ -585,6 +585,7 @@ final class NCVideoAVPlayerViewController: UIViewController { playerContainerView.player = player updatePlayPauseButton() + configureExternalPlayback() configureObservers() configurePictureInPicture() @@ -619,6 +620,11 @@ final class NCVideoAVPlayerViewController: UIViewController { playerContainerView.player = player } + private func configureExternalPlayback() { + player.allowsExternalPlayback = true + player.usesExternalPlaybackWhileExternalScreenIsActive = true + } + private func configurePictureInPicture() { guard AVPictureInPictureController.isPictureInPictureSupported() else { controlsView.setTopActionsMode(.none) diff --git a/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoControlsView.swift b/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoControlsView.swift index 2ffd0184de..49a1a59040 100644 --- a/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoControlsView.swift +++ b/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoControlsView.swift @@ -2,6 +2,7 @@ // SPDX-FileCopyrightText: 2026 Marino Faggiana // SPDX-License-Identifier: GPL-3.0-or-later +import AVKit import SwiftUI import UIKit @@ -403,7 +404,7 @@ private struct NCVideoControlsSwiftUIView: View { case .none: visibleButtonsCount = 0 case .pictureInPicture: - visibleButtonsCount = 1 + visibleButtonsCount = 2 case .vlcTracks: visibleButtonsCount = 2 } @@ -501,6 +502,15 @@ private struct NCVideoControlsSwiftUIView: View { } .buttonStyle(.plain) + NCVideoAirPlayRoutePickerView() + .frame( + width: NCVideoControlsView.topActionsButtonSize, + height: NCVideoControlsView.topActionsButtonSize + ) + .background(.white.opacity(0.92)) + .clipShape(Circle()) + .shadow(color: .black.opacity(0.16), radius: 14, x: 0, y: 4) + case .vlcTracks: subtitleActionMenu( systemName: "captions.bubble", @@ -655,6 +665,24 @@ private struct NCVideoControlsSwiftUIView: View { } } +// MARK: - AirPlay Route Picker + +private struct NCVideoAirPlayRoutePickerView: UIViewRepresentable { + func makeUIView(context: Context) -> AVRoutePickerView { + let routePickerView = AVRoutePickerView() + routePickerView.backgroundColor = .clear + routePickerView.tintColor = .black + routePickerView.activeTintColor = .black + routePickerView.prioritizesVideoDevices = true + return routePickerView + } + + func updateUIView( + _ uiView: AVRoutePickerView, + context: Context + ) { } +} + // MARK: - Preview #Preview("Video Controls") {