From 25d8e85512d8ac4da6e904dce26d2e9cb2e5e34b Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Fri, 22 May 2026 16:58:53 +0200 Subject: [PATCH 01/61] 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/61] 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/61] 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/61] 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/61] 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/61] 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/61] 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/61] 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/61] 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/61] 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/61] 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/61] 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/61] 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/61] 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/61] 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/61] 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/61] 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/61] 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/61] 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/61] 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/61] 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/61] 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/61] 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/61] 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/61] 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/61] 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/61] 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/61] 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/61] 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/61] 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/61] 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/61] 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/61] 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/61] 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/61] 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/61] 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/61] 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/61] 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/61] 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/61] 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/61] 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/61] 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/61] 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/61] 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/61] 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/61] 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/61] 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/61] 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/61] 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/61] 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/61] 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/61] 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/61] 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/61] 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") { From 42ad8cca8b9aae82da9954650878c15ff2fe23b1 Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Wed, 3 Jun 2026 17:19:59 +0200 Subject: [PATCH 55/61] presentViewController Signed-off-by: Marino Faggiana --- .../NCCollectionViewCommon+CellDelegate.swift | 2 +- ...llectionViewCommon+SelectTabBarDelegate.swift | 1 + iOSClient/Main/Create/NCCreate.swift | 16 ++++++++-------- iOSClient/Media/NCMedia+Command.swift | 1 + iOSClient/Menu/ContextMenuActions.swift | 2 ++ iOSClient/Menu/NCContextMenuMain.swift | 3 ++- iOSClient/Menu/NCContextMenuViewer.swift | 8 ++++---- .../NCNetworking+TransferDelegate.swift | 6 +++--- .../NCViewerRichWorkspaceWebView.swift | 2 +- iOSClient/Viewer/NCViewer.swift | 2 +- .../NCViewerDirectEditing.swift | 2 +- .../Loading/NCNextcloudMediaViewerLoader.swift | 6 +----- .../NCViewerRichDocument.swift | 2 +- 13 files changed, 27 insertions(+), 26 deletions(-) diff --git a/iOSClient/Main/Collection Common/NCCollectionViewCommon+CellDelegate.swift b/iOSClient/Main/Collection Common/NCCollectionViewCommon+CellDelegate.swift index 1b5390de87..db0f215c2c 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, viewController: self.controller, metadata: metadata, page: .sharing) + NCCreate().createShare(controller: self.controller, presentViewController: self.controller, metadata: metadata, page: .sharing) } } } diff --git a/iOSClient/Main/Collection Common/NCCollectionViewCommon+SelectTabBarDelegate.swift b/iOSClient/Main/Collection Common/NCCollectionViewCommon+SelectTabBarDelegate.swift index 063f90b611..f796a3ee1d 100644 --- a/iOSClient/Main/Collection Common/NCCollectionViewCommon+SelectTabBarDelegate.swift +++ b/iOSClient/Main/Collection Common/NCCollectionViewCommon+SelectTabBarDelegate.swift @@ -135,6 +135,7 @@ extension NCCollectionViewCommon: NCCollectionViewCommonSelectTabBarDelegate { await NCCreate().createActivityViewController( selectedMetadata: metadatas, controller: self.controller, + presentViewController: self, sender: nil) } } diff --git a/iOSClient/Main/Create/NCCreate.swift b/iOSClient/Main/Create/NCCreate.swift index d91319d156..df9c8c3f48 100644 --- a/iOSClient/Main/Create/NCCreate.swift +++ b/iOSClient/Main/Create/NCCreate.swift @@ -157,7 +157,7 @@ class NCCreate: NSObject { return (templates, selectedTemplate, ext) } - func createShare(controller: NCMainTabBarController?, viewController: UIViewController?, metadata: tableMetadata, page: NCBrandOptions.NCInfoPagingTab) { + func createShare(controller: NCMainTabBarController?, presentViewController: 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 { - viewController?.present(shareNavigationController, animated: true, completion: nil) + presentViewController?.present(shareNavigationController, animated: true, completion: nil) } } } @@ -224,8 +224,8 @@ class NCCreate: NSObject { /// - controller: Main tab bar controller used to present the activity view. /// - sender: The UI element that triggered the action (for iPad popover anchoring). @MainActor - func createActivityViewController(selectedMetadata: [tableMetadata], controller: NCMainTabBarController?, sender: Any?) async { - guard let controller else { + func createActivityViewController(selectedMetadata: [tableMetadata], controller: NCMainTabBarController?, presentViewController: UIViewController?, sender: Any?) async { + guard let controller, let presentViewController else { return } @@ -307,10 +307,10 @@ class NCCreate: NSObject { popover.sourceView = view popover.sourceRect = view.bounds } else { - popover.sourceView = controller.view + popover.sourceView = presentViewController.view popover.sourceRect = CGRect( - x: controller.view.bounds.midX, - y: controller.view.bounds.midY, + x: presentViewController.view.bounds.midX, + y: presentViewController.view.bounds.midY, width: 0, height: 0 ) @@ -318,7 +318,7 @@ class NCCreate: NSObject { } } - controller.present(activityViewController, animated: true) + presentViewController.present(activityViewController, animated: true) } // MARK: - Private helper diff --git a/iOSClient/Media/NCMedia+Command.swift b/iOSClient/Media/NCMedia+Command.swift index 38733c2260..dc00e0ff66 100644 --- a/iOSClient/Media/NCMedia+Command.swift +++ b/iOSClient/Media/NCMedia+Command.swift @@ -95,6 +95,7 @@ extension NCMedia: NCMediaSelectTabBarDelegate { await NCCreate().createActivityViewController( selectedMetadata: metadatas, controller: self.controller, + presentViewController: self, sender: nil) } } diff --git a/iOSClient/Menu/ContextMenuActions.swift b/iOSClient/Menu/ContextMenuActions.swift index b730f71e21..a029f74dd6 100644 --- a/iOSClient/Menu/ContextMenuActions.swift +++ b/iOSClient/Menu/ContextMenuActions.swift @@ -28,6 +28,7 @@ enum ContextMenuActions { static func share(metadatas: [tableMetadata], controller: NCMainTabBarController?, + presentViewController: UIViewController?, sender: Any?, completion: (() -> Void)? = nil) -> UIAction { UIAction( @@ -38,6 +39,7 @@ enum ContextMenuActions { await NCCreate().createActivityViewController( selectedMetadata: metadatas, controller: controller, + presentViewController: presentViewController, sender: sender ) completion?() diff --git a/iOSClient/Menu/NCContextMenuMain.swift b/iOSClient/Menu/NCContextMenuMain.swift index 09d24d27a9..bb12e80921 100644 --- a/iOSClient/Menu/NCContextMenuMain.swift +++ b/iOSClient/Menu/NCContextMenuMain.swift @@ -96,7 +96,7 @@ class NCContextMenuMain: NSObject { image: utility.loadImage(named: "info.circle.fill") ) { _ in NCCreate().createShare(controller: self.controller, - viewController: self.controller, + presentViewController: self.controller, metadata: metadata, page: .activity) } @@ -127,6 +127,7 @@ class NCContextMenuMain: NSObject { await NCCreate().createActivityViewController( selectedMetadata: [self.metadata], controller: self.controller, + presentViewController: self.controller, sender: self.sender ) } diff --git a/iOSClient/Menu/NCContextMenuViewer.swift b/iOSClient/Menu/NCContextMenuViewer.swift index 2d96bc0589..c1c3835636 100644 --- a/iOSClient/Menu/NCContextMenuViewer.swift +++ b/iOSClient/Menu/NCContextMenuViewer.swift @@ -46,7 +46,7 @@ class NCContextMenuViewer: NSObject { // DETAIL if !(!capabilities.fileSharingApiEnabled && !capabilities.filesComments && capabilities.activity.isEmpty) { - menuElements.append(makeDetailAction(metadata: metadata, controller: controller, viewController: viewController)) + menuElements.append(makeDetailAction(metadata: metadata, controller: controller, presentViewController: viewController)) } // VIEW IN FOLDER @@ -73,7 +73,7 @@ class NCContextMenuViewer: NSObject { // SHARE if !webView, metadata.canShare { - menuElements.append(ContextMenuActions.share(metadatas: [metadata], controller: controller, sender: sender)) + menuElements.append(ContextMenuActions.share(metadatas: [metadata], controller: controller, presentViewController: viewController, sender: sender)) } // PDF ACTIONS @@ -91,13 +91,13 @@ class NCContextMenuViewer: NSObject { // MARK: - Private Action Makers - private func makeDetailAction(metadata: tableMetadata, controller: NCMainTabBarController, viewController: UIViewController?) -> UIAction { + private func makeDetailAction(metadata: tableMetadata, controller: NCMainTabBarController, presentViewController: UIViewController?) -> UIAction { UIAction( title: NSLocalizedString("_details_", comment: ""), image: UIImage(systemName: "info") ) { _ in NCCreate().createShare(controller: controller, - viewController: viewController, + presentViewController: presentViewController, metadata: metadata, page: .activity) } diff --git a/iOSClient/Networking/NCNetworking+TransferDelegate.swift b/iOSClient/Networking/NCNetworking+TransferDelegate.swift index 59f9a31e1e..dc6fe30795 100644 --- a/iOSClient/Networking/NCNetworking+TransferDelegate.swift +++ b/iOSClient/Networking/NCNetworking+TransferDelegate.swift @@ -88,9 +88,9 @@ extension NCNetworking: NCTransferDelegate { } if metadata.contentType.contains("opendocument") && !NCUtility().isTypeFileRichDocument(metadata) { - await NCCreate().createActivityViewController(selectedMetadata: [metadata], controller: controller, sender: nil) + await NCCreate().createActivityViewController(selectedMetadata: [metadata], controller: controller, presentViewController: controller, sender: nil) } else if metadata.classFile == NKTypeClassFile.compress.rawValue || metadata.classFile == NKTypeClassFile.unknow.rawValue { - await NCCreate().createActivityViewController(selectedMetadata: [metadata], controller: controller, sender: nil) + await NCCreate().createActivityViewController(selectedMetadata: [metadata], controller: controller, presentViewController: controller, sender: nil) } else { 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) @@ -108,7 +108,7 @@ extension NCNetworking: NCTransferDelegate { return } - await NCCreate().createActivityViewController(selectedMetadata: [metadata], controller: controller, sender: nil) + await NCCreate().createActivityViewController(selectedMetadata: [metadata], controller: controller, presentViewController: controller, sender: nil) case NCGlobal.shared.selectorSaveAlbum: diff --git a/iOSClient/RichWorkspace/NCViewerRichWorkspaceWebView.swift b/iOSClient/RichWorkspace/NCViewerRichWorkspaceWebView.swift index df0114b962..8145f0e0bf 100644 --- a/iOSClient/RichWorkspace/NCViewerRichWorkspaceWebView.swift +++ b/iOSClient/RichWorkspace/NCViewerRichWorkspaceWebView.swift @@ -72,7 +72,7 @@ class NCViewerRichWorkspaceWebView: UIViewController, WKNavigationDelegate, WKSc if message.body as? String == "share", metadata != nil { NCCreate().createShare(controller: self.controller, - viewController: self.controller, + presentViewController: self.controller, metadata: metadata!, page: .sharing) } diff --git a/iOSClient/Viewer/NCViewer.swift b/iOSClient/Viewer/NCViewer.swift index 8c6a46371a..76b2611349 100644 --- a/iOSClient/Viewer/NCViewer.swift +++ b/iOSClient/Viewer/NCViewer.swift @@ -196,7 +196,7 @@ class NCViewer: NSObject { // Document Interaction Controller if let controller = delegate?.tabBarController as? NCMainTabBarController { Task { - await NCCreate().createActivityViewController(selectedMetadata: [metadata], controller: controller, sender: nil) + await NCCreate().createActivityViewController(selectedMetadata: [metadata], controller: controller, presentViewController: controller, sender: nil) } } } diff --git a/iOSClient/Viewer/NCViewerDirectEditing/NCViewerDirectEditing.swift b/iOSClient/Viewer/NCViewerDirectEditing/NCViewerDirectEditing.swift index 6332794545..3b78dfc7fd 100644 --- a/iOSClient/Viewer/NCViewerDirectEditing/NCViewerDirectEditing.swift +++ b/iOSClient/Viewer/NCViewerDirectEditing/NCViewerDirectEditing.swift @@ -176,7 +176,7 @@ class NCViewerDirectEditing: UIViewController, WKNavigationDelegate, WKScriptMes if message.body as? String == "share" { NCCreate().createShare(controller: self.controller, - viewController: self.controller, + presentViewController: self.controller, metadata: metadata, page: .sharing) } diff --git a/iOSClient/Viewer/NCViewerMedia/Loading/NCNextcloudMediaViewerLoader.swift b/iOSClient/Viewer/NCViewerMedia/Loading/NCNextcloudMediaViewerLoader.swift index 950875b5f9..e788a51603 100644 --- a/iOSClient/Viewer/NCViewerMedia/Loading/NCNextcloudMediaViewerLoader.swift +++ b/iOSClient/Viewer/NCViewerMedia/Loading/NCNextcloudMediaViewerLoader.swift @@ -95,10 +95,6 @@ final class NCMediaViewerLoader: NCMediaViewerLoading, @unchecked Sendable { let result = await NCNetworking.shared.downloadFile(metadata: metadata) - if let afError = result.afError { - throw afError - } - if result.nkError != .success { throw result.nkError } @@ -160,7 +156,7 @@ final class NCMediaViewerLoader: NCMediaViewerLoading, @unchecked Sendable { let result = await NCNetworking.shared.downloadFile(metadata: downloadMetadata) - if result.afError != nil || result.nkError != .success { + if result.nkError != .success { return nil } diff --git a/iOSClient/Viewer/NCViewerRichdocument/NCViewerRichDocument.swift b/iOSClient/Viewer/NCViewerRichdocument/NCViewerRichDocument.swift index 7b9b68f066..9cbcc0425c 100644 --- a/iOSClient/Viewer/NCViewerRichdocument/NCViewerRichDocument.swift +++ b/iOSClient/Viewer/NCViewerRichdocument/NCViewerRichDocument.swift @@ -186,7 +186,7 @@ class NCViewerRichDocument: UIViewController, WKNavigationDelegate, WKScriptMess if message.body as? String == "share" { NCCreate().createShare(controller: self.controller, - viewController: self.controller, + presentViewController: self.controller, metadata: metadata, page: .sharing) } From ce4bb4f0079f6f8e5b2966a1666e6c81eeb79e48 Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Wed, 3 Jun 2026 17:35:11 +0200 Subject: [PATCH 56/61] share Signed-off-by: Marino Faggiana --- iOSClient/Main/Create/NCCreate.swift | 13 +++++++--- .../NCVideoAVPlayerViewController.swift | 24 ++++++++++++------- .../Video/VLC/NCVideoVLCViewController.swift | 24 ++++++++++++------- .../NCMediaViewerHostingController.swift | 20 ++++++++++------ 4 files changed, 53 insertions(+), 28 deletions(-) diff --git a/iOSClient/Main/Create/NCCreate.swift b/iOSClient/Main/Create/NCCreate.swift index df9c8c3f48..6e95e84b91 100644 --- a/iOSClient/Main/Create/NCCreate.swift +++ b/iOSClient/Main/Create/NCCreate.swift @@ -303,10 +303,17 @@ class NCCreate: NSObject { // iPad popover configuration if let popover = activityViewController.popoverPresentationController { - if let view = sender as? UIView { - popover.sourceView = view - popover.sourceRect = view.bounds + if let barButtonItem = sender as? UIBarButtonItem { + // Anchor the popover to the bar button item. + popover.barButtonItem = barButtonItem + + } else if let sourceView = sender as? UIView { + // Anchor the popover to the sender view. + popover.sourceView = sourceView + popover.sourceRect = sourceView.bounds + } else { + // Fallback: anchor the popover to the center of the presenting view. popover.sourceView = presentViewController.view popover.sourceRect = CGRect( x: presentViewController.view.bounds.midX, diff --git a/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoAVPlayerViewController.swift b/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoAVPlayerViewController.swift index ef1b790e77..61ec9feaa8 100644 --- a/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoAVPlayerViewController.swift +++ b/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoAVPlayerViewController.swift @@ -103,11 +103,17 @@ final class NCVideoAVPlayerViewController: UIViewController { // MARK: - Navigation Items - private lazy var moreNavigationItem = UIBarButtonItem( - image: NCImageCache.shared.getImageButtonMore(), - primaryAction: nil, - menu: makeMoreMenu() - ) + private lazy var moreNavigationItem: UIBarButtonItem = { + let item = UIBarButtonItem( + image: NCImageCache.shared.getImageButtonMore(), + primaryAction: nil, + menu: nil + ) + + item.menu = makeMoreMenu(sender: item) + + return item + }() private lazy var mediaDetailNavigationItem = UIBarButtonItem( image: NCUtility().loadImage( @@ -345,11 +351,11 @@ final class NCVideoAVPlayerViewController: UIViewController { } private func refreshMoreMenu() { - moreNavigationItem.menu = makeMoreMenu() + moreNavigationItem.menu = makeMoreMenu(sender: moreNavigationItem) } - // Use this controller as sender so actions present above AVPlayer. - private func makeMoreMenu() -> UIMenu { + // Use the real menu anchor as sender so popovers are presented from the correct source. + private func makeMoreMenu(sender: Any?) -> UIMenu { UIMenu(title: "", children: [ UIDeferredMenuElement.uncached { [weak self] completion in guard let self else { @@ -362,7 +368,7 @@ final class NCVideoAVPlayerViewController: UIViewController { controller: self.contextMenuController, viewController: self, webView: false, - sender: self + sender: sender ).viewMenu() { completion(menu.children) } else { diff --git a/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCViewController.swift b/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCViewController.swift index 725d2dfbb2..6a44654276 100644 --- a/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCViewController.swift +++ b/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCViewController.swift @@ -74,11 +74,17 @@ final class NCVideoVLCViewController: UIViewController { // MARK: - Navigation Items - private lazy var moreNavigationItem = UIBarButtonItem( - image: NCImageCache.shared.getImageButtonMore(), - primaryAction: nil, - menu: makeMoreMenu() - ) + private lazy var moreNavigationItem: UIBarButtonItem = { + let item = UIBarButtonItem( + image: NCImageCache.shared.getImageButtonMore(), + primaryAction: nil, + menu: nil + ) + + item.menu = makeMoreMenu(sender: item) + + return item + }() private lazy var mediaDetailNavigationItem = UIBarButtonItem( image: NCUtility().loadImage( @@ -310,11 +316,11 @@ final class NCVideoVLCViewController: UIViewController { } private func refreshMoreMenu() { - moreNavigationItem.menu = makeMoreMenu() + moreNavigationItem.menu = makeMoreMenu(sender: moreNavigationItem) } - // Use this controller as sender so actions present above VLC. - private func makeMoreMenu() -> UIMenu { + // Use the real menu anchor as sender so popovers are presented from the correct source. + private func makeMoreMenu(sender: Any?) -> UIMenu { UIMenu(title: "", children: [ UIDeferredMenuElement.uncached { [weak self] completion in guard let self else { @@ -327,7 +333,7 @@ final class NCVideoVLCViewController: UIViewController { controller: self.contextMenuController, viewController: self, webView: false, - sender: self + sender: sender ).viewMenu() { completion(menu.children) } else { diff --git a/iOSClient/Viewer/NCViewerMedia/NCMediaViewerHostingController.swift b/iOSClient/Viewer/NCViewerMedia/NCMediaViewerHostingController.swift index 1f837f413d..2202d23989 100644 --- a/iOSClient/Viewer/NCViewerMedia/NCMediaViewerHostingController.swift +++ b/iOSClient/Viewer/NCViewerMedia/NCMediaViewerHostingController.swift @@ -31,11 +31,15 @@ final class NCMediaViewerHostingController: UIHostingController Date: Wed, 3 Jun 2026 17:46:40 +0200 Subject: [PATCH 57/61] share - sender Signed-off-by: Marino Faggiana --- iOSClient/Media/NCMedia+CollectionViewDelegate.swift | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/iOSClient/Media/NCMedia+CollectionViewDelegate.swift b/iOSClient/Media/NCMedia+CollectionViewDelegate.swift index 66904485e3..1ae3a4a458 100644 --- a/iOSClient/Media/NCMedia+CollectionViewDelegate.swift +++ b/iOSClient/Media/NCMedia+CollectionViewDelegate.swift @@ -107,11 +107,12 @@ extension NCMedia: UICollectionViewDelegate { } let identifier = indexPath as NSCopying let image = utility.getImage(ocId: metadata.ocId, etag: metadata.etag, ext: global.previewExt1024, userId: metadata.userId, urlBase: metadata.urlBase) + let sender = collectionView.cellForItem(at: indexPath) ?? collectionView return UIContextMenuConfiguration(identifier: identifier, previewProvider: { return NCViewerProviderContextMenu(metadata: metadata, image: image, sceneIdentifier: self.sceneIdentifier) }, actionProvider: { _ in - let contextMenu = NCContextMenuMain(metadata: metadata.detachedCopy(), viewController: self, controller: self.controller, sender: collectionView) + let contextMenu = NCContextMenuMain(metadata: metadata.detachedCopy(), viewController: self, controller: self.controller, sender: sender) return contextMenu.viewMenu() }) } From 01a5d3dbe84cc0fcc76da0e05d294cda0b68f0b6 Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Thu, 4 Jun 2026 15:36:39 +0200 Subject: [PATCH 58/61] controls glass Signed-off-by: Marino Faggiana --- .../testimage.imageset/Contents.json | 12 +++++ .../testimage.imageset/testimage.jpg | Bin 0 -> 384768 bytes .../Content/Video/NCVideoControlsView.swift | 50 +++++++++++++----- 3 files changed, 49 insertions(+), 13 deletions(-) create mode 100644 iOSClient/Images.xcassets/testimage.imageset/Contents.json create mode 100644 iOSClient/Images.xcassets/testimage.imageset/testimage.jpg diff --git a/iOSClient/Images.xcassets/testimage.imageset/Contents.json b/iOSClient/Images.xcassets/testimage.imageset/Contents.json new file mode 100644 index 0000000000..999cc487eb --- /dev/null +++ b/iOSClient/Images.xcassets/testimage.imageset/Contents.json @@ -0,0 +1,12 @@ +{ + "images" : [ + { + "filename" : "testimage.jpg", + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/iOSClient/Images.xcassets/testimage.imageset/testimage.jpg b/iOSClient/Images.xcassets/testimage.imageset/testimage.jpg new file mode 100644 index 0000000000000000000000000000000000000000..73273ca29c1cdb93d783d4776c82fd1418549509 GIT binary patch literal 384768 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 F6_RtB88$}_19#xI*G5=Im z+Sq0v3_n;N6AlfD^1qlspBRfX85K_EBmU5=1Cm zE(!KEBlH>BfZgXxBg^%nPyeO@p&{p)J?CFUTLx`Iow?CN2cNzyo_%Q{ZZ95QIfPJtoZwZc3_<#k-0m}?gg-9#`xYqMtgY!>+aUPZwyPXk<2z^bioj>CZVyup^Toi2h=1r9678|JL9STS#PMF zMpt1F-RiSy^U##9c{nwR@1Iw;FZIe`ABixFHSgNL*${Q0+K}ko zxvxk(WLM<_Z$1?p;R;Yx&a1JIa!kRD4o}bR-HDTtTPt`UlIaAmWu@B3#Ii*ZMGK2E zJm|+6irG(WQG65%;u_uCl*+?WdMt$TqzW~bH6GziMLajPo%C{=yTuNdA_Z7m6Q7T; zwwN+=-5Z;zmt-S*Wz0p@r?+3Zn%ZZ_*MerGT6t#g&ZjZS`1k-XROS3= zUT-NLSW4TQ#F0**s5Ojy18gKUw>TTs3)Nwgo`-wmq)Jwl^+E*LF+ut{_88O~->>DL z)*uI671>gymjl|Nk$vueQkGWNAhb{qcb=AX%yBDHr)fF{w6TqKrlfYjV;A}~eGl~u z@wggnpKa1rGNoS`D~XeRm&b*m2>B?>RmwsLpb`(64Jo6omk*a4_s5kvd3+{vIj@gJ zaw;l@xK(Z|eaCy2>{CgVoQ&}7%O#RNhMl*%5^mMiXl7qpJUP^T`?l)HGR9}8@jkMq z|HLWhgyQ3mO9#yCsQzPGS~@a3ucN7U%0dP_H3WGbPYa++I^vrWhZ6X#5EtB*?36m{ zu-VwtN&m46GoM%kY?6SIk&JY=04d8M-&&8y*r5Mu_NL+b0Yo{YO1~zjV$(d+jk$d+?8Co_CsizQoH>uR~pg=3~;SmG9)xgAe{(1=Ta5QNsJS z%HJdvt(B3L8CC+$w5t;(jQymel45>8q5hr=2(Hnb^C#(i z@mrBY5o$RZ7Bad~K9YpB!4`n=+sH%kJLb2|JI{9Bi)+t6eEEj1CzxSbJ7RlC4ng@0 z0QFW~hBYG1`weOgby6*%uRA&KWq-Bt3OM@KP{apS=s-g~bEn3}Z^ND=X04lY4xybf zW_1WVp6V*USnr+>7i}zq!4L=^abw=uwk`SaN-}DEyeQgc=R+Ws%9VcXcUI4iu)?O!(QM`ZRCwh>>F1DS`X(p4 zljc+Ghcjjb#FJ{l*8J|@X&c+rlbwVzcCW98kNt^IESQHLf&NYwrluJR2$=_?<{()X zk}Q~!AgR*N5_XHn6ev~W7%Zq(A@uiwa*;+0zUc@Nl~&JCjjdwne05<)gWbaKKd-0Q zCk8Csef9&6+XbQJ){2dANpY(Nen*B_okyVjWv$`;9uTUyRI|#O%<=D5Iq^;3V<@u% zRj_Q&lmfokq(%q2G7lldk{^GuPLXNhn6dthnxT$n^dN*1wWp{9{Xdy^{V^{JJUy2CT6T+;=jf?2GjvD}*rMQVCvtxKGwsaBGAjSG(5ns%*s6vZw!|ai%hbQ9Tk94;3Nb*T-ApMY6sM z92!SgdsY2-F(yA0i$-9dV~yL|?C~~LA%;nLdyST;t)M!l_aTJRUW~n?E|*NoYyC44 zAxH)wKbW$1b&Ub81wd~qH)$UA@g?S`4E$qk)HnyaMXZ&SuKX>`3*Q~OZ^o&D(e{xv zH=TO}8BZ-5>oq$;9~c}N64|`C5Cj55`iiQAf!v>U*oFfV@Pu8f2_Av^`YLS%7flo>8$hzxD{Fd+dY#J5SI4q z2D{>Dbds!0Z#iNk=q1x>hbj!YooPeV{AJ$6SmcFrrm$<%trx!5_yZ-(A z3GKDsnsL8&aoB_i@wd6)sZ37i+=Yfa8Ig;~=nk`Ub>5`1_NrciJCQN!u9(Ei!tl++ z&bC9l)E`SGXH8ES3l9I;Bo%KxFQD#Uv~X=x@G_00qbT_2{MX~KtJwCH=ul+kI=hTs z7vfmEV5J9l+(u#^boVDT3h|p8B(?=ZD2eq$?qo+Cm5*r)q_SGf=^jT1FW9~oOn5NZ zU0G_HDM$=(=>gX+i`lAHygIYC85W5NNqXd_s>0F=EpH0K2LxwzWLp?S({i8*hd8Xr~>_2GvS z&%I>u2c>gNHTXlw2W9Z?j(nclHWf%U70vyjliV3yS$-nHZ}p0wi$)tje^N$~>YxhF z*y!i;D2jaQGE?v6zWX=i&rcNJ6>9o%z!RbuBfQxOY&y+4Zyf6JfOPV*}D_SA8WOT#8UG0+1h72D;wUV9|Zeq77{ONg1e-M-MgC{)EI2p;b$pnct{z0rE zG70n&iGs^`DXM^W_MJaw#$S8-TMG&`%=NX5Mb>vcE*kctTO)raUFHIp$a#^fTqaot zeDnI+R-b%{d5XPLd8FJug?re9#e~s(hzsaM@Y7fM(Q|t1{Jbsoj~F7{vC|Ik^A5(7O<85AVEk%0zZ8NZ!%;Gx6YX|U1wptW9OQ< zc`-KDGJXl?^F0L#%8KQfO~fd>le+{{ce1Fm+YVRCDp!AL?_RVm3z^?y zsYb2I10p%JUWDBK|8{-)v__oZD7#5U?*!7`zdP zb^8|*<>1SOOsrO6^|({#ao5;ClHd7Hau}@uVt_33nbbgOC5E%X?BWSYvvXCTL1VqB z%IvU4h~lzlH~Wa8ozO%u*GGoW?|Hn?kL)QE5rIj@J=jRDP^h&sI+$oa>z(Xm}Nf~r+?a#Pyd+W z=Ipe(qMa2<%H=B93Z^685NlodUXcZ}LD8)T7tJ*#b0>JV!2jmIuKR4nNc$q^KLB_1 zumaw`r3R5$8awlShac+qfLq2d^9Y&qScF!w6}y%?w=4F$4}Gc9#|IflRaU~|yE;S^ zS$>WFQ$Hf*GAmmC53_Sm_e#Cx%TVg;h-=srlmcGa&`V0ALngNcGh0s~t|xWqSxQo( z>2%_QszwAkLRoUdu<+I3W3%}qn!@wOJ+5%onng(9Sa2Oi`ZE@C8~Ltgih6~5WA>VJ zu;G9S<~iN;p7qCC^6T2EKZQw)bU{z*K-V674M@(W;);P+Ey`M(z`@m}a2uZ>HXMjJ zLs?RJgQPFpZfoJ)quKlqR_^fxuVkJj{R(bqYQAj;sMWMh0TzC)XO~#GvGU}k6|Ynk z=epDN5DHB*_$n1<2OPI13!xlt`MTO zsk;7qSb9JuDVf750)E26GuOiQ#d&aZ&NXKU_n}B7PZy}glsBcUUD=(<%$omr&E$v` zZg?nXxYWsZ{58LN+;N;Brd1+bp8Qhfhcf&Akv_3n$vJU*=8qsuh!Z+%c;#mBVU#H3 zYD)IdHYg-GVXGp@h&qLT44-s_aJyP{kQ#Iy8`e}5_;Ex{*V@kOk4L@Dp z&XfO9b-o5AN$AX8{8bENr|)hNhQrP}zj!mYo!&IOLf-*+5M4`r@UFIWLbOb9voj zyjErKE6I_rsQv|)g;DAHF<0?sP=f|$u6>W~xP?g#9^rg&y%qCFLV_=MR;s4TA``k5JM|8o?1Rm6FMKeCay( zS%^jVGTrj;I;GObYTU0e7f;z(w15=v>U|f%B=30+<~N5 zl(_;4_>Nes6O6uL%psNZ+Cz5J6KRTs2~A^xkaC6ORWPin|MK&xiSr8g=tHo8sjRG1 z_Y{HD@jsUHzk5hR4yJS7PM`2ny081J&1Kw0BP&yw1)Yo0)}IZo#ik2bCCFD^lr32b zjlI&|;DT}QaE~vu>#nOcM{E?=w>J$fNL`+_h7=B9zWiJ3mtSLsr@jMe5q=ZkO!q40 zT~gB|Fcue%9ly%_PIkabTrB z^G)c&{;!6WvYCThtEnMV|Fpci3f&SRW)di}VLz6tUhBu*pIuD{pMx>E(%(PK&8LB2 zJzJfhO|h?DPn&r}8gJBHyT(?|-rZFXyqCP*Bwu0~VGB!&yxcch*^&$}D15H^;SA7ksua~`e z6Y=7i>2r90_9y|9d>uv)AL+>W!m_{Y@6{j$jd=KVmB6%()`6m~3zS|Pyi82v&67^G z;Y)2iy#M>yT(_(}L!I|z6sJA3UvBwvY`h-+W+IFXj5>m4MYRIAah)tmOOz*-`^)bX zOel@_qgdg$ga}g5PgSlv)_AYz6??&t8)72_hc-`a9ypz}2xGvOB|(I{n#ucYr$*1; zSu?KUs`Ro#$yC8$Akh9ZOYiodcUHLIbepC_nEhqaA6*-|d|Ry_bwM#-N0dq@AIfK_ zvwbM=k~PBH;2=AYJy0Anc*XJp+QU+IvPc*B4ZojtUt9beD;+643ArH1%_-G_eHqFN zKf;}K{(-kkyi54nDxL)WNFHaP$&WB47bPYk=mO>xUW630$j8T3l|e`6`C6yPQ^nTm zlM6i^9A9knn2D-o-5*i-4Z`A-gSWDpDRv5~`+?P_UtB<0=p4D|uT+Yvl`F?aKX`7o z)qTgs*iwmt*h|1T-2hYvcG~7G4^Gtf@{8NIu7|_te2dJ)%z`dGZq08z4Hl%xD)$zv zvT}&@iw*|P>M`PwiI*M=T0UuelzPy)a#9oaxGwNLP>2$iSb3o5B+CaIQP_wc zBnE8D@|B!ZCOQ{O)*-$>;cjSbX_@ofb{3-d1fPP0jjerzx*mkvU7&ZwAJ0I7uAmF%m@;-*tRzTO3d zcv4+W!Zn$@yAS*>DP6OCR#tV(*K0DtnD49O6C&Tnx9OM$DVPSSjt4$wl7NcIAa*V} zV^iqHl{PccFV-o%I3^@Ue$^r^e>H*S$<;}eCj@sd^R{p`P8TjFCq^%3N z@m?2BBDX=3dSyTiIW#_^Rm7DDWZJq3_!g;)tQudSr_x#eSM(%-Z2VGi<+Sr|VlmR^ z&T@(raO^!c6Q-KZ$_h~Y9~Uw!K?jp_YzF(N>s(FfwBw=w&MnQ!B{6YC7K^(-{}h5_`$lPB5NzZgR3 z6kQql53c?LM6p_p`@{PWSYC*ulT|n38-iexnLgfx7YSwA5iFho|o5Q6uL`7*7<=;$!!}roZmvc)mjX7w@B>ui1oh^$w+y`j-uT*sJ?@{ZDKxyb%+cEcb8Wto_wSW5gf=I zwmy0*to2NId_V<+@f(>8&EP%DQRmkE0Un0bgD?Cw56F#`UbRa5qH^&8}Xyh3fQjQCLmzUXwbU% zFNt;8$(Z&Ns`&YQD-#D1!BzC;+C*mfOJjRmRG+ub?!B*D2bHv@@$LLz~c8hh0EiN<)&6j z4a<5go6stj(ZkEp<;OpM5nET^=%)`K15B+0M(!ih@9T3;2mG6wKUsi~TB9G? z;>(-~w;_b6u=$SEDKIglXpJq_fg#J4z2`(+b1#1H|m>23|+epBZ2Rvh8!vnE_*taU!KY<2IY18f%NcV zFXZZyDxpea2p(p1S{)z(lGi8|$K*3k?iv+{!&5{g)tH%kL6>*{9|h{y0l2L9+3=+M ztla=>vei6Rc!BmQ(_mWlGw zb87xQ>m1SfA7B%mAbRud0*(hDhU~F_=E#otQe4z9moa01hZ}6rFjM!ez%OBo#u>0c zCm`)GL&HFhm}v%m4z{2%C1KIMarVek=P00T=1jEm_nTYOcidFsg|t~PvtM(PlCA;_ z(T*ANvHVqEM7!}#=(Ui6;ObiO@TNhj&d?+054P*5?RdQ!PL#<914tXgC`eBglhlk!vSl zN-||hq^J%1-GTcddZ)J>FDq-WSaxJ6G@zdqGjQ$Y4rMYJAXvRP zMx$jw@wz_?L-v3Bzyx`S9%6AKE2imZv6%oWsA7e(M~Dq>{-^3~Cq`3Hat0X$V(jUD z5R(O)RD}J$D)kaRAt2x;^c`Yj3u2Mi^Tox1*ppZkJsSL@!oT5Cm{W=e5bqbmV zHDet|L80K7Ldu`K0j@8`9*pNjX>ml=?;R+5%>H{AJZ3(tpj;m8F#0I8>hpz=d%ArI zheNSGB^8kBKY&cF0!zw+o_nG4Yt5>~{qJ;*!>6n=h)vr`e?LT-oaC#auiijT$9>DM zWlG_ac0b$=dkrEqTQvOySNa&JIQc7IpkB~W>#apWkoEoPk9j(xX389Yf1Sb!COXQ}GXE6`9~ z2kh)I?_JiEy_v%pS-rUAVXFak%>+Qf0hIf^XWCe1+)pv`E%48wW$N;nd&XKv8lB4L z5H@xvBZ|@S3TCsl=vA-fpoiA92MNM zhaBlN)_s)Ij*fuu1|H^&{QNC{BW+z7(!d|8{mn9xGfmz5piR?`&f;|{40X)Uk4g4q zx$u4SFpRK|Tj6r+;YNcO!PxQ50+)bcLT^wF`_Qg#H1!&k704+2+$l;GlpGA{ImI#Q zCftKDOZH!dEe!Y@v3eu`{L0q*;c98DT=|;6R6>Bjh*Cz@<_r6erXn96g~vT31+e+G zSHp7Cis85vDLR03JA0c<3{Q2vVAlaU%pyD7eIRy8Nk1tT*?8>bnl(TO(}8h?kVk(u z!_BtZ!vo8aIin=6D$1_2_WuMnS9pmG4D`p6|33ge)ornRp)vS{p}NFlUnZu(zVY=4Nguiw`6?F`)zzMpW{El zYi|^3r_b?cgq};JF@PTC)Tn9D0o8y|;D4~ded_st;g7)Ef7;5!Rn#;;5ksPUPSVf| z%PWEnrmqnBeBUuLcXU=bV0R;DlV50fZ^Ut1G_R*$jayHR*vBwA4i$*Oz{YXg>)O62 z@zj^!vbV>JFBN!N$!s*c`>C(&+6i5iA!1T%Hw*&&oHBr;Xp{n93P;Rl*mzKD83lE- zUSIGGeYH#uDPq&J+4--#e#en~P53k7UxwZ!eG|l5CEkUoIvyDniHvY@hVt%V&Iok* z*PM%JVdVojGpR||k&YuYFqwz0=q`SP(E+AoN8*Iy8< zwY@h{fhm z-+7FX$!cK%Vv(Hgh%>}6cWvc|1me42JQz|!5qBtUv9?fWoGCp=JReHxr#qwN^J+1p zQZkCOlH2tnl!$MwBTKl>=p<>a-Y^`vbpWFWz*|%)(3#e6Qsx)kg^@aa*`Nv+z>$>h$5}c$3vnpmrYFT zdnS#ta83y11Jq)tl_ZMVR|_rCMI|6oxi}fvxgc_L)Pa-K3b_)qlet2ipSp9#c=5wz#*L2_T*m`?*ou_j^uAIaXeChUzhpDm|y#X3CdM-{1FtUxmnJ zfeh>;LWoHyj@y_IbAg`R;8KeoO8^TH0bn`$ex|jqVu(v3z*|1^ zPt?*PZR#^vI+lBmFk6{2*gd;|+es=LNJKb}@$x-!f|kifG7H#X+N=V{^AVX(0tt5Fax9rNge%!kmq#cGHp%0E}~yts=_pezuaE6By#hIIIK(PJeV73RT!m-DoC$)Ib?N@$0HSpFQN*gD+ zxf=eTa3^xoi#A7G$eAQY8DrUnz$QX*mL&ZYpDN}trtZ?0UB55${EuC@Rn|0eKn1qv z@vbLCxt~eazQ=C?wYNo#d5)@Kk#`Vd3K%dURB}KAdgFE&3;zHP_4K|M2-hz(bDuIe z-)H5K+*hia`*%z640cYfGR%u{ zIEXWn=*&=K1QH`tjlAszxa7FqXT&-dm*Wiv{?ce*)ij&McrLA7vXX6L7-Vv;jJy%Z zD_oeG5yjGU<s2M!<(~a6BQK}zZO;m9CB9M$H(rE;@}8XKkPY zNW$j;{Kq5=o->>beIGPZ=1%^<@ZF#2`m^&`QEBO~_5FXD-24yFb*pUy?VccQm(ovg zA&3xMTY1c2FOC(wy}41y@6JVfuZOKI>?60dVL#ceg2JtFGfeU*PysH?S&w4QcM=IX z&QF?rB>17Ec&kYk)3LL^vvF^7LheH*(7}Q9Wgz~3mFn6)j?0hunniWKh<(Dj6&m zBCJ;dhCw5!&N%!*uNh(@^*dA>Mm?Rf$8i(7fCH-*^2C?`^YWe>=z7y*wcJMB9^6*+ zcId;PsTLT}^c9S;QoWIW$#pX=>}4+O=W!=F@A&%jn!YT~(ZyQB1O_BWeaZ-SDmLV9 zIl<%%kUNkDMsh7ipgR{KlRO0&AYgHxdCqyPJ)?WsFYB>toyId zk-Ko`7~l*La&yOQsSKSfVc^_HO}OM@p?g(swJxVOp-w5iHZx;#=LW4u8uzEm0zi4& z&>jwJGJRB~)RB2iu_`Mpr*76Hwp4SRi~@21`GyJS8M$-o9Z1I;xRmE$jVjK zhC_p#-WcMv2J3F(pw&pBySo)t+$*ilLL14TQzcRC!w?wH4NaID0Gyzo@P0I2hxCPI$rk_S#qezpv{7|X8PElmtk{cNQ5C}XFNBcG1==V2Q?*hR*R}huLjp0~h z{g&!}gx8Zp6YYKH-JX?7KI%PA*5WIv;aL?WiO$AH#Ir}@>YFb6uc@T`OGe*D+{{XVt13d_O{U7kZ zi!6Lm;jIf$(l*`cH%#}6_g4=jkeLIsg)A8EM?PN6s72fc0);+N`%ioYhvK%0apG+S zRki4hxr{IicN)75gZ;2a%1`^f!5=vO&;I}#;rLm3E|;xMe74sTxSrkPK2@85fo21K zNo+dEyFp!uz_#X9`LyMgVmWt&`u0Bm02w&vxMf#Ayn5P{Rz zHNoCI`ku9AZDk8wUQIU0VvTan+gqpL4?kLO{3jOowo%U%vE0KdhmL5NGCH0^DI5@e zYL~-XyPbRD-i)z0NG-0GMP(Q`3Vu`1C-0o`!2=v*`ySO)YRAL-KWBTNO89q2n@-bo z_##D$SZ!j7?qnG&giuun*F4u#WonT8z%$5x^s;it{3{;k#5Ro5+RFl6MItnAj;hMa zMhGDAcqX>A-9jBfTtgUVKnpI`1ZO?R9lCuzMS2p`WufKDQr)7=&|BX^BAx`DRz#6j zRScn#gDYT;p_N!3!~#24&L6kAhU>t-6wtQIE$!v(*J}b8adXIEv3m5;~2j(#f_oA4>QS_O8}-Emz}Th}*=zUzY-~RS4_BTh7VOb!*kg!5yNTg;-6@l8N zOA-c13`Ww=5ZqmBI`ds#`Ipz;X`Wk&<#oG~R~v{+*u2G?JBtnHB-U;G@8SoF)=R5d zmgh1}b$>K%1d6#JnSyVIe2u*F*~fbP%|&vbp~`mDgH8j)8ibOwCB>^s5>TQL)NCa6s+dZT|pucQV~9mp4ut7fX!6cyORR4#bs^g~394;~l^| zC2LCm0N3^XY-G)A*z5t7ljIh1b_eq@sbD14I2}2Yd_m3 zQ93$ry1I!>mh&OVCC~s;MDKx~3Fp3LvxX0HVf$v7pt3&3NbXgBPc)TduLwp+~7Y@M(T#AJ=KMJZ4jeOsp>3s$o%b4O_n#BLTmiHa5xM8K}okC-nz7;;kz z!=+*^#9D2Mg>9K_njOt#&eF!J$3AQfIRPAJ1n_?v=rq4B#?$Rm-mL2&`$Y2scCpUZ znlf@yMI}owMqYd3v6@NR_x`p1zGX+5S={b?6R9VLyhCAgr)ev3c9Ot;%jdKwYQ9=X zBm``jF2b#X8=Unu>Hag(mqpiPYk8jH!tXa$(@P(hAy#G#k|*%UjvX0$G9JWNmiT#Z zEle_^?7Wb&OB5@-MnbkWfsyiZ%EJJt8Lw>kkEgx0mxpez2pYZSnHy>ms+Cu`2!XB? z9)eUT$Niwy(S?<&^7(&XndIj5x#%hNU#ZPmToQ^-N*)32RwkMRRaoOlkyIc=2W|iZ zzpr0UObWn>Cb*eGGe#8Uv5%XB!Tx-DRlw^X$PVTmO?vX8?XIbx8lsoPM{}!6*B(-- z0~seARj8#8YVzeUtf)b78zd4ixyc_X$pZt7<0Cb1Ez#{*unc%OBRqqUe>%^$lgZw) zL>ZZOg5iTkat=Vq=YlYE)bX4U`4=tGt)k+&Z|C}d!+CGoIh-85jD~ZFgXD$kr zz}f=iAcMOnCpjDpa=~h4Ot1)YKnmFy&mD(v@_N>Ja~a~Mi%}_nO-!RCy})mk61-+4 zWheR zvZ+M~0;o^`7#t7Gp5&5wCbvvWHm25=qI-hPwj4rt$&x}00!HFM%7Ss8{AV*!n%dgu z6FN@{J~B51kOTM}AHe-9S;boAsWmQMr=07y@?BgMjo9rRZ5igXv~6zx07<>_E*3(W z;jnRDl$N%jr5Br*k&u$simMzbFF=IL=A* z`@aV*q#Kce0B4HjE;MUf``I_7ad~JreAovV85ubwo^U<*0C8>7lv9uO{cHaK0IE{f z%Uc|>YExZK;oX8LVpOf((3vxMKsJMmg`m;MT8(H48flO~lW1FM}brHp~zsa(Ao}*TK*rI>Oa}9tF%s>;0~1E5NiJb*!JscAI#?;{c3>tiaiHQP?8L~N*EJeyjXq_&{EfM7c^$*FTL20}aMKHvH(FS*~5Dr5PvF?;uuBLzh`%r0OUsUphjiS1gInfj@dZ%HS|9Y=ZCg}C;$>i zPeGcT94R|a&~Q&`jT{a$Q{vm~tln&b=Xe4?ENm%*le>0voN>4D>{hQ~3v>iFitxNw zGrUFCEzE*9+(1_)dw>pCaKjGZsESwlFhRya;Nzjs^rVccWN%^%ZrWIo4glbG=sHxg zNUnf!z`(%A{{XI~b1OBtIK`0*gV<3m)UvxX63oXWlaftE9E!-SKI;sPz=QaM(xnoo zOp4LRmqt4+#{A6O0!=zeWS&QoM`w}AW@b4oPf|@wErq_PEOv3UQ$=zX78hkALRbL7 zi#E`iByM5>9Gr3mM!7`lb4_x+I+``gtjTA#mIezd6lPGsIKjaHgZP37_Bp80LI(_r z<(B}qa-4&agV&66k;(6ZE6sHb>JmuSMGUM1fIuLE57ZC{_Z4#1MxHi9a+dHu=ONgT z&WcKcstL)?)g)sqc>{48sP;LfbscqoclGJiWKgO~T;!4fAhE^tyV@SsRZQo7_Nv@wVb#9zT#TEU7o+! z$knvEWQd}(1Y#JjcqC^BABp_UXvKXbvnvpw;4jLdMgYLzcjK>qr<%gHVG1gONKt{f zl7A9;HKl2Cndmc4aMtf|akINSeJ1;A0NntqmpX)4$;ERARF+nfX=G_-k&C>Ja?DA| zB%Y+!W%i{j90F_0cvUCNLyK$PI+=CEi5WxTNd%6>j-Sl_b)RwKB{>R3a%S^%uN09_ zB+^DqO&U{ePKy z?^rS%gPLfSXSG7INYj>J$WR3ffwYFsNhfwj1~54};g^<)ad|wjz_zhFvm^+`gkYW& z79@}d86=v0tU@VLJT~H`feLYeMsjh0c^q{-&OT26)t*vq^%pcjbdSy;*|_iAu7t? zf~0i>c0H-zENhHXqvYbIxQJZH%(1L$Ng)?FUty9@=4m-;sP|SYTga1KM;pknt1we6 zfR#W`Q-DqfuqWD^DG~~rO${w>?iLr0Vdfmq3Xrp7x@#z|sol-9;R848jC!5H9-gZWnC zY2?M_{em6-PnYKIlXmUbxAar zrr#^>Txk2cd4F3R&B$ri6kvcvn-^?qq>aVCVg^{{k0&P>9RLEeT6LaJ;Dsd6KfJA~oC>b9xQTqbIuo-mW#5R#&pNkvRG7;)-b2GbD=pMof6aZOa3K zKmdXPBxjC<;;nyYG!6q1ovOoQ1{{D!NIYb0O#6@MZ86UmvNsg51CGW>FuAUde*+zn)c{Sl7_6UYUbRQZd7xC2{`_>)!0cC zkfAK4n2=CsBc~^~Iqm8BVlOZJTVoxkd@1I_@wsIJ_Jw;(T zdq^pD>utX;>+dp^t)~9~Esl}mjrTipdXv^`<^-hfH>Wdg1Wj1CFUCpkIef(AIrk_}~pbvfIBe6`c;SOj0{Cs>Yj`vjN6H@7J$ybJ~dYAt4G_vjNogHJ7rK zVyv_xbtKh^ZRCv0Aa;=vK^a){mfMvak&sCMk<*@}VwM*3C3wusxDDk=e=g#dS zqdV?OO3p26dflDg+m9*uVaPe^E7JTgedlRQ9zz(iy?b`A4b$~#wS6+zP_`&M)LDZg zD{ciw4&@{sF~}r(99MDR>&L#`Z6T0b+{V#JVUeS8k%Cp!hEwQ41a${B>Oy?+IO@lm zK8LF5vU!s>^)UrJGh^o99E=})bB>4Dog0;HPf5MDoU=zHe<(wPka946c&=(~H*($G zTE!Y_Qo-e<;Dlz|xW=seTZLz0GOPyXJ5ESDP_ta>mbT9n_cl)~Y(C277lq?3Cixsl zrK4hzro{(4h+xRgx1OqTN!suF{5p5;V?xect=WHH*XnWdSl-B@J4A;^jsF0MgC)z4 zY<=I%pK)3mM}pqM&gMfbRw3eTvdsupGGm693>m;U`FHc&ik5G+>6iMK+AkIugvDVt z!9%=w1a3QW4oeJYpa(qQWiwjdN$1{A_8Uab`<+Tw-q<+#Rk`id3?8+0AG>)wAxB4j z>~3H9=GRR7CYz?uZ49@Qt+Y2+j;NAKL12+7KRe-Ah4fSDTt~#q!Ql@H>zDeTr4`1g z_R>XddFEn&v`8K}qm2%6)Sf@MdE+&O@dNg%@IS*n67@VZ@_3V2oXH##Mj7m*xKvSx zkdQX6V=Rck{Mq2PYvjKid|B1LBY321TD{qlRA6nbVk{uFQG>QMB<<)j-B|KZr^M#D z?F=ksN|R1+`n~kG_3C`?Yo0>^PnMg!-%ai2-rDv)eb9bCd_eGA*23#lGsg^mQb#(n zG{Hs=XNiCe2+mo4xvZZV{ABSLjxNTt;w@^zeKy^e%tvvkXL!jbOL&?wDFY{S5OR3W zO3wJ99+{$iK=DMDc2dPE-y4XJnugjq+;4PbP#gkWV22w(I2`fj+N0ZR`m-OiM`fmf z(xsiP+g*ilhviQ;P~l{dF78PHCrL(d3 zZ|rmN63Pz{r-bz<1uxgl(k`5l`@_kE=ljE;KgEylSK3;Zji>mRK-4syUIRXzbpu0j z600*Z9DUHgHbDT5yyOgGzc)NV5^EkE@K1{Lm{Ueiuvy#BcL;n#a};4xXTT&D2tmL+ z#=K{*(cc2UYP~n&jL=(a_V#V!4K{7?+&%$!^2!;b$oXGmx}BsPI}Bi&@$iy#Bw?Dwbk4u`UMUK*-6?Yr&G?);oy&(JXUHvHW5Vs)tQT-zNg$JwdEh7> z&Xbdp{eJJ)QgbNPmLm06r}gWx=&4wX}EEnZc$0XozJ9F2d9G)urhMSs--4`+j zjU2MzGn16$9E={kW1Nmpd=a4xU?v4<2wZO= zR4z^iZ~*iJrE01%Tw<#tc_Swz43pK5ulW_D6oE!73bSpUs*+nR3U3pYbq>)p0tAVS zsr4BIWP5@t*5LHm?jyXmifF^CNTJk7jEn&pg+s8%%9vRz_DVh8bLp^}z&l z>FM>Xz3Cde+m(M`GQ?7VjLxMwBMpyUyn6MeN#ki0xGb3)f}9dPjt(msB$=ZhyZPIe z*h>ueqAJVYos=+C+BuQ?zmq(-rkxJBgc==)Jf905ilT)V{_4^l(A%lNc?$(x{{XTXfj#gIeYE7A+2khM zR@I+0_z%W69vajnvAC2q&4kTwAyy<7Mk?HpGt<((gS7D_roE@E>gvksGCd7^S8bzs zpF$IBI>H|&=0hn*&z&PVC7o1c<2mQ0b=n8TDLgAWG5SK$=w2Yx{8ebO*j*~Va#Ws2wQi3NYc|gv<(1f z4xi&4Kg1d=@j$X(T1#sayqg8Yaz(f1JAP6bj~H%%_rUzo*L1HLe$M{@6_(l+*{s`1 z<;EcfNN=KJfq*{xK00*=gN7V_pz2yxr-e)C9MsACNrQt8oycV#xza=ZTkfR%XMhvAimK^VsdHx2FJrOjzAzsUO@9nU|r zeXbg$IjK}ulHT1f`0Bd(XnDVZem!YF5cJoY(s5;|#y8JnD~-r|kbjAD(TC%M#a`CD zS8c7v;U_t`0p9O8GD12f_~({4cte`pzlMgc2mtYH-G>EJu)_ zkV#X?$ONgzNIQ7e@TbR{WY=`o@g17#7L97DKBFd^0lI>5ki<(6R+b_FY!y~r#W4Ff zzlgDNrTe@9_1nntla*N7LrCn#V!7({+gMrxM3?ZrjXXFDPOF z6+QV-d9SQ|CeL>iX{*^llQNC15oI9yFb5x%d$elB;qJZe=fUEmI8@}C<$ih{LKyXH zJIg!!NLKJd%zi<(PBN~(;NbDxSIFY}8(RZC#Hj=kMZQU7V5*82C5b!$2RW~=B100w zs-b*>b{)ltQ`BVhiunEH7VOG++QfcD*TLs;OOc+223pJHPS(mMc|m>bs)QFMa0w#| zoCEV5kTQ7Aa4`gsmtwr+0D5ux{uK1P3_8=A;11RDQK=^V@9X+UuP1VhR&qh*#{#NC zh(L`Kpcuw6zyJfrJvw5M+^~^yZj?71TY{%qg z+FXE0RmM-ZHM+doyVd^yTmGhZY>98(25?6`4Jg&VVsJ8eCYv-whUmj!MHmBbBIet$ zr`>7>+(z#@Ll>B+OuTL^cB+G(31Xmi&rYVFvF3M0bJ-u*EE9oWue_zuh3St@SRc`f#iPAQ8S97%i zI4p2Z2dTg(@HH`A(+Lg4sHqz-kgBB}iCi-hHv#}`VB_XF2ZFREP$euyX-b@2z3=P# z^8C(uc@eeaNpmDXNEs1alN$vgivfnl3HeSyIUHvh8Lu_}0G0?Rnsxo@NU#Sd+OU~r zxPF8T*QBY#p_H$4(tZkfhSyH`qir1H%(J{%tf7&Y<{OkUu^i)-#sNKg*U-AZhW^%J zzS3?YvAf=kE0+{{RzdFzU9l8R2N8g|@4? z08F?C93Dp`VB?|V7$@mpymG>Jc4x`=HkUfjkKpjnoY`$d>=B1zRwQXl5WawJcEIjQ z?_Dp5ei>Z&D_N4x355D=(v*#Zd2f;l?T?!P^1g)Dzr)Xm*FG!#p|$-^<}x&$4*jQ+ zO}kcEf~blmJbBh|RcAAVYc9O#z;ERM@G;PK~KZSO>HneQD7^f~S?IdukI9>x}U=iE3 zdGc%V-`=cOHfGu1DDas#6vKSE$QdO{6M?}v1ddNgE#=Z?U$M^^N94tG!ax52U*5gA zNz!j!5tQPdmMM5!LA%y|8u(h~D}_-mofM9Uq!xBv%v=5hTE2Gp`};Asi*-xQR>J8m zJVzvo_$jgphTH&184bYsxzB%5?>+&$@eY@xU8THVXt2}u8{IxAS@9mou4Dm|Dhd1H z56nmI_`%L!;vW>jGe;()V>C=Mg`;3VJwdE1!j(!*O*ZUw;qeu))w$@u>&*P~@aKzs zZ}6vDgGKu-#Hl->k4u$2*yr9x3|n*ev%?`FiSJ)r{>Pg1KM}l5toVZ2Z=;J$g&~d$ zghfGfI4Vk%KQ`FW$SwWz&|El0%g4$N?nwFY0)j~(0!Nuc4Cq&drFidd z+G+TnzAqU^7eaI7<*TR9T~DLZ(@oQsNO1+msT@LIv&kkK=G+jl0F_czhH!rA#&J)V zTD`H9OKYPt#5%af;y!||cxp(stv&CSNG~PxWG&@en3^Om#x!JRW4I>JTR3710HB2z z7F(#U_7d%71-9uWXoEcR6UNf5kW?#fUoQ>jCe!k;ucCA2R!7M5MQZo+Cx=~I*exfH zHYQm@FcFit0JG$PcKoVvP60R>1%-ZiYTp*LzY*&Ct(S48pVp^I+U+Ou{{V+9>K_Rr8XWr1!~5HB9B8s-u5{?(n#)#;=G}+3LUuZl^34WU zs9?3`mfE%6pR3Psx2DrvlIJqXDQg+ym~yeqhxdHoZEzHZ=Axg(`ig79_e{8(NWQcI zVUo^g1`sk8GFZCfJY=3dfUf6M_^Wr~tqvP+i8jfu3z(iEtfX-z>cXl{eC#io(}iYo z2mk=YSHs4tT0K8M>-0X}r38~pXTAEKcyyVrZtkX8?OknQQ4k_IWSz-EEvAh_sbH-5 zW-Ip?6T1p&jA{|4pK9jj*_Zx3#HChf)Rt6_cqTd13<8pJI2i|@K>q-Rf8mXC-ZA0d zi8>CUAV!TepAb9g*Q*)jR8Cc+EUn6xWgU(ypVz!8srVMyrmUBiH&V}=yQyF=XEKnc z3y}NRGjA>!u0R1!TX6%TI{yG)(>bj4xm!-VdwCeZ?2<+YnL40H3KW!i;`^+nLk4x| zGoSXa>h=cGbh-0wttJqlXl#T)z@Ato5XN@86e`$1HsIO$vz$vC`|J2Gq=MW@Z)>(` zQaDy-x9X&l%e(B-83@2_yO{|Dg{$&iTir_?tR?)IB8}&gBBZm7`E$mkeb5L;8Tq#7 zf^ZXuRsDZo*P4v4rT+lo$+Yx&%U-fGUBQV=P!f+GM(yYc!$&IQoDK*hwRFB3u(+FF z`&F|Ubjxs)O*Oc25*NY=9ZKV4Z&=v<(C4YhCkyP3l0dOZ4Z3dI9MP^)6k;7ETph}V zn5i8(IOuE9J{|Z6RPnB-7l*Vf2`+ZTWST!TszDhfI+@7<7bRF0JvwBX#!`xjtbOHU z(S89*Jkw>l^J6MAzatHvG2f5My=s!=NhW;6 zZrib<_rQ!66#-?C@I_{=rs7FrYW2RKn`vZ@P#P8ABD<=5yOl=Vwq3H^s2Kng&>V8f z)#hA_LewK?ealg^Mb2|a+C0S@n!h) zPcR5K4C6S!8LEwOBFf={2$e<~cRA-EjBo(k&Uwk;W0}p)QZcY0Rly@UbCPm$dh?Dj zFb8~+$z@QP#^a8arQA`Z9DdUNzY*84+-cX7+TBAft=taLO$@40<8Vl2WjuloK?bv4 zap#z}zCwpBw1wo526_|0;B@bdjG%&P*_|U;*np+`kTZZ!;(t1?e3C&4{poL7=bk1h zG`H+;7~N~H`Tqc4r=eyoa^B84<1vY(bqn+EUn}NQxd=EcLb8y+fEzf@2$tVjU$Ttk z%afMf!5m`)9CznE$4qm<>NloKv-`mE0IC)=RTvA7*yn+h)SQxY)V965c~os7M^Ht4 zOr=(HmE&g3V+}6NZAV$Sd-st;h1wK?#~f#%=np5J{CWz9R@Ud%W_ydNq?+beGD$Ry zS>1`rVoxNK&T1*eHHO zn#LTjnL9LFT{7<8Szx=3?4*KHj~tl}2;i%!8Bz{-_WFvsKBRPOQ#whVqjv`;s@iy$ zOTI5`h{8lTBN(h-7g=20*q^eNRa29WIIg@lB9!Fm)QfDV2`exFV zWo1(;qj z+e2+7qB5+}Gx^cLUvhv>*4!1cgzQm{2^c*vPoS@d{@D}D8u{T5Dy$1*YcU~ukOGoO z7#wFA70qkj9l95ycuJyR06i;6Rg5Yq*v%%>SN{Np{+Hl$dhL~kww@(xiG}LADn;c3 z8zBw7i|)uEgaLMtLop?GfZARaXs+z;0!wZslVk=dBHtq^CCj4~A$FM55*b+Uz{;1! z9~9ffbXgj9kX^>`H*Mvj7!FNjdFZ^M^Ea zG*PDXmrmyQh+@65l~Ic{YBl zL}%tqclw^#uO7Rak1AF#mzD3KsR}Sq0KgmoJ5;!QaguY_JqP*4UQasV1+*e*(lI2$ z6a|9ea_^4cDrBEU=cQG9$khUb83dBtkQjlS;4$n^Cmm`5WpO@HT{ney%gC{eC@qY!>w&=PPXJ_rN%r&|sB7hMfG}#3 zZ%vGnw6Aj}TkD7$1&OJbT)v9PsFj7ZvSpzX0vVJL7^Zs(7y?Ij!RuPF*hh95f@A@b zKXaBxtCJqYA1p03Cp=fy*F%*}IoX)AGjDCplhpIc{LklFV%7m^68TIg zMs3fyZDlyxHi4XjyCjp4RA6oAZ0YH=5sImG62%!VLN4H-u>cZC1M?>zTF2Wt>L|PE z75@MS{Q7O!%aS<2KnUP-@_~*|=k@;p3RUvdaz$0t99y6pSV@zXCxSS@#y-9Kj+o~X z=T&3`1Cv`q4r=!j*P+!#eI}W4Z*^}uf_T_V6tKyMje+2T4pf3NIv$3q2DTR|wbIfX znN&%4G-M!bC}wSei)|R_3BVk570f=q<)_;kMw0QcF5rW3I8xaopc&3_k)BR!Nq-~y z*F-2Zp1Xu1+SPww*Zc!c_fiKuis$by_Y+d%&SE7ltAm28lbmomj`-)dze=qwreisX zh6{ko*vZavbDZ(V813(#$S6hF(IO_l68QjPFE2Z`bLRE?+0Ur^xcj5uHigm}%6oQ3 zJXF@tJT1Bz9yTh-Sr~#}%0@**noDrd-LaIn2Cm6yj=9BF)1p}s90SP6+z3CB z_C zisnGd09MK19Gr|`=Z;Aj<0kc8D$yuBi5?h>wvb?kT7}- z^V*_)K*-IUOJ=C*-?GjvuR|7PUz!D+(s428*{M%3G^!zY+l)OmgW?ZRY0RfKQq~~uyNn?}L@t%UCmi>vyClq@oo{vKPql&UOF7D6{ z4QBnNWpb@5L@-Y02OJFJfKNR?9&kCV*Se9IcU&Bt^Pb#(brDkh16sxlsh2d1cA^Pp zW^^{|ESZ&+0b`6V>}Qj-3}lV~9CoD8l#F2>J*21-Hc0^B9OU)JdFP&RYFjvE^CsNE zlt0J<6~Nj!B%hm{@-f#PK_<)Ed7*cxJ5E%Q(Bm9_1lLEj=-vMS;D46HN>0rpls8;$ zJP}InMdx(T^>HoQ_y$jPu{pm=W#EO2Nt?EJ7(}!1MzopJ7^d{$1RQBEcLaBBm23 zpa7mpIbt$=(irbaV@7~~!? zI9;Lf{6{Awp4j8ksYiYiJbG1gt9--*TzrmB<48?LB6Et%OP?w5O<%aY^M^PUlY9YE z!1G;n!!An<9tR@_I63El zMmfmM(=Q%50zjc$l@rb zv%2!U*q`qsjO}#?VLP+5^H@4x?SbMS6xv%@>#d|~dem~F+g(ForsjDA4WB83K+H(T z8%|Fct)GZsU3hXR#PQp0yGb48CvLz9W;n^f+&BQAKzOf`CAC-}xzh|$?n{PYHZb4< zMl<+huX^(;V=*);$|_AI`P*GLR`ooY;_(!#)TL2-s@A%{O?qEWs{a5Z^yk5zD$~4G zpjhh~Y{pAF=n~=;QO3pxa3?)gfnJ?K^yj0~@8G;|?AwdG%L{4TM>N`erYXW?2UT(N zCgsZSLaw>Wvak$3Lj8|Cb0^sEd>wG{H`vQx+6g}KiHI_&Cy$lz*vCgFBN#tUw8?38 z74Z3xVU9V-74}#xO07yWi}n3aO*K{uOJ;p94A5Rn4a_pTksB%nb_!IGPE$B1-h-(<-ks*#FWMw(vuZ;8MQ zu>@jER=8ffi#AGUXh{B!W~&%&CWuCpeE4A(by&jqYV(k#lG zqLpW8LxS*1Re~L$?p?=|Um9Qd=Unj=VjV+OzrBcK_jfl8>&H)*IQ+q{(y!YK;lMw0wyIR|S!=D?Ux{<_k!M|qhZFF7s{=G>o z;kS}oR=T{FD0uUm%M8i$VC-GPcgusxUUEIo<$;ZmBrMJe1?F|f9)Nm#)(woN=Ha5W zgk7*ZMB^a`J$V_&8z|Kc7zVmakvG`uvZ8 z#!uL*G@QQvy?S|dO?0{8pR_B@r1;u9$eI@WZIoXusf;=<(xHa{g$TqS?+WsnWtA)( z8=Gh*y979uQ#*PB7y?1zy$|&JL*ibCrt1Fy1N=z3!phe%GT-S};Z!Qz!~VJu$AP{n zIA7w}y+{@ERJvRef^AH)LZc_ka=Vxxx>NuO9CAOsU!!0vN9ypCUSDzjA;U4RQzgRG zsGhOY%_ie@<)QY!?2wPF{2RQ`rz>@9cca`|`N+GYe22j-M_iD3cy}yb6pU!sN;R^{{4@n#8+;$IM9#0mapc&r`qSe+-ft+;M;5X6`^oMr5JD&XLlX? zSCB!cVF5jGD{D*fo9U@>;mez=W;T`|WrWLe9^&R-y37U_BqHuO7zE=dtyQ+a`(B+A zW>aq8y^c9I#$#sMN!oB#h*bm;&|r=@ye_T2nhQ3xgM?*#n zh!!*jRY({lXP$ch0G~>udvg*i%IvWsDQ0Fk3I|e2?NBwh&LkzW0R!eZ=N-BppU>3P zZ;}FsWH|>bjP=er9r}Jfs@E%O{{YwZ=r-)Ewf>l8wi*b#)BmioQ#SBu30&bCag^|f-P|O*CCy)sP zj+_rcP?EKoEnil3x?S)pGq*S+k($v`lFDNQOIGLuzXD=jB4g7ZV8bi*$4!U%olOV94>bsV0!efS4MMoK&IA*X=S+; z(r+x6%7#^C*rm8Q+3+Z3Vtzv(-}O7G`usA1U12lY&44sm=gKLAky$oL~|%Mr+xA5ByI)C$+b-)wBh& z*Jrnm;`Obh4I4@kRku2TS(GZSafSd9#sz&R54h8-K6S2%ck6%aZ@}fou4t*pWxwI~ z9lR^7!4PLQSHyKhR=01%C4u~LS^AHNB5w`qZ>n0yZ7sUHy_}`w0x>2L$yCW0+Rd;w z(~U-Uabh z#jKizwDxf7D+-S~Gc*hib~q#)C?x0541#fO;j2PHTw~j%OuiLKA8OmHspBNqWN=P8 z##)#<@p?3JIuDGsZ-_b$Vevh+ju>u^1Z16CxF;i!djV2SWV#HNQffM!nu_1tt()9x z7aKycM`iMX2XWk>5ylj=6NSO2sQfmMPw{-W8fEp~g>`c1W|Ga95}m#G0zGrPq$8D*l=Cf+@X_}R+JH(bZNEu(uR4UPp(Xm&`hR6!pBE2g@@cxk&u{1(xUr>e^ z=X*tUF9JI_NjClE3{k)*gXOk58s)1>6>Qp0J-@H`K8J1_8DB1!H7Pi?vsz!L_0akE z_G$f?N5T(@TDG$BM{yMJFZO-h&Hj;X3l=W}s8Bbb-2&sNR^f6%`tM3mSQ1ovWc92s z?5jh1vKS+k;FEL?=nF>MSxHiQ95alL%tw0Av$wg_-dHbmyHTe`VwNAY5iE@~aIr0lI10?5?cpYo$uMu5KdEsd-Wz-?HywolJ%M|Y!g-ouPz>SF@ z@ZT;;7$EeonQrvy^vj7YZXswVg|@tIFjW5lhoSB{uYk`hQ*|RkEpsJ*lQ+w%LZsxc zs{DnGf!qQ}n@%#}aNGlx!6zK!kPboP7$hM#vxNc33R@hIIt=$5pUl)_dNv3g4DfSH zG_L+ymu;!Su6V~M9>XM_JJ-x!+aBGb?8ddki6De5h9?Etfg1rF07i44nB$Jek;Yv1 z?{M%k&m>D5mSjMP z9Y;9NJ-zxcAy>ySCm z9A}Jt!`~ayVb-&5EJN-93^2jS2fldp>08E0Zj9t*3*yr3Mo}9!=M1@Hfq{dclzNVa zlHF$eB8z|ws#$jj-Mv8}Se>c^5rRM%1duWYO_;QuvXEkter>A6WH-y4@th8$oN_(J zZk-ZKh$aZ)GKsjp^$apXE~Tk_NJ=wll( zHyeK%SoGkBm9B*};SyzqXWg;;f+ac#03McNj))x`I_5UtC~VCPR6&xo29Yw z?}xU+g}=069%GhLpn{|h$M1vFs)3QseL|qZFn(5F%Qf;!Tnj6^dz3#tysI*i*sBKq zmGu-+N%ooU?PU8+z4NWZq|->?W;IX{F(DUfkCi|fEZE%4Pt@c!RY=Yc)bcY3YMFW{ zBxSf+B@G*~F>MU2ia^LMxa98KK^VXwV~n0-#5zjZUYn@pnf}uM063ObbynIJ@V%&GO=en18@mm9EQ&T7Rdu9zG?Wv6#OwkDxmu`GI?e++W2|k-;xK+ zPXHDKfB@-T2f(k3w$>U_L$An|S~bL}5Kk?UUc9n}4gxZf0mA5VqC0Bq?TQ3Zn<6 zP5|eU-79BIn^T6v{U!^2V!*RQBL`pIGVTq|c_Eak10)^@;Nxds(WSkeB(%8lfMCrU z<71B8_RpxKg%9?GFLeYaO{Nw5QjO@Y%ehtk2~{L#ouklK-&2RZ%=SKBRXy8VU+en) z4?6g5Fu(C{#LY8FTgIB##0KJ2^Ab2=d9C9401^`1N4T+7<;Vc-Cb};W_#;--ZCmY9 zA~8AGFxVcsuO0oGd~>Jjza6|~Z#+n1@l5v;#Hz{*OAL9xc;f^Uve(XZ#yA-RmdHF6_RkDyHyU<}qr*6gOSQIRE#y%u;hB}0M>!k5UV|UI=eByO z$CdK(Tg{HWMv`%r?q1V$+p8(;uTh1S$W=Em+rOC`oun1q$_Y?TLBf-P(~(>8S*&)^ zGTgPxA_aGo0hr)rh)@@tXMhJM09QD^6v>bq`_l=LoCmpS56pKSTaYqH3Jw4O3;+O= zP0_Bc^%AkP>1u?l$uxkOepM<_RG*hDz?Q)VJ`{!xS6SJnmCPigR_f_>+vn@~{s#y9 zNqCMg3j8y^YttNdHxt{TxM$qxsLeE_p&bB;FhL}<;{#|X;4c>Vp4Z}*tuDP|dlKI@ zyIP24#oQ(xg#j@-?}|XA{K_43f(jK3KF$5ACek%KoiD?8sXnCbX>lYvRkQ|IySZ(< zN1zC*80WKR*!)59pWvOZgC)>4EjLipwA*``QpVx!u7t3p@42I8Anw5lAUt78fPV0= z#&b+%3|@CqntuEjD(z*fx6gk=^*$2GaQJ!>&GE2PjbCy)+m$UYpPS$0y4a!N--jB% zjrBX}C%n_Ni)%o24N~&qV}jbrS=d7yWr8o6Cf6wxY|-N#Hn3C1+HQh;RGJrt?JuVC zS)-ESqgJ}SmB=xZYDn_OqA$&c!NC{_;D3nm=$f2U$E?e%+v)cK^v~^hQrbA~SR$>& zgL?)sv2DA~)d~p*b1hWW{vddhNx8byyh-7`KU=$w<-E)3V2oUOfr%_+KnY`#ckbso z8Q|A6=-yXtBkXZmVxxt1FpBYN>i+;`bkZv6dFi3dUL}N5%@QZtVPeHEZQZRf+YSLz zBvFPSuHi!O9bQ47DkDI^li0P3*9@!w~c)U zofeTS+l0Psv52>`vH4781WB~x4UjN;0sx-hNqf}1j7NBqL9v=^J84=UB(YNN=vi^R z#DHxe@9GEvxn)(WMee;ky8i&feeC&p)3)#W{67P5UkLa;z>{>QOGuRw^SEp(8F8FsWP_5Wa9CFi z-W-=u)Gh7cS)a|2M#9w$&&k2zcAWLXkQyUYG7iy*`nNRs@3ETE9YS1WT}O})zWKJMU6QfkmMSk-s^Dy^)*s$R!vdvu2@TS z>zS@5IdU?<7jy2+22XY#wLogg6X%W%LyKzm)FSSVhInw9KQ3z@Q@*%M!S)AxSYn-~ zx5-m7XBf0)N zR$7g`mk~#I5;%5(V!1tx*5~6$hqj zp*3Ebsb{9_=uJg5iu&a1v13S>s2I;v`PVsYT_!QWdbc9BZ#58$xS)5z0m$S7jE~TM zRn5(-Lt%6!Pnp>A2Wsf2DOPS%TlErmN+|C%w9?|9-tl3zj#9xSvIbn?6+U+-w1l_a zY~(ixHs^AGD(g*^5mzK&ab7p9_?G3O-E5@3bH#KX7xB}rqQP9L&Of|| zbER5M^AgA4>F=)!d90{gEze5rEW9COq}$|2z12b9dLM7|>shus^mj7i)p@}uaSOl% z_wUC}^y|HTQVMg=7_UNlfTmsP=4fM?WLIWg#4d8DBw*xq<2+}m?~o}Xo`7v`1_#sppT@lk zSajRwVw9GKH;dsC*ti3drnr9!%^oJTxJD!r*78JA400Bab4Yd;kv1a$nHXl=58hVw zZ9>lCbhKq_c9PhB?OFt7Y%y<~6~vsA_w&XVpaQ&l{@#BM>n0~18*n4ItBrW`dp^B^ zUE1fk=r-#;ng?dvw*-Nnam8QOZtZQLg$oV99m;E*@NTnjK9>j+F}NsPexCVfus8%t;&=H_=;p@tPNB#y@cRY1ukkN_N#Yrpa5 zjC4;5O8Op+ZEq%`5P7V)aTsBd)2Sfz?a=dHYLd?_^|hQ)v)x=UM2iF+#NZBpKgNu| zFnNX_+xs^J_P=iS=y+VavRN#U9@RCHi+M{ZjFPOMItLhKAn-vM!Qk}=1Ym$`p|kJ~ zrF*QUjIzgP7>Q+?S&HwAvoDscNnpXSG6@?=+q$}G{2k#B3tKFjt;Dj(220#r`FoGD zum1pEyzIk?a9Am-PEK_nNb3IpGB0$Q!p$njZ5(L~Xa+YpT(gi^f=SLeY+zs=qa!0U zLo3LcE00h)0#+TXo9?{Hw3pD6J7te7f~H8yza@ zA2ZCBMInE76?X<~jtR)X#!h+U0giGuppJOZWxpEK`+Nbqg<;4duG#4GYWjWU&60hf zS_NsNjBa&9oIVFt$ic@-%5~IIb5=$=qnkRkur||^Q%kEXq7 zXOiV^Wf93Qm;2dFagDK|U@~wpqjM^bG4lcGnQy|0KX)`!Mo~y8tT4o6f=)VT9Wjt_ zNFdgbt;+dV_3CRKG-o++r8VOTB#9$ns}NL;5D+1HM+99 zxX5C9Q=Z($U5FqPlau~&T`--k9lLZ!(bwi3AoKtd<2|0C%p%AfCqZ3z(KRcwJ;w znkAJ92P1MYD#MOL0uMqBQ+p4ytWPGw9d|C*1c8z{0Pu1T2L~8E2(G19LER-|Hrq~U zre87;#T(|0@-u_TIKiz+boAQd)+UAaC67ZT(f03A%Gd%^L)7^XXIQF&Uq(WT}+#{-`4*C*F&C#C4=q~s(5kz;nyFS z2k@(w4EIWtFPXALQmE;)j`<*f!1KsGG0<~ckyu0&Gbqn+Dp2lpcC7yZ zuj{!^I~SM&MOcSP0D^H(ms1dr-Sz3kWa@4sQyjGl4@;7y$aM*&iEy~cB<|-Y zryu9mx%;g`c3=r4cdeVt^_&no)@{wY7*i3yni-2Za8#}a&=(vKcVy(}BY}>4X*Kqd zZMV3~EkS@Wkh@rhB&g3B=z4+APW*Zb)i)}UN&BRgop!4+Y{qg9;erNm2VopgJvmT=1) zZUeV6fHtm3<*~_8)70Q(<1U6fnS8XCMkx{%j5#DA$!(+4B$M@Gdz$Q0QQ7Qnp(SY{ zi)6~JC{kkt0>E$y&H+Dx{HjkpfsFA}3rSpbr>>JT4aHa?;6Gw}kHe45Qc_WV@f?=2 zw&MQ)!X+n+bWLmLM6N(HHzAsy?3tzp{uC$|Rb!#!6 zNnKXnsD0s~J-H`d+Nf!HMZL$%Z9l@Lq z4hYHOyhp`4bhi4Yv3+9|#onEGkzCwdSwzyr!cd&&91YG_C%a?4c$m1}w>K+#Tk*g7 zm6_#E7-(iNY2Vy&zr4EM-JZ+L=X@>WtIr2`n@-oX#|wF5FPN&w<&Y7EM)g)w%6*M~ zh>+P17DQ!FCcj+#P?WE71N0 zd`^quw}>UNxVV4pEkTq_=;fNyT>P^E>5MB5;|HpW>dUc;gt>J|_vm;yTJVG9vtFqE z2k^z6fVs4{eKyu~l4jpLtVFSbtVZ4mVq0j~N`MI<{Gm^=_;*fSHrz=vrK~5I#6iNh5SXPT8+jliSB{HA}^E<*kfNKe%s%( zJ=FSz_r)Ix>FIZGAPufq1o@Dt$J-G=`IL-#oxvNq1&I0+OV#xjv{>SS6uXH8kGfNW z`I!fxG^-bd5F>t!A=l^^8MuC z*O{5+@y1bcsJHXm)6>xNs?nv3gTq$QbZhfJByFwl6)jEac8+NL*dVt|B#F6{up@#u zD=tAisU3}aKZ86Qt7}MJFm&xcTX_Vs@`*G^R!2?W^3#~rwvIC%Nd;@${w@3r(>@V+ z@(n^gF4`>`>PEYQ+%RifctiPn2-u614HTa+JO>4sWslCk8-Hxy4)`(yrg(PcJWa3J zO`BJ>7}M?5qA1An0vb{`5<-BxvH`~@@>t|`X=0RRs(0J&*VSL}THMbq;93@Vymf6B z(OO+Qrl0DUN4USXKg7!)1bhyQ!1k7_1@5VPut74TylDev=TZszWj%M|#10 z=KW@^Hz!Tn@BF_*pVPb{q-YlsT-sU(zmO3v{lW8YWY1mFH#k0vf$ds0ma$tYSYk+2 zvpHDC&{>$X91i4?N$yGYtqZ78?j?9plH9TK(nj3MGr%kW1mhgweJV2pMyg04Fvusk z&Of26P~}!f4COkFw)OtMtxEDT`O6j`aZKio=#~EiJ5o?t=ib0S;%9P&|qUccl4{#D2!trDpZnoiBYmStD7mU@KzU;mIG>I zAQAyQk-*MRd=4^rsUxzIMc^_V_Q$XFu8L2au>7eK7ZJ3ojAK$2m<|Csz$e#(`5L!Z z-PE>}m5fHy+DN5^%z-3QjAM320E}?TK?mFrMSBmx+mjxpdmO6bZzX26MVO}Y7^9M- z8;02kAmn9K^#dMjB!j*yQ^8urzk&5_BJSGNqOr5t3qfURg^m^>8zi0;Re4q5YzL)n zhpwq&AwG$(xtY_WI@IL`uI>2leMW6W_i#+|vs<7f+Oi`?@3|QyIa0xpr^|u|%t-`= zE2|~N#Pcj7*}R91n94ToRs?*f=E*D@BMLy+*&`|r6foJ|UPGw;o*8c8iQ$Uu#u?Hn zHZv-}U9IKDNE{N2o`SkPK4R(2Zb$c#b6=&_Nu?$If0^?(<&~N~S@^;5%fmi0@qVX! zcc*=d@#NYjNOqZ602#^Zaz}dO?LHNFgGY?6ov6ii1~#hRN3u{meCjfOrw8j^$NNoq zlUn$0G+z?$muAd-Hp4lcsR#WIj@v{EqI^99w+#z;w@iD)Y|UG`sHUwwbR>s zlNyKSO}P1kbB}uW@7UK*g2I0i_?qiRxv|nMt(Fwk^@#!i0WUCKfk05Czc?NF#eO%J zW;pNdT9`aEuL;fTt5!|DJ$gNllfmM#QpC=JcZ+wsyR!22KC!b?8Y;Oi_(8DA# zyqj{&v9mKqq=G`TF&O}{JelUaiYVSmRc+vnOUT8zRRb(}>3|J>wC1gM)6n|2Xg2S$ z^55-C@sM~%`@;}x8dbgJwV90SkXn&(_FLJb-3f+A^2x%huI5$ZMK~G7VrbtVbRUSL zOVb}s)>BrwkXzYZ-aHSq=ZM^?+*P^9QVAID51jmC@a5l$;qfM^;%hlPt$O9AonV|Y zvMcR|SkxW6;z=Ug(;*xmylcol7wZ=~9;J1lYULuwZ0pbiHo0;Cj>Cs+>DK* zr#b$2%yIPb_^P>u2drCNeifp&uTK7_!aT-tt1M<2>ldomSgzgv?x)tV7$AZut>KOf zbVW$xgkcpx0027|{{YwYjTV$AdTU)Z)MP!z z1SyEo%N&j0b$B3s=5BC0_c%D8X1lvE#WPDL^f5|p@fw0xCHn4OMh4PI-`^P;-`VXF zRBtl{py%fB{?L{Q2;6J0l}%kUImzDa>&h*= zh05{+e1s9m&u;wl=z8-}3FEp#@$UdLC(eZqK_Kuz<0FI6j1iDQtZQqTr`@;!GxCsg zk%7tMj&tk%D@GrYzJ5U;!hN``>P6n{x>AbwUjG26^>0GpxIS9OLhVvm9iu$ssp>}^ zJ9Fx3tvd;q2&OHka~Ao5;{XhDI`f_|I62`FK^Z2gUdtIF8w6w=5zvE?{VF4)NZMN$ zE-wQ_Msb?Tna5tHmQv)PRA&ruNX8UkoSdGVV~q73vPBgWt4PwivNHn`h{}~+qlU>j zIoxxB!2=v*>CWXH#Nlm7^-Jr!o0y&1t_Bu2d1O146*j0G9ODY0Z~*!Meu?;DuAN`P znrq4dF`&0B8$UE)FFbLa3i;)kBN3`aknDF9zze}8k3G*EWaRxw^*@4_j}DisV{_ZX zbs2OG%7%@yN3Y%eE9tUIp3f4ZwvvC>;CXo1b3r@odx(%@1RkAnQC-`m?X=d?fMS^n zH@cC7{OTP#c<;;ITi!g@?23W{7i>%m5_)dSZUE$w@-6`9FS8d3Wo|B>DK7lA6Fd== zjh`PeRV0uzoB~cgO@4sv^gemCpPBG)#yE4~okzpsCi_fUb=$EyB$zHCkN`V;sM)S! z3mZ!>4%=DDZ}zAX)E9(?#J?#g8+ib*190dj$>O~m_MLeA3-QasruQqQ_>A1Zt;&o^ z2!>?Os+SG!8ob_VE<7ABbnEhFlE4djqFF>{WK|9sWMIKVEK!ie9A}}%EAkxHgs9F} z>8Hureyy)V^~|Qc>R{oAkHaU-zr>d+`KzK0JL8Xw{v2x~P4ca8^l0PV1)ajO{^UZ^ zt0RUixkNuH$j&}dn)S^m_NVZEqoYjMca!*QQd@Z*D_u!=l20ehP?D}yi3LavwDZ!w zUATi!gI0#$PZKOJZU~a(?lQ>M600x+I1J>BWrjh)#sHgIu(!O2N|i#CYl}n1KZ{F7 zU(W~ftuox>6NJM>DRV{HS@wM2AGR3pJ<3%A5R3J^=9zzlN7d)-AOQ zST$=)h%RAaBL4sZ-0DFal(E`3WMFR?JGlKGm*O9VtUNzyt={XpZkMIqTP@U5-d!Yr zBPd`-=E)!&5CP;@<==+%$mZAV<0`BSpcNw^*IA{gipNs>X#EDw{2O_WgAHHAj`&Yop_x?dGd*sY`3A z$#pH&rM=y<&jKqFssKIO5*Hw39j73S*NT48m$BT`N&GphTiDL~B!GlRCmBJ|A90LVZ~Ig0J{<6$gm3I` zE)i`!OpO(tzn)jicVh1$0ZTM-%0qGv0otRQ{#v14y*&CZOPRS{Y0^&eetVx`z?@9p z6B&?Wr=m_&+p^zQmsZ}5Yo@2c8h)r&`r6*=SyIHf59O$5RqudE$sU;0N9ehKlp@Z;UbCg-rdv}-Ha2R>|00AY-bkV3HZWmDR}S*-|FNyT2z zQ}j%$GOJBalbxGdH~D%Vp>ur|#g2)t+T4prxD2F+Fo@-huJIhGHw7WluycY~0e~uw zfv0`1RD;i+&J!$)6jR?AmKci=0hU$95ChARk&MO%_N;o+gs`IZUwK zu>d4=U8qUx&fiMc)3v15p4u@nW>bPxuyO}L3S%q$tHs1pl{L(^d!DXgf`v&slp5x; za*F#cuj|m3&L|el%_{kmOvL=N#937JAKplZ2rb)r+RodR+W_{bs;4vf|9Ou76c>Z3ZzJmpZilY|ZuV3r%x$=0-U$v~% zUW-pt)l*p0wGWm^(C5@u9Z$k~b^7j9+8dBZdf)~9!`(ddO(e5O#ogp>%*6F1_ayV0 z>9nnMOk<7bSXGzVUqisj(8lpAI>gKw z&>xhMkO>~6C-XJ*6W`iSxOG$Y6_KfF`dqfpGbC6H7X`pr4G!Q;>RupKQGzS(Vi*&i1^^fx^O8n5H0!Mqp^iT>!4HN3AD$0*i;Cf7mPSi2WjAgeYP1ty-P{HcI>O7n4hRmpE0O9lq-WG0O19cP#q+30+&cn6DnR2M z`giH|^fmI>ToyAKR#lw!dYf|HD|wdpeHM{{SKs zTz6mB{4=r2=}s8i+f5o<7?7h#g^85zW4W?O&In(W0fiiABk zmCT)(8mgL%me4xP9SB?}e4O|6<2AIXPPIqus#+;(x(!Ck=A?62x19yyl{ZAf7KaBC zbzQ*qQ;ZxCMt5_Kt%Wa#ZKiUOIxWFRmomz+j4GBqu>hO{fz+G;2_RJ+Zt%xgo>%4sa8?}^FQ#cr4bDp)fC2acz^+p3T6k_s$sCfP zRshC!oZ#&_!QdPcJB~6sj*G&YqhC0QWJP_X{LBYIp7qlz?g_ntE2}e9=#fJaa!Fyv zE5^PiSQXV`aJ&(W*Q0o!QPea`pjDPgieQA!Smd0Xp1gs^1_1ApeCx%YD}zvB6oF%D z7h{)TCF6Se7b3CB;T(6`=f=?lrI*& z=Wmx!_dGsLcjg%`GVz&5E4^|u zqthT)P2+D0HP)u4&Q)txMPk9To;w=M)-Pl5m9?ymXlFL;K0)%Hah`h)2jnZ~XkseT zsTtFgRUWIl?ee*eIurJZOK*!eU2g5xVM7f^k^4`-?x^@hSEDFgJ1`Z0YFgX~< zLOAJNW~1QE9`gB~Z4TPVlu3l}uE=Hin{;7O!41G9l?pcu6V4lTtZABr)-zf|CA&!( zk=U|qLJHweIVA2RU=i}1k;ZyyHA^e|SOl{SD(oxBJ$lxk+nJ^#7NuUR_pY10t*TmE zPMucI(VEH;j9Rw64>i5G@a~Mi@ix$`?;0=N!-X+Q%A}o*B9K@O$F4}ptUV7#({8RM z)qcnl2^`3lXXa7R5=rH-0Kh(+=Dk|yNAPZ~cXI@i8Dq9WWC+EIHUSwJ_aB{ecRn1o zluC5hVikxBkX1$i+Auwd=RIrdaMiK6SWXkJ-Si@ytBS>5W|Y<99rtqR^- zs;8eB0F`jOH)0Ml2o<;CZA#a{#&IkjVcZpz?Zy~@cw>SGQaa#qlf_4K8gYpNxj%gwN9Ky^FSZ~^K!x-Yq92dJwucy{Mr)UEFp z=vwTU+TSWhN_?nDd=HsPVh1_ka0goYEFOJ{p@C1H^0U8N+e`ldT3o?Gae8RKtq&U1 zye}$vg7(KrH#g8*NMaMu0g0HX0f|%FIosH%1Y{6u){)`OM)}$?Zp|XL-MI^s^&}70 zy;dDY*H6?LRKzmjNyrRI&p7IO9(!@>DSSmI+H_eS%TJO-mJ_+h?)E9hHz~o~ea8ea zIL-~>__n?#QjF>HK_t_EuFF-jTl6g}X}dVcejn=kSQodtay6Vxe9`AC0gi!!HbRm> zBp<|8FBNJKE`bG?ma*Ix^W#f+03}Zf9D)xan1jjS41C!gCxVfuvANN1Zc=Lmi5()l z21(LWBOH~$3mDp)d16>G1#l~CNwKx?J=`)uZ*#3`_Xo|NTZ$#~EwBkC5t|Xl2vyI_ z#FZEe(}N3~OB0zTimaa|u}L(grS-k`>wl0+5`*UGcqHQYPQ3Cjt!^yl+89T33r59H zLxuq3-n32oNh?Ufa?D(WP^**Z1^^zzitF`HhTq!y<^AB-XV#(wwAV37_M4aq$;_z4 zF&#Q!k4k_1E2}#X2T816YKsSwB$4HeiD6_I+tUO#eo{H-7_ZW2o!}!CIm@bBJ>Nq$ zB=k8ax&{_*I3Bgj>Uy$?7z59xb{dv}ac&{Ep5AMFiB;YtkYmk7Aa6nr7;?Qcjl(!5 zxm%wIT-{y;Sf^;g1;VBqAH{%u>)oSXgO9Z5O?v)a{zpnlM(m+!swBBHvwVnFG>t$k z$2cV8peH9eCx9{4cPurDV2ujNwaY4!BQ3!o5rA{ZBxj)NasjQIzk;`xx^ikdY{vT7 zsA(gfGYruG0KBJz_*E@C!&9};fya{PfX* zg+VNbAP|bIa0xs!V>mntbt33kd z4=(og+15o7#S4?ZPykRpLI42z00n!rGfXZf9Q0OCLz{eA~6O-I328h(K_tYIb8?g(a{MIj)KPFrXh zJe)7NKGnj%hp%*c*>0grMnWW*GC}Lv$qjq-M_wM>%{Iw=D(rxrP3Dh~xG2XK-nPFK0 zjaahaGZU0K2O|fs1aqDV?}9GFJeLi*@ONahXPoxq^QK&Balm4aFxn)|yLsVMyAoNp z^vNXi>z?_q+#@9gc?l)Y%C}@J_NG}Pw{p-UxsKvwDI|;hJCrj8Jah!$vC9#H5)3nA zikDdM-m{~5_ZN0k$bjU|ggQuDkVEGLp4l})_r=IKHLWSaym_ffNqIE?03%r4uQNvO z$9xQ8WLXILS(hLjagmXq&Z#J1&I+=HI0WP7;Br5gK9yq9_{cUlTCo+o++9SGLT)5y z3b6o5a!(6^$`{%ydBPtDZ<) zfJhwyBR;+8TXsDKPc_bjud$PNZMr~JLU0BKeLC~&)}3i=irb2f#9*HFvkr}Mc>5uV zrhF5%22KZ4O>HVzWZ5D#ZaRlj59?Yw)_kq*5~Sj-VX#FfkYw`6MQx>*B|$mcla8bU zaqN963xt0mrUiDZ1R%H#k_i}Gf+BTH$rJ3 zwOAe55md-`IRgcb0Lk^>0r(2A>LyS&1;D{0JoD850G`!tr}1oT(v`Wj{=1XgU5CIX zv@PO{P3QSZBYdH^3~vCE6p@3DPk!gtu#8egBz{_kR%|(D!2<`8!O6}BIN+0>Fm!G1 z9WlYj(y(!R#N?b3`IgdQdu0KNe?HN_eGx|88QcfVxZ{u)k=Fu|Z4)^onz?BpxELc9 zVQhrOfZg)22ZA}^@O%FNk?T;ZB<&`5(&e+V(91CdY#!e4G$r$6ezgmf( zkN_l;fIVwQ?@N=a)^zuiX!e&9O!6(1Al=IzPS!CaFeG%yUEmYbarLWJIh~U>_glH} z-|buCYabE(TAgehO&*@ZdR2G7utORV!u@;yzj%_Io7-}EG=PuByq(knPFqP zFylCFr+|4pjCHJ?S5li-zn1EEW_LUmB#;LIet=*PU@FF*Y^-iACZ2CQcv&+HVo$R$ zmyslLC|Q0*Ctz)-dgZufuWQ-C`@65}`rMv(7P(IPKa6o-T3oKBrZ3xMW-E1Q+cCJu z)k($_BLF|?q4X8Cq4Nb zs%;{Y3yXMXbz6ub3S&_00#d6*H6cXFpMXM zsX0Yod)Z#+)PDf}GT2AL1|kmKmgRAkIS+>1da8gimikx2+U@U# zrBKsK(Qa*$s061y2m`lI!)*wl2*dAF>jCr!S=@k=qv0$*{kD{_~S`k7h03g z)~y1p!TD>bM*?6nGUJ789=JXD>cC=c9!C8i>Hh!#^KjBqf{FHDf;@9^eK|+f!obm} ziZGa9*=^Zu;DR>bU}Fc9k&jgH_lU0GzHx41yP6=bVv~)rwm>X1&Pu)r0B}cQYvyeO zMbmCpG}C62NSQE+0r^lB0}4BE2;6Igjg;<#mH$sTHzCmr#ebLf9M`q)(V zzU;MM(jufId%^4f00i_;jNS;+e`ueDdgp??V`kPinu0Rh&u@pG(sg6yPKpW7ARW0G zQdNM-75JI^Q2xpuFa4JMPibSXh;`Poc0`bKEt@C{9O1+;h)-n;obiLf_!CZ8IDyl+g(Dqn#y%j z65{{B-@DyjSoG$NnSmw}>?Pt}b+_zS1u2 z<8uR9w6Vt(=5~?f$kGVMAghq4kOO^5`wr_ZsQeb5$<9ultWO$`MUpt&f8*Q#09yH6 zb4onjpXht)w5Y|k)gJt9+Ch*ht77>F6?G-pdFfOAt>b7(w3OS*B>w0m&I$_^%hLOZw_@ePzCdCbU+O{D(5c8I@ds2>|1e3BmOlIHntC6U7jMRt~Ps zFx%y!8+NJrw-Vc!j4l8}k_iCmnc$hwx+64aauyjXa7IU=!6WfCa9qUDBz{r{R%SAS zcQ7DfPdLEABLhD%$vj~C(^pLiVq~;v!fiSC91ba0Pjc$J7=j7i#s^M4dC%x;ZP{6q z4pV8)#mCGrcszbQW1y*Gk{!gjEHHlQ$2jBl$NvCYq2m+LJy4b^THSxaF;`4u{34p# zOLYRbgGboV(jXN-U44Y{Pogm_wAFjtbhwn;LeeOC)pwZJJg{CffzB!h(;0~yPC(C2 zKRVuffcpay!A2AloD7qJ_>zAbh|L*bo3^_tAObUvGC=mo{OJ16TO7)(z3s2{{{RHW zZ6*3HNWkhb%~Li~OpNh5%7K}3haeDe!|Dhi`ePN>qoEieRG+(J*i?OTds}doWyyEC z{o_tprBaNvB(hSQzeSaB@ICSt=b%4!^_OHOGX^p^WEHO-e7W zpN7w;Q{T%gzijHk>YksW>p!wL#9Pl1d_MmGgmc6fGT7^W8UFx@e2p`0^WYf}%%uJ7 z1t?E)fd2p$eH)=%J>{eli_1uEOkl|z7e&xEISTk)z#fEwpFz^UGQ0!vC4L@w-qTr* zOG#{6;i9^TkNEhucf$g#*}|MQ2P#*NEA$V-y5ENN3$L@Kta@em`L-a)ag6R(95RqW z2hi8*xmGjlRCy<)mY%Djm^8g`pC#e}j)G8dXTq?H27prSN>UIBMWT%mZ*-;j_F z39r|$wb|+SR;c=<5WUpCToKF)nWPHNQB?iowH>fKD;|K?#=o@>?BD+Y2uacWL!xRI zmtGrvQra2xtCfoWBYk)G-4}C!aD{ z0@zP1K3N@$038S!z#E9~nS4ib8%VbR10%dl!@1jK=O6rf?T_nOJ_iYWEg_H}C~6Vf z%5%~yxTLmY_&Y*>D*V~uskt?7ojSD??f0gAHC_eEF*r<4A~f7wC1q!2Z!@BA4qMA% zCHcR+`y_Lla{mBiY_s4t0_S#0up`?iv8y)LItHISOLKW}?(S7T(HCp&lgnack<}GL zWNst@j%y3U+T_+6XiKXYH5lV`Gud25a#>JS)TPSf&G9Q* z*X_~ndIYxGriG~~(CthTG`CieTulY;5_9!HWFWDgS<$U>lk*xC=wFbT=|hzDuM z(&Y6P0t&2Y`_{AHOJ41Is~Y%zH( znvB9HW%Gdz21H>%mpg__ox6bYV>RWUwI7bA@PER)yJVCt=C3m8kd-{DGP6v>{@JT8 zf7=zuc>e(71^)oTtuIb(F^u=~Ui|hKpa^auJK5s`F_bDiv{wUy$+*AHYs)`t?}?T^ zDVO2OY0oMome&{3+f9J*v%1B#z~ge5oMdoEBZFUCnPw^FRjSj`*{_?jKRV(JHC(?B zH+$)1{{XMO6m(6#B=8}I^tiS%3u0M5LLE#}Z9UEs-%)G<_q+7ST4R1;N)e zxc<*@^4u3|oxge6ii7u-bMH-Nt zV>(*J*{@EWj>6t#2OtV0pBCX+o!ES(m<`R_CmeIvkJ7pOYsQUvHD)bVDcR8_Mrh90 z0OYB_I6j92AH-G;l$5T{rQf10$tF3?MHR>IPFNMdz{f+*dHktWqLqk}MpfFx7}PP{ zgS2BKf^Z4!GH`K&UN~0?fWl0iw(bby0~q7KIqo`RnX^brR=V%~e7~e#yL6u2)T**L zAgLJ`^yax8QLx-1f*6wgK7{RE! z6Rw~J6r2;%sa`hoob{@b7e+YEH_VV8S11&cMoG`G$35w)cG&CYxnVC1V+5LQv@rRS zyimsz&f-Z~6aym#3K_A~?Nu2)ln&J_mgwa|63eu5$`t1u_dWB^agLoS@!H88X6MdD zi3mdpKv|1qh6i{c4WtpY=WzL#05l~CySHLuDPtoO(rz)|U(Vk{9ZV_qi{{RHq(V@1wkk4gv_GXXe zc$4n}I)(?eZ#>*%8;`AV-YM~4hyEL%aEiG_fR+fgLE zV@W{Bjiy3f4sgyUj4?Yi^k3~!tZIJ}KWF~{8fdo~eT~9BkK1Bfb&+IAAZ3^`lEf2* za7H-~LILQ0J0Ch3 zmaunQKARs+f{rUIarT-uua^G+@DF{}^*7V6Z1qcct=ito=-SIFr|wUZKr!tr@|8xA zWRg!_2C;2!+O$@%EzbEiYu)0O*l+3oi>eHBdR73<2Z;^TO&FZPzH z^wHUU#?6h-_BV(jk%K6L?zv}cG{1c0X?2X zoP_lxp2og-pHuOq_oMB81=OLqnYy*Ku^}LGa>E0ra!r1pe#;*U<@kBI>k4?;Rzy`bW%(k_BXYZH0cHf$5qv$xRh_H3&VevRRm#GzI ztku5@Y2! z#1wV@5xZva>l+7jd;sbI-c4xRHMom!nZM>NcQIl~B$Ll>cpU)Xa4Rcb)f(>7OG`~c zZ8k?%Rk^kiw2DeHh5|%&%90pl5tSJPWl!n~(Mej&{5~?9W|g{#Ww@F)l#8MyWR^x? zt+X%72_Z%laJU)C#z`mS-`amxn_2ObX^zp}TInF$jqE{L;gc+4)fEpp=S zKtbTj41sgd?Hqms)K}*mWr}gYx-i~XeObkUFU--H#)Y$-nUK_SeJr(mSdxq@X0qN6N}h2tR?Z!5%tP-7H)e z?uYV)v%ijpyuA?TId1G~>-bSo0Ah@(5PUHz->$r?4 zWdI&Cj!j@m;CL)9(3w@#F*sXhX;Ak0Ne3CnKhD0Q{i9?^ZL|*$TqoNku$Ft6Zmwc! zTHXc=3^y-Ut9}G85&!B37~SPDt87X<0pgDHTRfY##oAR ztp?(n+TSk6^;Sce&mV%Pjf^>5x;=J&W*quUy@ZP=l`}}22KSOSR>(O}a5I2LIT&Nc za7HElji);IcVh}Mxuzu}cFsT_wF@e#QknSTAWSp_duI|#>#%V4seAaeOVrxRJ z9F38~D}Yym&5#MofsS%3U3h4ClJefh$_egnW{_Lk+r)_PG=$(P0n0HYC{-CG*KG`J zp*u@s-i3O!>bylcH9Pm;@7&n%mbq`Q#cgYGa}3zqEyDTAWRfBS@RCcX1dN(FF<=hDHPKpvtMi$Y#Qi;T-0^M7q%Ld>yLW3yW)r7GknORBe?5 zfXGyT5MV(AxFZ$zH^a;QTHnGtU9O~I7m&nvd2s}Cti_ki?$7gbPDtQ%QHNIr8BX;6 z9m&nHROf^G%@&m9qgwX8*Y)%0dRkkFj#z`uP}U-jIEY*mO?#G+qKGH-Uo;h+~d}ob&&&L99K7Is~DC=WLX*40hpYj$T=82a52tC3F9~-$DET4lg)Cv zPCpUI<9DIaCbDE)hd-TDyS{UixR5&z^-9+B?4?19DdO`MMvg`M`*ZS^7+?-gKqEYN z0EnYcwOqv$+(&C0xK^G}#UljZvjqjPI-CN10i>zL6yt4CX|;#b+DWaZlHV&L60CO^ z*-EnHW2%x5VhI@LxX<`VqPn_~Oa!h-$rYnGcQ`#M^4Q04g5hmUX%i}!jK0m~2>H1= zErx6!Gm-$n$BB=?y3JbFU)S{i0N^8N$+fRf>-zryfI*_97MI>~4WW-ByXkyGV!CSL z+srvt!e9^y0JkJ?0m<)xa0$RXJ*}qQ%tiq`R!@lU?qe`2GdPg4sZ+R(pmoRH7$XGW z91t^ujw&#Q5n1irwK;5#-hUOxZMh*$`BK4|PB1al^aqj0etivj-luyux2Ut3L}hcd z1Hh_!o}n~)eCZ;}&d3BtIRb=z%1K~U?i)i40=$rOfK=f{sllb<=HoJyJA`b+D|;Lf zk!Ot9$ zF~&|*dDX6(a;Iy0*8Eylp98Fsq@GY_Rgy*8UkA`+1A@63IQ8bUmKHSW$;V5Yw>jGP z{n&Ny#}Rn(<>^r?cghwD0|YU#@NKZ|nTd+e@C+ z<17YL`qE9PX`*r^4yqNoMGkjh?HD9*P60iE&JHknzYko$mQwaYFaXIp;0yuzR>Pe? z%bzj4#Ha3_HwqQE4p?CB8+Qx>IUwhZA2M|+LZzWONx#V5OSz`Hsx`lqsx}df0Y~;^ zzh*`ND#*le!#n}os2q>4K8CqzFJ`s5jH))kdE&L?@dVydzuhC&x2r5~tVJQ595y0N#CW|}jXJQ8w7IqB)vyRcR3NzPcQ`_AopiZHUW(A3rLvVPGFrz}p= zbHN`<;Jj0Ht9XtW?XIrwE^Xj!!xXZ!q-SDBl?32?kwm<9jR!>->*u+*HlhNI?;2qW zFacX5k}z^Hk&Zba=ZqZL{79Z30i%$`aOm-q8x&P{tqfjmRyTcU>;$2dF+^Zx)D+*sV*TuUMQNtW6rf?G!N zk|{nycd3srh2De>xpEtG0rT>*>vwuY0y}4H5!4<of5!{B1Iy(HV9)4^tM9;~68$rqowaK&bMTG9#B?G8a}x2Wk7Hjmv}8TxahibJgj0V#7-k%;zMZ zbDvuBF_bwgdq}HEHj?f0Tifm~SgAd}^FK|nzr59&!Q^P7MBI{RgM<9*ua53Wk{nD* zqLeF3MO1uS)!i~7k zLI!ep&PnOTcGQ(Tbw6<^$~)h!^wC-BwQao>w0E`gyqmgz*Xi8Z(REvkDd)bH4XrU~ zoe{SJxj4bV>M%&`PBL+mXH@aL8a=wpVGMvXsSC4y0m9?u8OC`e=NTs;4ls1xWBW@> z`zkUcu~1o)Z~zgI21h+WJ&!m&D@M~vf$WJgtcazPaO7kh5%>^C;at@*ij_%8$4jKU z^n1J7_S30yl#<)}{*c{!Ubnl7Jz{HVCRK^t+CjI>m4M@J<_nYec_3trky*Yb)aSXl zU3*A*9ZZF%NeM2@c_$~XYf9SI)=0j^JCP1SUqFAtvHV@H>H2%crlgB!Zb*;MRcSVo z1{stLg)Nct?l>6$pO(EW1yz{I;hIVJ@2;J@`MveFinN?liqRZDh<+p9S?l)AYX#k) zm6S^)1BC?gc9JrEvMbi~{{R+WXc`2%gO4I<*K?^}3C0ONymzi2!dfreF(2&hsU;dx;cVstX?D0T1`3#qVs2hEzjkG#YQ~`B>N6)w=Whbxo zsoiM4C2td4NF~{AI8zKRoqqAhBC?h0foOHmhLR7Gd z?FF>UD~3sASAB@9xkH?VJpm*G)Zk7|U%nT|RxcMiS&!=0=Z-?`P9(?A{m_SRibo?HL38pF^H_sk|kpG^RVd zt4QN>8c6Y@?h5_D%8cWT9{K1iBSF=r)HKUTu9tad+J|Xiyc3c@;Ba|38NljAZ`kTS zUBnj1%;7MmR>#bJPB{Fl%%f8gfuksLHz$3sruKH*e(Tc4oRHxD&!Fsj;I2}j_15AkOwxCHJq+%SSK^PfRoDi(ROAPcl1defv z={3!I+dxTL;gTC;82#WsHshS+V}L$f9Gv9vG0&H3nk~19T;FLrzVzH#(j$!b!N()( zTRQfg>tCMA^60eNO)sk7Yg@V(DzZrKr|_HV*U~|EapYQCktLnopjMb*0u{Fc2h2Ig zL7!T}@qUSUW|75Z1PolAzFQZNbIE4t3G@|b!edjG+7f*{) zNxpC!3nL7c9<}D9dA}xWfR2&0fd|Jx$rZU{#DOjWF9-x zSz0BixhuD1fU?Kj5nk)Bi>C$kYK=YGzR#+)zVG-aEjc9lqEx-`^4>kw^|i#FX^bp! ztCm+!mx4G0qhRuWn~ocycyd1wwCK}9CIll4o;v`2YpU?9)>gI+F!{rrjpGKHq#~VK z_G@VsBa@NkfIeaCUq15+_?n!O(ooSqNBuo7qxri?0zx=GVrOXFykyt7 z_?{~p%{n)~wvOSHGKHB7Mz{ruZptvHlY!86BD1tjHr+IvP5yElG(<)h3}juawMmhQ zW*`o%Nhgv?ub|2EwAAb3VR^gXPrawT*G}IfSw+EnolgFV&wXsR=3jEY&fQvO6QwR*X}{~C%wG6oO1tpv_V%z_Po~}p zCJ^lX)SJm?$m#PIB=^ZV>s~-SHrKk%rGb)ItR|G7G%o)DFO0Sa0JayVdBCq+({*|L z6)*NomPvN2AD;GhjHGWMmJze;40GMK=e{cBz86`qEk4X5TO1i$GIxCJFmP}>0()0B z4~eQ|&ZR1vEmC*7Z@s^(?QtqnSF^wM{c3q%g?txb4A&BB(Y@RONk+GPFhUN_$)bp7TobKtIab20!>@@i8G`JOaq$-E9^OCIp0PPZb4o|gldMAt` zy|tVr%g1p8Ht1w!00K67eKJ5fBfo6%it=%MLyE*vglN^}ac^C2*V%8+qfJ}CaOdfJ zGnmx88#bFUieLyltFPU0>J3E}hX|T7@&4>%a-(37I^dk(pX<}LbQ-RweKvyEi)Q8z zwI3s3FTC!+*fI4FWx-SoSLM?Ahy}FX_TPBPkg(QcTBxfyy@~>}SP%G;4?j%@> zO=6|XHS)h^mEQeT^m{gEQQc{=!a<;{j6e&(C*~k@8Snm0SY06%GqaCeR+ZFkXMJ^N zCew9n{#=L;-6R4z^dN(toYyU?__hmO3eQ2cV8@%5p)^$<`-pI}e$_9D^^k$!V<~_tR%^TdsAy$o4 zsX>8`4<|iyN;vq>-JFtl+t+3I3Nn+_mRZX6&2rxoq+{^k#!|*mG&&vY<(w%$xnaO3 zEOJK!CmB5fHP&5POC_^Tk+a0DwV6h6eeu${jZ;sPQnb`+?2)yGZ=QwvNT+QFC!|5_u9c7_HUFlq-2^0d>l=1Ps~A zhGG@AfYmY9wk`DAXSok5h;2U0y@TII9UT7adBT<;N_Uuo7xKaF`^vZBFAZy-5`00W z_;*a-eSFC9+1tx56%=ojs^sBDOST7gC#f~|Zb~t8lhJRsmj3{UJvSRW{{Ua-sg5;nK ztlq7@pKU$m%a4`|Xxc|_pa7cHz4#5|sC2f}o5R;)6&YxxjgnwCf=`yfF#|Zr^y4DD z+E}PiiuSb>SDQ;-o1O*{&BZ>)*B=OeE$N>W{2dj|YpQc9yGkW3B2|=**~Nfu)rE#3pdLAPwI)U@}esCciL#7?MrB6 zn$ZAVM$-puknRA69hed}p2UvC*XYNCt?cZDuCb=P@<97$Hq-A%T!2V)>~L%CFj%-$ zv}X1FJN`PJyk_McI{yG)neE;Xj%|9(-P-E}2Y!I6_BHZVfoTtRvBoz6Vx1GwOGv;)cL z%`Sc}9 zl=2VGzomVd;2(&$w-Q@e-PsWg%3cf9JK&i(}BVcUJGOZ zPfTY3k(}2QB>9wO{eQ!5e08O>F3D=XXZo=bQMHh69Do7HB;cOEj~J-tx42m?+9?qu z51CctEM0b<6oNLL+fGP40x`}*H6IkuInC*gNgME zT*+&Ew*oM++*!)Y<(CV;s2NFFT?p(!UH~PJ469bwP>uO_USF4|`RFx!>TBNFLwz2} zkTFt9KT{iEfXnDWBz`8S==z<*Ts?*GBJLrTd|*hY?y&U)at%)%?0UPwZ)y@{xgSB;p| za^=KfW?#XRO#oDkRX!IAx5m`8NQJ2yi$!9Y8oW=WTtdX&x`vyho`-ZS<>aX)azU zWDb(Rg9{n}a99(&)k)1wT;7~orAy;~oBaI6Ze3bCwi|CFwQGxbC6-?$8h6fR-bh`flU*yEB z_9rYD@Mu-Uh4OMh9Zo+ko&Nxrr84@*?2#e9fuo0dwr7tD2yPpZ*o=efD*`VaL!ivx z+Pa)NT(*@V}U8V81lNO_- zt?Q8u#h32{j&Xz}22y=Hk8$r@9zzzp*dooKUD*AvhD{N7~qUoBGy-*+1jGXb#rH^u9}9$ zt*0hqJUC^?mdlOHz##mr0l^s+%SRJJjxE-lTqP|X^z*vcQ`f*sG~YJO{s)U{Tdb|; zQ*|Q;k(bN3g{DLE5sb>XJaiz})gQ8-#way?4#UG&%!6Li2auO98!Jy_2k!QRk-c%{ zo=yhS-o8oJ&zB?3Wt)L*a)`H)Y)!P-~4kTe+@8jUt&N61kBQUQX9g#Yq1E zXVVq$mJG)tMIn&w5*z_2z&RK_Imb9T2et|C#%8su)RYoRJ-eeEE>}v7VN&;!w!OOl z08O8n-YkNBBI&HBg`m8KTlSF5q!v+WX#iEr5a>1?t`$z={dR*~$AyDkS?bnS%_z9M zu$>}gd%q9(gAx|A-_`4T&R?bc5+ zHP{HlGhsZ%hi%qCyZ(C{B-*un#&r;KF;Gg0W-dfqk zc?P32T7{y?D<_g}qFDoi2vM=voVHY8?ov3fZT+4;8d{Axb)zf-Ws#)Q*7n?)t?rQ* z%$_ynQ!Ry1Re>mtl?p|CL#X%{;uYnbjV6(<{hl2@8+#PcBYjTNc8tb^JgGB;Qod}N zQp0`~Q4zAAS$@YJBfRieg5lD9arV6`=v~QkZT9Hc$d>LAM&Nm0nHYe;a6#xrc=ht= zVdkgLb+Yd5s(edBcDKy>_S?sroce|BnocFT(~5ndNTrkpP3kl2 zf)C?cejV|pp1q|MzS$+r&SCP3s-*FfL3Ip3WgjpUU75cT_;ZXed6C3*tN8? zUs=86mq?>~E;hofdXS341GZb#*OvbPXFNL1g`66UF~b6zyQhM9=Q0vNge#*k=a%5} zfmJEE+RWpWBRw|!&Hn(}o5xx&!+l#*)U|@DBC&IL8SzrF*-7U)QPZ-wtDl-)a%PxxI>L zOkx2d$Gx0vUzxA~+{39KRq8pJuiEEDvXHxnfqaPpJn@Wv10SVc@J0D)Wp%CSI8*fvv-PikdHmDJ`j1Ne7c0g}ICVk&ojy-ge#OIh#_(#3dc#lh zFt7CYcADj5mu1BANMw}mXLfDQS+F-TEtWk=Y@W4`C6tk{B<&dkB;%5BE3@*@^#Yu+ zM!&)<*jTRU`qf?AR&vqVOC&8Dm)PK`*h-##2;?7eS0+fzU`ravp@RU!BOrng)DQ^w z6}NI@f)gPhA_B}63$XwmfO1dJA52wg;YQ<%ZnJ4gZTkNJp5KAG#6-(CTl0ZcWCCHA z1apjIC$2NaY#lvA5rxR(gPN}{n~p~nROb~fS*oiP?IdGMrle#zI3l#{u92bGPasu0 z+j&=^&N~{*f(w}BB$JBhpDg;5%^Te_cJ5Oms*xB(O@k4rINV#4jFXep^NurtiiPc- z7~H%P858F6SLI`lKm~{#`<#05S~?|^l3iUu`##YSK3k(J{NRu_o>cnh1aLcbs+YIZ zJ?Hk7w2|C2=2+yJO1VN?fu0D@^7_}!;xmeLanQh8?fLE3Z_KDwi<*j7xi^wmr$be5 zv6m?l!lj7_y#`Kr+XgK@ZV%_ET~1cEWtSFO(B7b!gs?{#WrrM#TFlZEO>YS+>H zYj@#MBSQjRNpO6&Gpk669@ztBN6OhG3^rG%psbiR0QcF!*aj7I^6fpb`BkYQK`HXp zl~e$CHcoNL=s3=Pp8ade$heL;%uPtbKMvRJw!ha?hMibqRHJRb;m$+Fr(N;JwRNc6 zrPir<%m9~T5P%<*RFD-^9>W5*{u*l9&ZDP@r)e78zam7*UAY+D)DC)mJq0@AYn>MP zEPS{t$+8lLU{(rS7yoxDWw%0iYaWVFswB(#y zv-SSJuBWk%p&U&r20jK3&z6%?zoYxMd)Z&b<=j_MZ5Y ze*!~)ufwXM^g5oGXLiH>1uRT|TJq?OOB}*fc5=9gtAz^Nhb%C64ZDU0IUwhZ6pl8L z!YYFXxEc~Ak zYdE~W+~i3Dfru^2;|Bqm*b~DLFmaLWJ{;Djx|(Pw)8w?cgp(c0IY$`iRhJAu16bj6 zrGT4C38&QUt&;t23AtIWhtTh1wF9I=S5uW{I6wV*##qqYt;BH17Dr_zqbLIcRe)9i z<0FI6j1iDQuMY6fG0)p!@wBngQ-imY(Vrz>3s(ho zHGeg?{2%M(=0CL0h#SD)0{le5z`WK1X;81HoZr01(j|Y+z9Rc^wD7)`hz`-KA!Gx) zK`uY^3|G(}w~vXgWY>Hf;j|^K)g{y&Q=G8{LlNw%PvUFkuZghF{{RY;#Oo0mNMN~A z6cP$tMx=v|2og6V2N@j+ugN%$s>Wup!(P#|kIk)rG=6uClE&lVhm*3E)%|q*&r`kB zZ63wb&V(%6BmnF(tFA{MDSfzR%8UYblbxcx=fs~Dyfxv#H1>0-YBI>Nt-3fJPB|dq zG3riztL0d{U9Q;LnEu%^LvsmF-Y!F-B~CIuh{)~@Hq!q9`#(|BqmFpxw~e5hX194& zWRsQ!!(-=9F{8?WzbVFZxx9KDCmJvJ^KC7X)6Z6V*bWGz39qfya)Riz`~GLN_^ZTo zYr3t*tEfsA_HZIbHzYE~7iy}?2}V$(7-A2#Ph|z&m6n-lb8!u=h19!}`r%RHhkFRx zZLq7h(dFTpQHk>K0V5TZEo6BoxG>zy6l=a&CbgLW832}O*scL9f_sru+RtG#K_R@3 zP!zkFWkHE|HUw-@*nk3@v0h0S<0o}ZMhXyCOZu`uZ=*_{9ucKYSuGM>4W@$NX!6do zNf(_cD54i-MLUZP<=KWXpaMDPCyrUCtXVM`WDz7;$X~wMA1NjA&nGri5RMv3Fa{FLkQRq$EFx((z?G2+D)n7*u=4|r1LtEk|Lw9JRmz&LD~T)<>;WE zwaHaW;>D@-J&m$S<8Cr`i)ufPxk8F&tNkuJ?2pDW+518Zw zgT`}#jj+|H8%AdIs>|V#$O|PFW@htMnn02M@Aqy1CH{Q%8BPE^HzZ(X(OOR|6U!WL zBya%Ivh%S?05S9#AZMmn4&tobq|-J%o}-76pt^A zF(h--IT$_h$?L^>cjj7uUy8IUH9bboTyvX+d`f918f_vcy-aalYTz{5JixF|}<%*G;uom@`ed z^}z&jfsB2AxgMPJHkv^3CCsxT6(w~5vT z3BPJYv8zjf(#Sv<-5?v>-~yu{00u02x0wD!3?t_rn6Ic!xF|g>*yoF13fKN0&-&~; zTDVUST8QnHH|;CP2dKw-;b!qg#CIFqwn6ARS6i%jKKD@t#nF-5`=oJPP3ML$tS$s| z{MkH_UWI0*O+!l*<7akl+v<_(5=SiKWNHpb8RxZjmio-85?lE#ZbB=CbSg@+hE~Ds zRDut1L9S~|(4vyy!UE44hF*sUuwl^qkJNP)u_StbhwYiJ9KshR0E}grkO(J&1_3?! z?a4LZR?3xaHn;WPV^tKdcc}Pef=kH~d3NJGf#0=D9mEhe&oE;@rE!|3xgFj5ylN3j zE9E3s;gm20sUssijPP;~AlE@U$*Z zZsBKbP3=cmSuNu7Hsl=h-lFj@h%Rm*dFPR)xtc=q%O+Xfn3Bv%>T}eSo@qa{B9ZOI z)Cn|;35RrzBNxJutbT4bDNvwcenbQwcV}w12{J5EfZL7^O?N{rl_aNG-M{#2{r>=w zP^l|fqf<_v#{OxqZzV;Zc?-9ef;hn^CkTKAU-ndBb`=(_twr{C@{}ep0-Wbr*rL!R~xRSca#^&z|C3Ev{>XVa9HhbLX(qQ7hV~jdqFg6 z@xtuUq|CThWdO2+(2yB&KiVFmh|W@zrwx}=4o+2h9L4p>LnQlcjE3EC-#&MI)yCQsHz6EuQg8vUNYy?bTwR);^($Ha&nBI98>HcZaKwyZ zc9j5zA+p1RhTG@!+DB~hxSltWq-1DRHuL}~40zjvxPgLC%5n!8^srUoQZ5ka6!g&_ zZ)n~t7Oo=^EaZ?K8>0Y0#z!NkP(ka>Xuz>vOCfn;iW0v(mkg+gG7d|&0s)R#k%63? zX1J{a*6s~Zi`beL^3?MfOs2uK-~rWebKg1VIjwcQl;607d1wFt4CL|Gn*1KU7(#7D z=)O<&{cL#d4J*I%IvYD%c(pSdknV6wgZD*kKA9Y|ENv?)1CXY?#zS+Z>dAWYHpV+h z&PP7A=ocFFXG>dznFN?s5uEOB4oKq|7#&W2VT|*Re03ggXDT{rXY2icz&ev|I~@<7 z28rkFVKi}qPQX~O9e~D9<)3P)rRqZV_Uhi?l(*wzLW3E|>PZ+F$Bp^mSzTN#U$cT-$t%70*QesQTRlrn#w{hO=)VL!cRi+>_A~sg8kOCQZeG>3 z;+cQpAdYlZAhPh|is3#V=rd0;+)FDgMTWr=5rA+oc{%4NH~<6BLQlBaXu5P3gU@gq zZ))StGX)983tKzZ7;4b+c5RyE|a8~`h%@b&$^lW3C|BaUQG-a3}d zjku6oB#hvJ#s(B|oRh8bc6~J_!brFoC#FZGQt+3BCGiEdxK35D#18(I>pVnjVB=D% z<-Y!V6&iasTk!47`ktpHvP0zuC3#aG4462`Jv$un$?w6> zWu^Q&ai@U{d=bkI)h3^!e_^>v^3j&Y(mBO^-1A6ET(sq)>G&0Q6x&A$;p-W6tC1`P z(kVtHiTZmGeQT|_RF>OxYD=)nZ6lN2dH(Q zzAv+%N6{s@jH+BjrcLPBBzMofUGZMFG%GYa%A{?8u`=a|Ad!Qf4@~3N2P2Od@rT5n zM^=XEEtO}Cm5NA`QJ>-<0i2P+%K^s#o<(_CY_V$*?J4cn{#O3JM}I67Ib?0>Hy0iy z(XNtLdx!#|j#y=7VeQgW=ZJJ}&ToiRAvsmE+v|ypl;$3F(hY@4gav z`|LAJx6c$=avlaej=&IoyVq9wtQR`mcc#~UoRTzaF+;K0c9uZh2?5ZN+i>+2^xs*l zhp#$SVR$|IuTzb-{zp^r*Tq`3{+6w%>C#0m@I19;$oaOG9OECw*EsE-D}PngW18qh zi?z=qY2@RrbGnYWo>5rf@}dd~x-h}casbYF!0G_xV4iT_3o74Tn}IVkgN$dUYv!@B zSepFNk1f6%UdsBf_#I7S%WE}rva!E{+SW9>WsX)rUM<^zC#VMlBk&cJ*Vge*BHEW2 zATc0-c+aOjdXIXk;++!V!I>DcM#FZ(LEwT1ABP>sUJYW!d?Q9OI1Ho>oaFWArZ~sy zJ?qPOS53*S8@h$QzH8^PHTX-H*0%>~2D=MncY`Zb`;ZzZL5Cx~7?7phGZ7_PT~< zE0fn8es#?2%M9FI2BTs05nz}lxFpN9%rb3Xz8%5F(a`pz+9aBk7|M)M_JKe zwz`@xCgG4Y(8k5%2aJH&85ukh0X~AgUr)c%t|wPv8^8t$$4_xsI_9URU+UjxLa!f2 zJ%^{`T#&`ZNz;`y`nRq6{I|1uUrid+=yYQG+4LZXR=m~pV%D-W*BB%h12`m*I0J#4 z4)`48^I34)YPON8Y7E|E<(MXRsq89C-w<1PYI2b}r~?Vc0sjE?RShrV=9#ERaMN6* ziyL49dx~&aj(1v>JErfozFvcIi+=mm-P5)BgtEM`%Co3g8zuIgyO~OszD|Fqe~h`HsHomc_SS$(z(qm#Fkzy)1-S#ueHkzuO?U^DY=OQuo=+^*W7vM~73s)2EJT(IMTrfcc5zZ#nw*?maook_+gC)ZE1gb*1um&=uPajOshaOLc7*Z!goa zi&B@zt-Q<+7wQ(;FNhjDI9B4`Cp(qNS5g4$j@>w}lfzypz0<6ud$Y^a$a@2Taqd9+ z3fCV_5u9mZ?`67eC#|;iU2SEjmGV^QXE*m6`fK0c+CH~^57_M(5ox`!#{(+B4uZ3^ zeHPN%)pbjYYkflLx81UM`D%CyL%k3tPo<&g~N%vnbq8 z87Bjv7ytkOth--{wt7v?-iNP4Eu4zY2uW0hbkbY1x>5O8jufw#q@eQ@b#oXr6 zL+=3LKpwvW;}C!uFoi<`a(T%FVOShz4fEBfhFF(rw3B!1@>;H&Y9~qB$?jn2UMbUM z*DR9uL3O6ElGaGv5G~{w!yw}f7>;}XHQso0R7+nF-n>$w67EZ=dMm275x^i|5C=Tx zlb*wy)_xtt4dvyG@cFU=0c^I?+y=stk~)sr10alwgTuOK{3AMZOL3@;N_pc@^B4>z zz|P&s#E{rv2;guy&U3P_LR4L9Zt_}L-um0BR{Q&X`SN=;X){+{(X{Pz#8cc$9Eo`x z5rV>If&!-?0U-c0e3&41!74Wl{PTaUYJU#(Tk9QI+3H#i+{QUA&?sm9-62;%~I+?D5!Ivd1$^Cz*%}g3X?%3`xc~7_OVcR>^699*b!l zNn;LWj!2Lxbzm8tum%Zj$G=i~v90T+i^I({$L#l4vBc?a&P*y>4A_y-Hw5vyfgoUD z@GH%ZVLU!Qq@yO(w$bXYwckr6*6Ds)Q&l9sW-XoT_-j#`+}pLzr86(sR^EIIrSij= zijWjJ`SyZNc8njJ0*{Jgw79Xe*YxQZ`!ec8kV&1U2{!<~>e$K zmP=V~AeAp;1w?Wy7KxRIMjKlLxC}d1uZushtuHM+NVY`D1nqLE5Gxnj3J@D;AZH|; zzMJVcXit6QVfPxBMC`+6Ul9~wRv_@CkTif?DvbSs@E&rrIWYdIY;9Ee?r z!35yrA1OHl85Qb3u*Q*LfAIrWySlSkZZ!K*EH{EyN3{*P7>;(3Lvy$ug9LI9+~>lv z{9f=Zx^4Z%rN{P!izKjJTBEFJNy>nEOzk7g2Rs}a;BG%+y(hr`01zxZOYr{yT9d<) zz{BmDblazb<|0R#5Cr6_42%|7a5118ydZxPc2tR*>Cuprsq%8<~2*! z)-~sk?Hgl~Vyi5ySy3~9NdS@uuqM2Z#GWeCJ|%dj$3~9rVDQeFX*I?Ev2zQkf=$CO z09V9>q6pA1bG4F z9&pmu-1F)FHtF6J7P>#fi0-@@rl^swwP_(si@WJ|{_D&c%165!u*87Gg<_#iFN2>N z^*uYplG}KXRJgl}`a>#R-AK-1l=T@Wg>ON?HQ#^2fbi~};_;?yn%tMuU*;<$j4=?# zfGR6FIml1|$3QVu^?%tSRMup*x72>cX&M=t;&o|RhCtei0Lve8J?pNfJ0_H6hN%7} z@jc$V-*>O7?V-w{i&pBkvfK0z+7IETm*Xucc%Q>oA$16(#nrXA4%ZPAfZr$^nR=6f z--^!go}m8#w10puJTGzLolim4v?}Zib;?#^ArJsI)b_AYu^eZr*?L*Jne$RQRS}A6c zBnlZN0EK2?xDA7X#NeC}=3ujUMvUGxL>DtU28rRP-u1;CR#SEv#yf_9Tw=J}Oe_Xg=!Y0is$tzC1j z_O+8*X@2Kynv+XrjgNvJA=GrOOIFYw583SQCuO&~EVD)ghifXHMgbfS#8=e*6W5@* zjbjI6gbgFJVKbb5wc$S!r_uCXO5etJmq}}JrIw2B;TZ&TMBHs+NFP8xW;;{@NEOy- zekHr`?aZo>i>qmrm5h&@AZ360;=Np>G>eogLh+2Wl6xk)uhjHu<3|0-L-m$0n z{dF1cEwvvG-@vfTbo1?KF~WsV4*kr)b@^-4&bX3|7*3uYHCig`be*-|%eTvNag->^ z)l>Js55M}k#dz3i(#NPrYd6_$#A0~7vK7>-leI|coD3gOE6n^m@n2R;xNoDnL#P}q z(s^Udxs65yXE{_j&I$!N#&S5Xdhy?d&LktVd1CaD5Lddt=7Np)J+QSw$3=Mp(S3 ziCgAiSYRJfkPo)sTInyoDgOY%CI0{j?vU0I#*$ey$#*VO&diJyGZTftTn4~UNZc?> z3mOiVH4DjnMPjo!Y2%9J;>X}r-G>8hE0(RlB-2FCZIxZ2!H~vk(>5~}W zbENQoqj#o!H`a7LUsRS`-5&1Z-q07qO>S{CUnU1q@?ebj2Dwibc%t9zbJ*JXx{#95 zmRRCrCdpY_d~B)9C>?W*9znncqt^Tjd#Llks}JE4cHMfmpR*z z!0sJQN8{fY>7E(CweYrqr;S@()ua)D8+mZE^dXsgk<{)y4o(Gp)FDoIwLCI%l>O~A zOQY9oX#W5qO-0!~%by8;Bio%86|}mZ*-=nOZ!%!3E;f+lWUc@MAmkhqkzIzX;vx16 ztybRV2vs06p}CN#l0Z+%xC72Z4tnF>sdy*$e$n++65VT#*Uq6%pP@!YtWAXu6{IgA z%L3ULUCh`}FbA4^ME=h3e~<4pD;uMz>DDvE0ZGJ-=Co=h1mhc*E}?KjpL+JOI0sr$ ztexVnmcNDn0Idw8CC#HgkkDTC@4@yLa;!;oj@L3tXmb?1qXZ}|yW~=H#twRtIIIl^ zUuE%iyjScRLl7i{F{==HAZLN?lU`M%T*s{4>UutbsA?8+PKB)DmTY;!GE7_$6qU|0 zan`*H#I|>u2CHYQHkz@m#AKU@os3Ka0tg3`Dgy$(huBw-EG%P-jwcY4i+fo+yM6t7 z{+elbTFxGVc*DlYYpQB7=j|64R}ut?bMjz-qA(Al0&|g#cP>F0`FJCo>1bMgrlgb4VLP2!-Q~Ox>`RjYVm!FmgS2hJ|fR(jo~wTHR2{|;7a+587bDObSlG(8AyrzZ_$yw@{{WW#x*78G zy)@VU5B2vQ$HiY1-&<+&-Nmc4s@QkZBy~px#~V)HnHwPFbI&8ad3yMZNbyyZ+rgRT zXykQvA9RMuQV0YR6dd4iMQ?bsO1z%f%RZFWG3kUycO;-Iquh*P1FQ#V3~JD?X-sZanP#(eQ-T1G^+eRp6<$9(w-k^yxlTs z<1Y5Zx`LaCKXsXL+@o{FZD?m$h0{w*pS0<=R^^~?DON(tsu^TuKPf#!eber1H{vC& zj-#f&m3w`637C{Nk)pO$h$W;M0SE|i!zbopf-)=7g=$skdm66x*Uv}4rn;KJxX%3< zfvfx((ge3N>iWxR_X`tg(pq7*M|1-zokj_8!^)5aBN<_XoR0$0{2{IQ{{Y4b;Y|`) zZ||;Rbp|`hB)0^Dr_L;@VhVU9t^wn|Yv?xAEUs?nzq!0OV$$6JOR0Z)t&tKclgqK( zpl6ae&rII4)%43fGg{a53kj_5ZpuSGr2=obf+FnmFd1F!0ahdB%K%B@y<8r7Qw=F9 zskH2(^tJr{Upv_Ejy_b>xs}l0_$Tj$wBHE)Iq^oVJgZ`LeQH=@isI#Ebhoz5Xg8=; zkcN^#SSSsQ;B>F4BJu8#W2otN(oWN@*!d(?;dmhD1J@p((!NXhRTZtDg`|KxTm7E$ z;&@`YP>U3|2hCl*2|=C1jGXb>y^ZvnjW1a6!rlFv=EC06Ng8WO8Oulll--apS(vUd z*YU@?v#NNEOxDp$s(Zik(E3asCWN5~b977B`utB<@u!FM4~aev)jTPv-Nz`pwa@xO zu1OrLl`2%7z+@G{1dMZE9{4-pEdDOhwf!bb8@(^=LO0X2`}T_WO}HS)9U_Wg;yke1 z`=J?90Us`F-Zkx3_d@aZsV)7g<}|fyM%>JWOic2EM@%@t$mavryu-t>AA(x?-k9EP zBJ%OBZsd+ukhGIZUFHDrq;BC<0gc0wa(J^0_v*CW3KD9gYTkC=f4av$V^WM{qZjXW z?c{umdvoxDElCZ|o-~;YVrH|OP_&c)#asy5ITvs$M$lAb5s`}azl1(5@Xw2NMuSS! zH3rhHt>ds48mc*lIC8~fl4k>)oCi6;3ZZex^gkZx+9kAC+Lg`4o8D?KEc#}GG^ixF z4AQf#u`o^1ETds<%y$Q4v@iy|*WzcvuNZt!@fEDv=ZG(~iz6DeT5g&n7Mf@%6>ZT> z=2zOAP%#aFF}nh~s%Mx?eKgfiXKVYiy`1@yS}jjD)_igQ0194h zYfIH{F5=ZAxbl<3sWVBsHF8wVgR(Hn6pR*Z5O(0!c8&W%{7LwYu4=X(5Yeq5j`H1M zP?2P5@r7kF8HrU=cQY1YjGhMqzLN1@z;6OyX<8M}j1p~iG#hxd`;B7i<)fLQ0DxsN zkQgDx+?<6NC9%$yv!M7-!g?jYhpl`wVHVlm-o|@4fPWzE-Ek|X0+|{nDy&Fh7T_?s zJh*-%z_n=7l;*E@(@omD+h=XkHhGmvt6s~<`L9#)tbYRhFFbSW58|I2>K4Kn@9t%i zZzeo~1VT4$kJR+(oXWoNoQtu`H4J|dgS`mUyt7i z{9ofgi~4ldHeYSC*O@J>Cbcq21+ptNam6ES0yYt%$iQv@F>Lh<=`;Ki@mIqi59#sW zT19_r5^a0kDb2(nFII1q6&dF&0Qav?G|Vv7uymAWl&7t(yKVAk!f_;LUk4v%uc~g{ zG(M^Le=d!#_%}sXv9^-c?4yODF)zwf3cwID8*pQgbCa6+>*Lk3Y5pU%xwj-CSuM97 z3wd%7H+$_k{422dL*hGYKLu;nmfD5G_@)BVS!pc*2*j?^$oa`^;FTZ&vpkHE&3Mbf zI?lE5@%4+iu40D3o5qP?Gu_CIwPY(8@6P`Kn2hb}I@jfyc3o2mm`ZMUQhQ$R(!c9u zeBLgU^9opRV@uw%yYfq`{Wse~itxqh)3srBX!1%#aj^j~`QIMkpcBX>kbCV1130g& zY;{%FppN3v;);1hGfTL9pS;0i)L@h5DLLTbj&p@Wx0QmYzxh3w>$)wSI%z1{A=TYB%8<@n4tElw+zJ#V$TThQ$-5854VmC;15K4t)O z&wit?Pp6=$=8chxu-e0d53OZei5~b{<&fe{@M$U-{1<&j** zdn#b#cg?u(Y>mA!?t9~mr&3m@EB^qWZ(k;Tt($haEQF?VNX9dsI`pUu7Th^Lxa>Xa zrtsH{X3{lCEj62CabT4&&I)s+6!xT#uww_}$sJ48h zgC~-6{5%7=^{xy~aXc;}o*ESIIX_K1-|zd1igDDYBpU(TGm;AWf1l}1Yru-&#;v$u z6Q7rmaz89{p0!pTM^9S`rj}P}7$~#NTX-4zzs6IzJYD~+RYSn={X$>ed4J#cuhE;+Q^nx?gjeRX!|w%4wg z>GFJy$))G7`F~gPB(`86nUdyMRiH7)Jb2#*Phh~PAe{0D^#-)ONpiF3)+IujMBzg^ z=LkV9$9{{)sjhEbjN6%SEa8%EIHE};0GODhoGBZCAO>a5dSurZrE0eq7ZX~<@D8V&W1%iqwOt?2`t#~nJBqS9$b7pii6>2m6Nv`#y;sq8 zHbyW8MleotIjt=pO|!g$>+KhDz{LW`*Eu=k9nXF}YmxCkjFuSDrA%8F2|S8$SFYAL z;jxqN&2t+60E$1bE?~a%irhRe35>RIRG+#J03E=dIRFFOA8q}!D%2xVQE%ELwbspf zt7&eYq|UVL%U}aWz@Ok_xv-!Q;?F&JtlPUiKSl9D@jj5z zt(usH)s$x+Xfi7j$fJNl?cLCGpIW2v_f6k!&^oMEurn}*bArGqDi2|i_;FnGio)K# z3YxiZuFubZ>r0(ffqZ->f1IBdEf)- zShqecy75njZtbruKGA7(THHr5+Onw!5^CFa>hJWb@yO-h`u)VpUIQvrr}l4$S(?}4SBSLe?cj_m=$d{RmPrr=YsU^r`^rj;;JIP|RZ(3hkNyYzIq^r1 zw6)e2SZ{UBNKVaVANpO=My%2z#I6AIHjUC9#Y^N9fT!v7@s5+=+c>oIrY(Si)U}n2 zONCgY0aO9mSB3m5^~yQTEW-~@aJ+e!U+cQx{LgnUbn4HY^?EM9(mFjl$y!2P3od!j zuetA3J|)X*;U5X#_=YHyzME+^+bJ2`5;Ej}GfZ%p{>r0QN6O+pJtF3!s|VSw1m zdK2kfukBIe`R+ewy<%+w+3)ooW+A0PX=<%2FqKG^lqe*Xj4|f}pK6LVVLJZJNnfh+ zJ^IxYV%_yiRrw#6H$EfQCh;Tqg5u`obxX-)yZdN%joI0lG>X83_qTF8jCzXuC&HdL z*ZdgvT8@*fPMU%Fq?db%gB%h|=jKM;z$Af!Hh>0x9%$Yf%1EUh8*2m95ymU*{{V*Y zMdG-$20+0sE#PJh*(HJv{f7mEaogXmX~Y=1xEfHcMouYS{{V;iGx44&#^Nw^=~SU6 zmEHb!-@eT0em?lF`^OsYi>Ua6GwZ8kY=+_jvBVSyE(}3=W0;QM$_CcTw%}Kt*5Lbh zfP6=6S3Y)=b+t!4tW6@1%>{H{6RkzoyXUk?CzjwUx&{hU8*&c^kw%AYZ!L(notRHM z3uKI-D9a);F)f^e3oZr@a(NiR_$nCrVk%I?Rh8$=ulJ+!>^?cwsY0W7mo?tq@A|zx zH|g+1W;M7&GPA@OZ&YopM{o%T2j2(jR&Vu-{bTIkZ@#&``z#4-2ij$6JeXHIV@#8^ zMotuPGg9AKTcwbi)LqKa$|JavNJ7MfqLolX9(OGLag{)DIRdtwsNLyz4KGoQqzL#U=TEb+S1;6=NU6 zPY+(lq*%#uJ(!Ytj433omf@Q@AbiRJJQKj^YqN()n%)8hYUy>e`)=%oW^Hy}vW&$)TSA09l&m1=T08-pODMtl~-2Cuw94BC8$=0oVh^Pg?fh zhCUpZN7IGf?YlvFHeYO~_kfejFu>Yy0LM|yb@uU1BAm0H$L2Vw?g_U%@lH58e9>** z#x=3kDnZ67*W9sxbRtA(-^6xN1Iz{W@-2B72HzI z5?vxVW`w1b1wf)Ak;p=m6@hK@#7AdG{K2*DqfT=7SRZhSCpN7`2VOrJ7a zT}8q%AZ`S6weSJzK>!d>XtWoe8;GWaymCh2(nKUOusH+~(APaYO)OO(Z$+mU{d}Kr z=H*vgmt#1$k7~*;1F?Avf`xFqKwY^69oacK$lzd;atqmt6xdkg9o*!9PUG?wv!>g| z(jqhrRH-c>#?>Pn9&?kE&|`y~ah>$j>_jO0qu$Ixj03qq0|fDm9Gqb1gTsDw`HjX_i!o~J!W11j_SeQDABUuoTLBlHo2=*X@ z(29uSV%?6qbt8UMa7khdZi^*PDqVKAJ7jh~)fL8*aV*UKYi-&9LyQb(k;ZazJN6#= z=_b>iSdcML!)Eb;9B2t%BfMO zH&I5$cGpwOyiGiMHK>etIl_)=rjX64Y4=7La`3{}4(}?)i5h}iC9pD1(4#plLV(Q5 zD&6jfec~NKzR-&rE1Y4s3}oQ+9OE2z>Cal(kHOk)mAqo$G{oc*qz%6*!8z@mbjjzp zQQ^*lt%#LQzvM%sZJFizgj3$hz6gm@YPFYyWx7eGCgMAUkub5s89cL)?9I3pAnX8O zgSh0b0B>2fg>6z>YbG(rGI>&n$pS^(8*2{Za;d@Yp!TSBJtI`qCG#%jdzk_(DqYNn z%S8zqH5~>mr;SaLPzw?g7bqV#0ff&mww>xXK(vo$yWSN#I@sFCb zCAPIo!0`|<%Mv&yyQ5l8Hi@S%yLNM0AB67pSr!Q4+M!*`Na}+MrH%<86M%3!lYlZv z6>dE@`wvuwBn}aX8NncNUb&#^R|!iH z>aCekShEHr2u|i0Ly`dil?Mfs8>b|r%|GkP-vV#v?h%?M@M{xbs|G zPb~LxyGbn5IcJfH$z~^#NjW6d8+{h_=HL~K9j)IL+Sq(KBtVwV^GzvLRg7hro&W%K zHMKd$a=SB}EvdvNj$^(tI1(0YD|F+I2`3)DooYQ}!N$oNM~!8en4JW2hZ`A7AG!$0 z%H$RWPC*3cZ*8gR-`Vqk2z(#BIvRcA$0njQ%Xy^Kq?nj(PML1=HN?e#bcq{mW4c1B zc*w~hF#$@PqbiOn*Z#k+%#|)=p5M~`pI*9H^Mw;e46XZ!)ZtUTZE17GAx4>GmM!XD zIGiIoXXXKjV5c1p(~m>asbMeUn7f2@0g+ojyyOC-0Q1i5k_Hs_AQ!=9J-iQdv4yHK zqsjr=tiY87^aO=$4*O4~aAGJmX9TW0B$e5lCW&=>a_c?Bk=yNJ8YB)&Z97H@92USQ zJOWM*FnOI@!gnbgw^6;UaodgZ3!BJcb{J#wBd}=>6!3tYl^s|E++SW?X%Sk^@jOu6 zk1`2nLJ~v_P_X0XUCP5FJgFT;alR*=HHMAm&&$tTVxtPDPF8()HHdJG&5}lNK;r`nIL=8^prmrUK@yPsmdAdd&{j``{Ovgk z%&i&)3>AhB2m~LY1QG0OwA1z5SB&5Sc?Q3qYsyip%N@37pJ^$$$(fhl7B`w>Vl+{M z%P2U=u13d1ogN!5S~$Q}C5s#nQh6ub*MDKGTTQ4uk8cZH>@cjRFdt@fw>@#wk6vm^ zO(NS)xoNP{xnjdUyT-d zt8DHvQ~(L+E0WeM?NxuYB6f!AJWU%!`(zF|8%}s&M+ZH7)*i8}t;4Tfz@Br#9Al5p zuB1O{(w=d15ZOJ3Go1FNM-1@IPA^w&ulIjlc4km*zILvQUk`jQa@Nv4 zxid!0N|orMwDn6X`$uJ$cS1lsX1bq*8oWA1iM4P842I*TD#wiOVYt1Kr1@Ub?6~zz}wChm9AyxY9MFffmRY7Khw#^^YC z5I8*n8L73sQrUE(ZIR{LIbbjac}BCV%kymy11SvhahwC3at~ggj~K6rmJ*dbPb?z5 z-mm)k3QwLlEo*j0S=r(tqU>AvO>1cDd1GY*Sw}3PmofqX2-!R-$;ieIc;u0eGI@Et zUbnW=N@bSrNaIy5Q5gSERdf$TjwH z!qS`Mr!G}(PFgUPNADT_E^Sk62`!b?*p}l7YXqdvF6ZSCkk}_UUI-ijFfm>u;cpv3 z;rK4%)YM76%f~i3QcpjHeL3TuO3%ewE2f=n3oyp{I$--(&)z2ZZeGNv*^=GW-HgL_ z=g1gdxWK_903Vp-o-r7C;Ug)~sXlAF->ZJ5-tSG(=6as31^%%;%nyZ;vygFJ?}6ca zz`G9I2HWb`ALCweu4sSR1WL~BbLT&s5Q$bbTrSe29;BDb?EnQ_6Pz!9@b3O(wZug3 z0}2sfNF$0F_biiSY{6i>6UiM19Z#_8 zMKeqBvGA^<@=6_}ZZH@C4o4(lRoLA0I%!Ji{A1}L+T4%TF=FL zHKeN}F&UOI9E_lytiLb^3^2!r$j%Nj0UWj0hNYJCDH|~$zsfRj21)z?Cb_?b?RBHs zMHRszV_muQ71ChJ2@jcz;!`fPg5wjhvdk<>ew$iR z$4u9q_*1~swxIUDYsVC=*z!G1cZbCduii%UMxc@xCoB)(YkjoIE%v>)mK{l8N{o!- zzh2+xHSJ;JT~n1hD$DXC+d7RUvAN)ngVx$Of=KR&DE?~49L#}^(!VeV=HZVABRD-k zBhu#kPK~8`wnNC321}oq5lgsV;Z5t);&W`uXd9bnR|2R=(pP@tXL8Inwe4 z^VAU`&mf-b`qxe1jdtD?dw6C*AB8!=3_WYkJUJD+x@=H{ z0e;o6msIP(B)8B2N%j@=7@ina=zD5bZc6;UE?S&omCm18)@C|&)MoW%xnyr97MQBL zV})V?Dhb9;4m}S_ui?)P+oJ`Tz|LAS)80fSGN@o=1Om8bUBDiIkZ?fSeB;DvYXlK4 zpFfgq)kH}G?N!^r0CpbL)oA`AH~J(iDRz)>TyS{r?O!1B{6!iPQJ=b=+v>K{Z9iVU zE^aAZ>G_>Ui1hs?2;{UyOEqk~EgF^u2Hfs!VF)e4=OhA0LymO~uZFFy?x%#yZul$a zr&FG%+}AJS>-(KT@;fLlcX9&|xWFIG*ITFD+a{DF)Eo$-UCz@SB83?&eFZEo1~jNs zt5Qz#(%b9bzQ3LArIKFRdrh`GJuP%yR_5DZX;hdO0V>u>X=rlC4bY;_5T2XV(C3wvES*I zc8}%DZ+9}bOT1;-vQAG7PaK||0N{IaJ}8>@eR(b6lghL*Di(8rxrhom?nwX+vP0^5Ok|Av@Miz?*v9d;K{h;gV#6#TQ~%AGDb#u zV}`3s9HCM!Nv-YY`1<_19Q7$eboF+zy`?>tgQg{^i|ukS!(fc5AOHaXalt&~<+^|| zT5{M&d#2n=Gzl8{X8>aZ+-JRcUX|h-h$U&>5@UYE;Gd;;x>t$cQL=F=h@snnt_RDJ zjty^(%rP{*%SFa3-Lh9-a@F;B^H;c|4Wjh>zpvDjLbtcD(r3&>%Nif_lGrQ&-~rrM zo@?GBV`*~f8M`dzHUlIm@Ay}t%ckF4>6eVS0lri~+?)?;#qi(5EBJH^l)9M&^W^Mg z`GG&(Zo<51<7i3>Rqf3eZTDOL1D!cZ*&b`7Tc?QiD4to)Q{@Qn(0V8}-Dk`XwFFdIsz;qpb{{WE{OH;miF2B&N;trT*C`Mgy3FK$7>FdG8V`@4aS1oZo z4q{uy9$*cOHaX+(KYMj&_hfjjhYDq5ZF9$N7wn+(pMf8wb48{2A=37C|Qh*21pq_ zYmw2sLB9HEzS0b_u~{O?+yKGPOkm{nIpF6UXWgfa#o;h_rv=KNU2p1h`z~kAExfiq zUezx3-78kn*uyE;boo`8ravPPdC5JGBk5mZX`1z}zv26BQYjG3@r9B!IUBgh2fhwD z?~3GZ#*OxkPU7~=U0uq*yGI{%Vo(xF3g}BDV3AI=qq3 z5}SmMN#TeFA&AJ_J21fnbp%(aO4EJ1$>O+skWfP;(gg*^Ib*Z|lh=R;pcPj~_;aIp zOHiEJ#{`!5SvN4r5N|LOk`JgLk^cbd#YRs~o+=W;r^_d8dRqFnyt)}ps71Hj;^CDV zSv)^-AhywOClZ%c+8SS17~>?9lj~ep#vg^+pN4G2Y@&T4W=UbWgzkHO56QVsHk^#) zWECAb&$0N|P|AuC_A~gQ}-aeCjQ8Yu~DG&whbVmT#B(^gP0f_zD&Ubr?h>{ql> zRs5rXyCfO;l&*SLMWlF^&g#op(}uG(-R_L3_OCnySy|wA@|l@;FardX$ILq5kZ;TI zy5enET4*!*7VvUobG4&jeR>|b{3~wb;myd>W7O`F-YrTs{r12JIOB2Yk&c<CYWMAM4KFp}nAK7P{Ys?Q~1q&#=Ro#4_6j3>8^l$#UDW zqz$J$kjId4Mai^#%@X^~isf&unnjF6BVDSYKwcF5r;KC@=B;8Dz8};q(ngZ!%Q67S zODo`z%tze>M!*w*NXK3)2jNWG$?id_y*bL5XWYRmF|n{|JGnYBo3Ywh_SGvf~j zOLcXo-06BonRTe?FC$r6o3~tUWe&^%JL1|v2v`OxP735!gq|F_@YTJn^69Z!@3jfC z)%?VS?+jyBD!YMaY%u_ky$4F_ZxSyKUU?Vx@&prp;$VJ!9!J+4f;(41d8ORy8fEZF zlxaGS_?zt0Iy7XFos5hf30&cnf~|r&SJu3KCJ=NgD?w>?o048vx8%3;FmR1XrnmAu z*IMw>YVlh`_Qz8(G$bJ7%y(;k+sWvC@uxGHW4?O zER0noWl{-QN)5OpxH!QT_8*8E9X?B4Qprc19Ey@e{NNLkGx$_;d^^&7N#aQL8w+!7 ztIkZ5OK6J|FjJV6GLUyJT>i#(@=k1JZsMSFbrU)GCKl@#=j*4O&}zGo2@ zvuUeN+6JjCb{E(7q3zZQ7TCCD`QiQB{qf1%yLSVDf->GqAMEd{=r5{V+evSzT0j}> zm97HZ?IG|O06;3m23zj|)k!3M2S0%AwSODIe|M$LqIjQAET-aIG&d0z+U`fpH!`k4 z9Q@fJ9D#`b61uy%x79807FaFg^IalY5>>TvwPU$S32^@aDZ{o7Lu6zc`Yb+TtH!U@ z+gj@#EvH+jZF*Ym`_1fOEHvC*wzj@rpYT0TDfoTh8?8r5wbd=JqrjB;%do80(x(Lq zo7H}=)cQ|^b&n2f_cpLiE%uXh zD4ym|BQe{v0H{L`?+`Z=>}rm)p{AL5vsp|nwPP_h*0$<=%c7w9k(MBZ`E%Ivcm}?D zzAB@yDm5Rv++?)c&#sScZ>HTD3Jx(&AJ(2`PM51%L8)udcxy|Sew?r@HyV}nPb8NT zj86E9H8RFFX9BeC|bFIMoJ z^P<@mzn1RdR^i}3x?)*(0;J#-UEH|fKLq3vUWD5J0E)B=oikL`WlIK%HG&3M%f!~h zX(gB*WZHQ->NAEJucE10(~K#&u3060^#1Zn_g(GM=8l?nlvbaR08Me8i^yW$}NAmvwhV?LX+fEH>`iZK=eV}W0?XpW~ zwFHtDE5fp=-N?^CP&YqXz2g4>0ZV=TpMBv?LL1n%wYP#;?+TPYTCwv{o18FUN{>U= zHK*hKhR#1V*^qq1Bb7W5NXR4TM?x|VK_ayNA8GeK9n~%FRfer#_QFV@@`+g3ws#VE zJaLZLtgF^oYU>jw)NQKnpZpeyp(L*kh9!@~>s@B=Nt;rzu+gscsb1Btw7Wkf>PpGA zhSwO{%GhQggU}4AtS=CJH}O@S3$G$3f(rbI9v$VKYltkZYk8uUWjK3~NeX+3H!62L( z&N;*}n26zN#_-kOduh>n*x%VEwRY@`4+Utqo-Wko)-@Ya_i+`9BRpV6&_$Fj>Bw;|$J+sq{@Hd1tORH&P@fN3d z4uc?RS4WywcAq2VSvdp(eNKLr+WbAfxYs-*b2gKtNgcc>vPETS8DkVgHZr5`fwZ4s zN3DAJ%9zS_RK5*1T5Z+6Z>OhcV>n7GT+!Va{{Rqg_01zhyPjQgTj-u`w^vR}TrTBR zjyQM?*Fm@W0|8iZx9~P-tzfZ9)4zxr%Qh1yp2F3}|vdAd&-e zz%e}F9)A167dk$p7K?r2tucHgrWg|F^ZPB)tk~b>Fjz$1GvDcNye2+-@ZQ?2Qn=7QY zxOlH(83*`gjPOJM00{@F9XaViYNCm`f#vib;8Ksa%m9C<>N2a9aa;Weh+k z86i$U_KyWwcw!Ac%UXhVQbhKCO|8p3a}WsLszBPInB_6(dM2~4Ym&W|rz6SznW+Ot*X0)RnxAwvtFi@D^sN=>c>Uf^8S(D%j3lH28U^> z>yKh5e=6NqN4x|_bg%Obt`(Q%CvvgezFFJ5bwS~&-qKgIzqSo+s4;V=B-`Es35>X4 zHjyH!bF`H>AoE^N5+YvAZ5OqOM97|!{q`|T|q7zEJ~H!3iTa$IrM)9TDFH_ zeW_~_?3(~ZGqEZy#7yd-uN#3ZRFXFaP&uz(6;ie;m$qpuCfD?sn zSv(ag`EhCg0ND4gAd*QT^8_2fQp#`!;f(jlC%6W(G!0VxYTBym7I&6jAGCReo~<&5 zFMy^YwE+wpI1D|*W0P54H1U0ovEkiNX%`Y)U&s}OjL5*toL~@o`ix?^U3Xc!wa_M5 zXO_mmT&yz8!0kzjjDj)p01v9$b2@bNUV2~r{{Z37P|~E) zV!Df4xww+a%ZX-1P3^F$SBxpa3hMjR@(EQQ^{wGA9$NU`!pB^-PcFo~jQUN?iRC#} zWf02Fm;>fV`EkfztB`yy@5k19UbKsAeQBv#`Jx-2Aj#!`SAd>qQp0d&>EGq~R{?Z= zQs2QIBi3|_dBka{+(j%dA{&%4msH#iO8ndo$0NC}nwWZ#pq;Ha>X&t@cl+q}K_f<#DxD&>B>sfvl)b8GEbh!I8a+P_c zh-`W7%A7Xsq=n0nxX(L*1dL~ui(c}4$kJC*_=vP45-)#{^#-smejMx9`rW^aVE+J$ zQqtPwNFQ39E0k9G4isgHTmlYqK{@9GJT?kgw&OKqw$a|rU%$_&;G*09zs%I}FO0Mq z^*C+qbt{{BFOy~CO>D~w7W=tYA-98`!;#j!l5ZWqhP+{B`i8G(s7GMiU}a{NE`DxT z1Y`oIY2A!x)Kw3Nl3r=L)RLW69YlGCK_mu1V;L+Nw}9ONC5CbBT;{W-$>!dq(%Xn3 zwh~PeUBUMx03)Nau`DEXW<~jmhWnsyU#!68v}sdNle%kn_jc=h8On{NbbFS+t0tW) zSlbqxN4>?>sE#2kJPw!&*yoItVbGDEN~iE+OR>3*<{2(pOSPNrR<_&ACz8#uk~8C>HUh5_c##nD7hB%c@(&NigQBW4h z+&J&RBm=8yKDjco`gI zXRUDm00q7>FN~&JZAKT5PZe}|pKo}2&({{ZXP%Vv0blCLUBEA+pm{_Fn$z-v%;i*ajz>-x~Kq-owW);=g& z=`uk27KN+WM}Mmuz_$rKatF@cdHrZ+iq2n-Jl4tm$29Lja@w4q8omR5G@XZP!}{5u^K z>(z};XiDCT`u@EJJeq7ecB`c6*H`39+TK$Guta81#C_=4ftByj*3W=+%`aQ=CFQrB6}`>kVWxQ)GODlx zB;|J<;Zy<3V?C>r_@!a0T51;l5|d23Z8k;?cdFS=W1dI^h$BdH3gD5r@t#-{(;nsu zw5hss=2z11YowER+V=eHZ93H}%_S@7zYYHGhsSnWR-^XKZ%)&pivIx1f>ChcA()6_ zb&=$a{$RNB!7m$fyN2SdLHdXGWqZM*S+dBQlGz;FJOz{h%8t=~@Gs1a?57-L9i@gC z;acc$Xx|sKKM>eyHt}gN2?f=ykpfkf(pGk0%zWg*957WoifsetHa;$V7P|O+Ep7Z8 zXtz3csUtM&HOhI=kcG@}NdEvL?h&|B2bj&ZM&eaZ(dL+%IjkdsQsw7YU42vYU#aHj zS!}Z`H`U`lS10S(_g{)u4EL~Ji%Fq|>4R)!0r##f;r^nMKZWvBSF;5D`7yn6EZjthC>zPHkKW?617Ax*|I z8+2!J+D6b1&D8h#uUhyUuUhC=Vpk-*5vuUFPTXYDV-pA2+X)~upm5-s}2HRYA$amIY++Y-+$ z!w69n<8z!U=aY<@_J4!^9q})Ptme?pk1f`hVWz^Ve;cf`O)z528383w1}Xs=0ebeO z@iv=r;vWr-G1}McTA{eIzFDOnc0^f1shqAQ01H9%zG#kE4B>*4wXxBN;yg`Uce8_D z$}K$}ty`<}?QQIj(q%Uvbs{eV{{Y0h!g@WRXAmWwzEJU!BN!wOGB+Ncrn=i{lJf2g zWQ?}_^Tr1Nk?a2e>Z}VnUL705lS^y5-YqsL?h^%2oD-3bgpk8M@;$2dk*!e{-+;+d>` zxE2D2I7~_xYeu7TkUHnCPd{42)}C9k(r(|4tMfNF#s?j60LQl!YI=3r_=e*C@;MR2 zvTco%20i596O)ou9AJ^x2Bwl6!enl{K?(**&Oa*ja=gM6XhK+;mvVNFza`v-&C9Df z+sUqGmPux3mRT6PyPUHzIV6+R)h3cm zk6O`RgqQb|pt4oJjiYHxMnV-;19srVV;LPYn)Kd%frcUBB{j`&m&xt19QJyg&xZU; z^62+k73Rq;W=3msGUP=l8C)Kq=b`LQIviHrj1bz!K#@$S5Hx#8Vo2v4V+wFNIpkwJ z;jQ5Kyr4iC$6s3Ntb75h>hmhvXi~>K@t|Ahx?dtzDUj^AW>8ozc7je%d{>Q##9^}f z&TvgermZjL?`!oXwLc>x`%B6M^v|UrzjyhUH7=`XWv1z~TVHBo*H-~uEu^$%j%Hkk zRgs292|Qwmds_wwq(Snl)#Ug0Nkq86*r6azgbz@JnwO|@Im2-=yTe%ppx#$84w6|;~~>`n59V@ zNFZ`{ob$jV9ON>{L@8aa~!im-dn`nFl+|ea7YJhDaLvbd)8b~Ev2-vU99s?k%U)v zRAAtZ%-}8#KyC)!NIeD*EGyAX!Yy7s8rRoepMc!-vB&9pB-R0)iEe63-w{C+GKn1n zDGEa{-IJc(Pfzojy?3q6a$e5H%1g_G@`$b1bce4yM5D& z;dLR9j-Z3k)^Nqsy`b*do0^oZ)e!ieD}6;|EUrmX>9#znAb>$VxFq){9XgSdpz0RZ z(M=cb$ziaEK*S7#!6So^IT$#|=uLMkab-LLFp5~=M&cB6>Q4jeYmm0^k=5_f*K9!) zs8v;7HxtPN<~b*mlZ+0405usaJEmG{S~OkM6*ZqJEkcJv4hhC;4R^uHWJ!;iYZk-> zB#=H_C=PNJcrCbrjsXCcBXQ4`8T4&zMAD6seb($vQTU0jYkIAmG1@9LO)J5^IZ(Q? z1tgRQa<9%X!~n!F1eI)hG$_t1-gVvS_Wd1Ea2AKe>^A55|CYNx}iI}Ikn(#G~!u5V|JUQMpA zu&@lHa*_|q!y_Q822ueW^?JUTG6f@C1C1mmr7#-u8)TiKJ%&2zMq#JX+OyR__% z(k|5`u`!*%ojIK&;?L(aa~@$3=qo{R>ak0tOKY!5KW~AU_;*2PctTxOql$v*lK2MYo&5^-57#Q4h&matQk(J`@dJB1Dh)I`r3TqTw|&9`yK z99IqF$u90x&u&;Eo(SuYTC6q(HEXVI5xgSmtxo1kh%RDKgk`b`C#5$})^2oUD&IUu z?vdKOCO;ACR(>Fg#(AzHnkht5Ld&=2O5bi_E&`l^cA{M&M36*Hhz7Z~G$Iq;y$V%U}`m0_AWB!5o9g z?UBw$72+NW)Iw`uvBPpy*L~yNMCzJCsy<0VIQmr8!c)V`+EDjYnos)QPrNC&HDjK> z_?2%h&B3urLI!4r29;h-yzNrhP+J%o!h(AW-SFp$*6QvyE;kN7YWenPUJXT)m2KD< z>t4_B>q>izaAs1$aB@dDs?B4|Lz+@-(iLQvBG~qk;)tzuwhgqiFVGQO^_13rEf*V_ zIOOv-h>rz_KQPA_;CIg>j=1JLTQpV>GDjgGCjff?0GwANq-!@iVG=~=%x;+(0Q-vg z>KT7MIZhAWdl5PEyCu2n5qKuv*f7A2*qz{?l#VgS=UEzu!s}U>UB>%aL1VYSrD^Ft zB9~jUPyus{ZmYLi-R7Ha&Ml*tYT+&2*d&AwjKd@bBa%8~;B*zu8o0RBQFF7}$4n(C zy-rpgT zjCuW|IbBD?S32y@CIO^>y@7x$y!c)4vS{U9u{@GWLd21-OR+cr4&<7#cJaX`7s^5v zikFpRFSQxS1&GPot%V$%U;=9PmGKoUAy@uVdE;|#$7M%|9p>G+G=x4({v*Gq-!IyM#PP( zcpm4Dyn2qO+Pq9H=t4=#&%WDhZ9M+~6t*GhFL3lH(=4_9C(CG7HV84dI5-&3B=UOx zJY%JK=BKCKX%8$(6p`98^D1Kil5ht(;~eB;<`^dj9-A$MmKG8Hj4bYU?he}k8@^%) z2P7{efs@A_Yo6C`76fBDoG(la^YpF^EqT+MYRxwE(f%Db)9}@4(;jG9v%+<+4Bbm4 zGR-Wqvax-qJ5-zuf$xEn?d?_5PL|hDlG13N38Hfxk;tW4SR54~@(4U&*Ky(rXT6DS zmL_TDk@rfAv=qfRl*>;Ev}P%tw=i@-t&(s!AC>^GxsEpUzk2oQ+R1-b z{{Ssc%lxA&X0MAtF^780E|4jXBul$vYLz{^tp3a(GBjI zZnr4T%tI;KNXX|0+m8Gmo$*j;+B>Vs6nowcs-iZ=1~bUxIXU(0J@eCQ7ZD|bq>a7u z8)@xWw$iQrl!^9-fG~W?5(iLaBdoCDf2S$q<6q4&bw@?jUj$ zZv?kM10Ke&_$R`!_-Y5exh;EfJb>;b7a1TEl1Brn?}5fo6{o0ppHq%DODRlH0)Kky zGI{7eoh$Sgj->GMbJccV*HfiNrr(aA*ZGlnTS-VSOcy1j3ETcwz(`ysV$S%qp?#GoBqMYsmm_e< z?0Fpy4o;2roKahsl&P12w3F}8r}-7@P|Y!T8M=J;-%i%`@6m7Nwf2LZ7TZ>T*8c$0 zJnKx;Plq*6wAZ^^$@e@D$X1q{rd#S7uzQST;nlH?-SJyKEBJY7 z;py#?*q9qT}Fx~Rg}6T|>j7KdxBX}54eAZCr#of#CAz#tM^7*oi=CnubAIP{+mgu9gu*~_3GDd-P; z*8%?k3o6)INgQeq%bY0y{uK{{ymHBY%XIk#$sG1QtIEaFsU)#<>8QzcYWMU10E6FW zbWA4+`>Ec?xNBCHm)dDhv@9ya8=Mh>co;S2{vLfdL0g2>9u%0nU4W18~46AP>Y>A+71UZ;36HEu?7fBxN!f!TClx8LY6eshLn~5$?UT z?f(D|zkZhMa#4%AM?K@;9jscYO&obqgUrButM8FmT6c?eonFA9kjXItR3mVsIpgss z^RG?tx5FJI+q@Snj{AtnZNTTfPkG=N{3)l(%432|R_sCp?f&25>z} zb*bPfx^aiRwAXf@UqhNyz5aRXc;>gLc)_7-6_IV?Qi`&<`{kGg13YIW0rjfh9`Ktr z-zqm zpLmnR7Pbg2(il)XZ8IG1EKU`3(~>~?;;nciMW0Z&mT4noIU{QjGtc=o>AJVV?JCmA z?yjYd&Jhz#XSn%M6+z1qc*h5xg9kk;hLRiK4(cFp-D;`kI{;5kG8A%u3g!Ck99AEa zg0$a$+I0S}rM&rmuA%Qedy=ermVtbb+-@+=-zGY*xHZw(K{lN%_ZQEbMvMTmHzSTu zLBTotd-bTbLk^*TWp){)SQl>H#3AGpk(?92;Sv%6bFOYV-{|NwL(d1?9RJ zH5fs|#&Agk(xLG;gKjmQPS)naDApEyGJrPWy5!eerua~3Huk{^OTDo(NJ^3Nop#=wNcKp*K7-p{~iXIHRzDrq>=5q7Agq$eL4#fBAT~(wlcuY)u zwCcrLyl+yt!H_03!zuq%xj(Ac8uBUX6X@n_CS= z-tGs1Ev1Q)L@0d6ehQUs*Z=_e7&*u}8%}wpgQrD`Z*u*GJr^S?!jfj1Ch^MX6c;drk*cIO;3uvkoG~*QTY4rOnIoUbgh^uWK?Pho+sOqGys#;>a&9?C#Y} zka?vWa-ZH1*i(;jn(MA))Z}y)vd5>OV9u(Ct8pb@s^jy&#=?c4KX?{VwsJq2(=SbFg0q_t~JH2psF zPiImp#z%_vw7b%7ETy`;B2`v~cn0FGhafgN!9DwPUS+Ikej@RPgw{H3*Y;MX>=o`3 zc8o}be9G7(aOv0Hy%ytA()4{&&T}~yH-jv!KIVHI(%ij|{3LeMT0U(WP^HHU$T%78 z(Ee1g73D%y;|hB_-7Wj*XFKa>uE#y$?JH2#bl9xqyO!#GGGLE$Jk2DgQp>zFnFOca z>IplCBOLU4mYABptK`8W#i&L^>J(?@2bNNM2%7gG%b)%r*U4kXN;zra!&d$#ENaF8}vMP#3?q99hJ-4OC0TZvqzYT ze3mOLY{TxMQb-;0YrfR4u6#j#u3g>P=*^+rSPjt4lvq5t*%Ts{cX^|_>p@V*Kag!DdoCbX94EmF;ckz_9m|A9}%U{>@;TNy5u){mG13E1^Yax2g@oB&5ozuv@}l+>DPLl)5E=*_GEDw#=ro<=du2L z))i_(x>TzCHn-1jyRFf|6r(4~^*s{nQLxt}OY3XZ5M1o}cpQK;f%WFOeOtjckn0aL z)|v^R|x0#d^Z7KQJBcprbxa;N9%IU_fI-cybcG0VM{M?%gaW{1WMFYk(R7<{33!HU>95`!tA<%OMpVd=C^^cuPSS7=26-6AQK_z8TX?8l zT*c-^-@`hgd@5OpmXNjeWqh3`TjMewmYpwlGR|xX&ul4HCd=hnN;7W&f2N%m_SIizO| zAeu1B{fl7qHQuFzmL;_(d+hb|X?k|v#*ycYZzOp3i>uja+C{dnWb?j^W)R;)yD4bw zFs{AIHcmn5U7n|>4MGbWgu00=)mm9yA0dzvg2Xdm^%&YX3VGd{==@`GWhI0uscFYe zwVw#KR`En4W7LP+oSuCM;PcZtb~DG~o14us^k&m-{FywxMhHwX97xCzs~qP5bN5($ z!-6Zu#KxT8)*`JG`CEHGHTe4Ouc=X_<+rKAT`TL}YG~Sw^XmTqW&P|ra_m^wq5_;pW9R_Ok){$XtcN^+@Y*#4I$^o7av_e^!5rTMJ zVY?oq1Y@6PFUC%tYLupvq}H>x%`S~AcGAYq)Zwbt{r>O;M#bIK6sAMpNjbq6Sg_Hr6ZQ1HhM<%|B zw$=3K@R)|q0i<3g0 zq6OT*U=>{F<|(I zb^eg@TR;F|Xv{X{K>!ns@sHNIuNLbX)`@;~duur6SS|A;1%sDVZ<&;LI6XZDYyGA5 ziqz@Sh3PeC)84+TP4_}{v}r$h8_${*%Z3$}B?UW0-O2ZC#; zyzsr&qh&6ctXn;#cabEq%PQ{#@>Juo1Ovw+qqf%1iS+9&M)gpSA==vF-0h7EVTfqP z6u8*EK_es&yg}$*370|BmFB*@yot6!b>%G5Mwt>wtosN^+sgx-e+UFN+U3U5jx9>P z2}&*(-ED1jvwm0Vmq%rHMh;CzPUp^-e+zyfS=?XSSz5iwOQ@5~dA7n?NPXMM2WtW` zft;x4Ak_~IcuQ5#{6n`<)5XQrrNSGVIEuHJWiCUavCAk5#IWET0zo4_?c$cz?XTq4 z7Ulm7s-qO0U!W5IOs_kC%L82wPUN!zErWtD-f<^P{>rD;s>um_=@XU-9=6} zNy#Ou(_i?0zcM8K>osd1H~7Qh4~_LZ>#bw#lIs#QirBP;<=UsF<|-HE7zAXDjxsr~ zX42x+r|?yp>iSzjeQXy?xkM__+$!QXBoZ(H&UU@}BOv37rQ%NkogYllv`a}?X;u97rTNxuV zDzPBv<{2K9S55G=z7@5fN|NF&N5k@18(-}Ultnio9Ql575J;XNNMcSIcHjaIJ$Fj* zf$)Z-VFlK=EcWo*DwUgLlFuOAsvY?GR1t&sjycKZu>48kNwu9#qO;VseI9I?88lfV zh|WgEGBmO&SjY{WXD$K9LyF6*)~$zhy{u&o+k2&@o%^l)y6vWse6QKtez*R=t$Us` zsrZvZ@UEpMkAJ0E-LoKQu5`#_tA7bRHhxlZT&IMu^n-hG4Zf*wsoFiC z*=_FHJ+6rv4vQ&c@;*1N?wD2N=Z?ltftS`6jIdoUy`gGtE$rHq^Q3JI*(6U8I4al$ zSyYg~4@_4z;E#nKE%Cai_NDB8AJH_=^k||M$`)n>F~CqaZaY}J5_8hNmV~f#Q}%PZ zcD>uz(R6xR+b#PNpsBQ_sx@r%-&k#YbwGRDfxT_DYKJZqVqj`QIvGBmSyt9QTXkl;O;ePo=;4=a90mkpG zdUSJIR4od2mAh%Kiq86P)Y6x;r)JgLK3{|3Rqu!N3r%mv+J(1<^!6rG3$c5*7|N(Y z%Q5sk0oJnyxuE#o;(re4J{Y&~-JCEr)DuoxOS`if97&V3WD?szAY#4$0K^{y2gDtB zLh+uPu1T!vEh1dcXRE?V5r)Z7qmVK%KK7Ail)2<@5i&M3` z!`((Ce=(v(-yRsSRdKjsazV~(*~+oBGa0^Wm#DW&@#S~g-F;u+G`ZHU&(rlOcsKTa z)I3snqY_(!hzDPp#U+}zthkXykr-XcT> z51i7Ys^A$4w(YImILAaE46_ig)uAb=BLzW9pzpXxJ%jw~wWw+JtB9BkgZoq4M>4qb?%N6+o{8?>I^NVB@Tc5OXb&;mUF0F_+h?`>{C>ykTH6>R=d z#w;WutG2tOn!5A8z5eq^wG@|Lhk*PxzCHrF@ddI?Z*!={aRg>dSwxELK{(HTLvih0 zpN@6=?PEcQS<@}9g{|WWCz2#$mh4GaS7rOB<=~#pn(^&JNt5C4i8Qpaw4B9sh^81g zc_d$zfF~z*2q5u-NCSdTqig>F6?Mq%o5DY3k3`UP=!`cKT&lIiz;buEIL0x+89$|Z zSga&!SB4=jYpdS^bDt3{=@^CV6Ju8pUtgJ4r{5N}~c!Pbc z@=0&3L$zZMZ6Xw9S71zR0XS}>A%O>)>zKhdqE}8;_{pm?x^!zNoUG1USd^7RouYmk}X?J?FT-r$}vjs>D zi7yQ9Fn0x0oD5{(=Zqg-`0ZhCd??zWebZRU9n2CyH+hL81tEFl9mqlU2EJL<{MOUk zT8`o4kX$3OGJ=IlfyO##{{XFCq2LHSN38rd7FT{KwTnm8CNfzgAvacvO9>&|z$ij_ z1LnXZ1Z089io?@{V~BD})=zzV^dVD^H@xkC%lh;?4}`uE@V|+p)rPTU4UVyX#v8+M z2igQtZDJI*e51KQ`9@edHPu*ne$P*b&Cx7&X>l1HOUekB85`qgV0Hoq=O6*QsKLjT z>)soe!&-HX{hx^+S@WH=&-6Dk%K(E06=w>&%P9wpfDXW0ZcLih{-tlHTU}pGEyb!? zvOsMidv~@Ek`=cOLdaMXj&OO#4RKea2{%elnM-YTRH{yFs4IFD4x7aLh?yhHBi9p0(nazBT^QCGUfL4hd-YfE{c0xDT#5PI?~X03-46f~M_!fKc$ZwZ@s^hby^q;7Z5T)PJG(2pRcYWp zUhtfPHnJ%y%10~M;=ET-Q|0OUmDiTqVMy(*Ji)qjotTx{5M;(zcKk3Z4n|6WTDmr) ztLgfk#;an@VQBLCH(GnTyO{O>N??#x4@2pmdBt~21z!(O+D1Z?xItRE?zhB!NR=Te)BX>4VPhF-;n6w}mY3?yVMStz)@s zQ#Hx?*BM}-U}1h@bKgB{&zr?I+Fq@Ba=}Hs5sB7DK>lAwZMhj!lQ&;`rCKeyX~d++~ntcjo(-O ze_NPy_^$6$)3sL7=ZBO|B`GCeD|vhfC|;wwot zO(t7;;)F*lD>{a7>cwy~xc%120|4L>Hw@KG4CyG!oODk~e7w7EnVh;a*rV{?i>zs) z>M3S`&WzE#icE42>g&M;PBVfC??#&ki*((ZWV&S6mXHw9UO=nz`YuTYP?6UkDfJaY z!(S6Lcr>d!d0?FA_sCqpnFj2Zy#(4zeBy)qt0MA+CeM$5q9-Qu%aL2i!pE>g) za0-G)7{JHjTnihF!Ntn7y`*KOo7HJ_(_eR!=#A8=wR>3dKZ{nn--##l?rg4gIUZpr zmuDGvh;1vJf^&eXN$P7Kz}i*azKN*KdL&zWWsI`R6D*%O0dS;n2IVKFE6}X`P7}mu z8zhQ29Lf=b?O~0)^!a`LO;Of7LoC+<;WtAi%qRnP*rk*xJ;1>n`h6?1jxMcgadjUo zue!gfoK#~L(VTRqxzlaN)wI+?vj|@qxQrg5bDiAaoMWbHC_FoRHn9$z(Rnv>F6g6> zfMof1C{xgu=sD_fT|NH*g7i&S!}glZ#juk21tu*)q?E@VLP${WQl*rf9OESzsVmO= zY1fn6*d~#x!>2`Jv54)iqiN;1UIPux2v*1#U~|tsGhbso?yE^rih65%t@e9=UYflZ zD{Ws>&ouu46+-bxrhS>7NKw}5Bv%a-g|W39e5_Q1xD12P`qydUEh;Tm$)#KUI%(C) z{f0?hW@Q8BV4!eDN8Tf->s)t;2C1gqS!#B1-0Cx>a#H+`pj6qn6!nW1$&7s(`?pH4Ae4SdJy(UOLbs_5T;FTq=( ze6e?N{=cv5QvKb8_LjPAlFb>qx*`U$LQKnXkP0f`GXwl513AViG;3>ZTI%-R-J^?9 zOgq6D8IXkmvy=C*0RWsITng3Fym35m$i74-GbxVxE(_#v0>5l!x>qUUZ8FN@=S`Zz zGo(*%Z1Py%t2q*ckOzU3oumNUu)L2n6OeK}d^U9|$;PCuD`{?)dp&lwwf_Kz2vt*7 z{=ct7tGn=Ynyrj-Xx5kBBTdL$TG{Ss+jmAxlB5t0a(ZNOT!yWuS@=0p%Tq6>+r#C> z91Ip0+D;NM0A*eV(AIK-MF%)V1>-k7|u^`?2QzqBp=1u*WhE0+u*Z zGr=6{d`XvOqFCzo8aH0omAN|D3!`^~+& z`F~z#R8zWlx+?h6>dQ-LX40ofA(R_*vJo)3kCMPh>M<_j?T05EZ98zHrSPx8q2ntn zjcW4v>5?-ov~WGENbbs@?qXR$Tre2x#bWAqvv^im-VI)R@h$DUy_MvO%LkP*$Z)`9 zWmj)Q_o}{y;%o0EX1CO)xzz7wlG}W0`)r;04tE@EEEsP3=dEF0mMW9yp({$-z1RE= zH1+i|rOPR&vwiv+_kRfPbuT%*J!qPZ^@Er%tfVG5W_1T929T*a!39YNJcEKW%E_eL zX-}E#u3?7YE)n16jF8z?kx2!(I6Q-oP(bYbQ{z2i$HiA-)B7*`JYr_GxV?=SNck@m zv6jXg<{rGBxUMSRTb~Z!y}6r6)O5R>S8p!L>0M)EotceTNWohQaxy;m9oxiL#8YiM zNo&7P_#b%nyG@>Jj5HWjr6KJ?meTA6CWpAlyqgu0?;?7ML7g==cnQg?fTn(T~3}>iMloB#Ga5I|sspel+r+ib^HrKzJ z@8@GqYg4@c0Hku(zYFH@&F#h`yE=`$pFOS2ds(ADazxt92=xG;P)}y~qfpcy%$em| zIDpzwxIBGp(Qo`mJ;tS{=&g6;-K>$x4D!H9TxTdA2 zbI<^v!%yCg-St|1$#m0AOdR7j`FGUoHLW@wD^;3JIhs8on3$!SVJnOb zfWdNCBoMp3MomjIL#b(O)`-ok!x?6RB_!QM0VS1xzMv97`^qu~LC-m1;ypV_yjGbZ zy|-|&&k4b4A`YxTd=i9}0J7n*Na#*>yd9w5c!yTC)a^3`y|XOv-0#{IgM7a*{vcH3 zcKRCZf`u9itygBcy)WccF5ugCM=N7+rf~BVd1OH#+IYhZujoZ;YrYZ|7qUe;x{4-6 zo-uP5l7V_3CUW_@#Ye_UaT;Ld1}{8Rx0|z^+fiJ~X`7^$B8V%mW3Y!(?&V zy>r06E`}NWhJ-_OShKpvg6seU@zmpQze@Og4jM}lS~d5cz1OGwAD33bzcPpJ)!Tp9 z^|{CRo8gtlo99fbOYP2a(AS-5`lOZ@@>^V-6>e+n9cC!CJ1I9P%SXW{zANJ2i_%?b zejtTaq-8*3j1ovulGq-DEKWU1t?=|5s>+*wo=@~RVQP`)J+s1deWumfNM!(hDu;<$ zD43HP@!q(vhCV$O+G&RGDJjPd{6jd%{6YMys|_cJZ>DttZKFWQ%GoS=+z7`2e7HF| z$>3w3o1O}-3{;esi{)_SX{+sWE4>n1%PDRxB@xHHc0ndtik$P1FmZqnPNM^^E-!_y z?)3GFK(L6peacC7!QdQ>9N_1k0T|~asqiyLJTO^3tIlL*R%8R@05flVw@w25qtN3U zjdgmCn2=e>@vA&YLKeqN=K!BmgZUch%sH_1<0?yDyFcstYoJN{Ia#yiMeuCa{{Rpz zB)Wm6wt{W)#NQ_DAG?v$(!F+4)g6HdGKQ_-#V z{=RRzT`v0RUK%y0=ly?|%BGj0!K!OlNYl+20VIkT*K$c9U0?Lq~!YYCT4C9VJo+|2ic#Wk< z$i;ev{{VpFvxYgAC1UCr`D_W~0y=$p^yyI8_%cb;E`e}K>zevJE*hO!x}3{vt$Y4I zf9kK#QcIhkZ|nO10EaWa7P?qnO=)n-{#%jUh6M4qKb>}dD3?!TEOAM0aTfA7eCkR0 zc=R}Kc>BHc##;DTLvXB$5g2UQxvYbsEpD<+rsZs$15P8o( zr(D+@-VnYQtrVB$+6#vdV5!Sdo)*1BuP9{$sgWW*d4sG z@;h>@KB`A*>XNC4oRwI%o7c;>y7lShzivv=z1sWEKVE2JwU;|o0mmKdBf|4Xd3_v_ zkm@)*@(pNsr%QY2Sql|Y&Q#!>7x38}5ymWY#tOw@Gq=K`5geeQRG%H-BelA0X-!^IjaOIK6lF0nnex|s0HbH^kB)N*lwfzWg6bLj%jEbctl zUBnN)pVGO%6lzbb;=tqV(t^e{bB*JLIOuq0=eB*#eJ&2Z3)n^rS7zU@^tSu8E-LSq zI~BYqb#60c98;Q(QC}P3DaR z5+PtCBw%`1Rq*q~@cpsU0HSx1WR)I9QJxjZ2R*WJj1$iUlU$W0JU2CZH?rAu`Maxq z%qh|4Pnn$!pMhr67tfmDvbzRUd=P^GU|@S^ob^4h2F1pxG7Iu;~a9YMvbtPp{m{g0K<9_N|)Pg#n8M$vD@XzhD5@$jstoh@v5F9 z@kF!!>JST&jye2m6*Mhs<5!X^hF6Spye1f*b6vid;RtQQO>y(M#~!u7=+K2^v~qsG zclGz!)^Tb#zxDlSWcXiCz29hF2;_~JnOa#2vaZwfDB5=&+eQf-5_856r)Wmn)nOCd zM+}n6V~!?Z42`t5)g8{&V}aR8?Oe6}>)P!Qz|tJ5ZA@fj<2;emui?gPV%AHvwq=O` z-ZQvloch+-niyHNPG1+V_4gRk=4+Xp=f%6F1ngs;PBUIhrTD1NbR)Q#)lrIp(3TxJ z$4vfzt#*GAqSRhzR?L8nq=Cq&{4wy6p2kf;+sSn45tGY8H_E_dD()a;^cf&}kzUO_ zI>KPTc`Mx3Da%8vi{eaHx<<(eNw)BRyghSXP2vw6i~U>e6UrQ}LXZYRoMZ#&a6cjG zT_?oN9!)ya?6$V&%|XnYTjmFVGt-Tyr#$o2SC3nGb5Ogx^6mp5CxV^1tTOyH8j07O zx9`1~gL8ze{{XE&$nR{mXy=ut6C$c8aTA8jzc3qD0F2<4=K~6P&H?RoCA6MHHLGx# zGRA*|pMIJ4_a36Tv7!hq+V1J(n&Lyew2hW#CnS=2B=c4O0JqxKP`1&OG7uf}@~9N6 zQKwaMQhK$fwoBIQ^U1C}6TI39x|^B%S0+E2t8tW02i@JI$SK|FEBJ!`em{6=+Da_CU0 z8<_9`>qZOP;h^c$N!!2Od+XQl+d`{rE3KFH{d%2yUs~Q=pl6;mR##JO?5A>&$_Z8n zX70(8YlqQhlH|#YQGRCf-XJjgwKtknDVS%^!PDTOEd)YGfvz+SFT3NMavcF#bU42qHE41G= z&XdDlA-c2I;joh7WVMun2Oy~(L9bia%x|Y`f>FJfJBL;JSA=T^%f7d~k0?ZgagBEo zj=X<~72bGHT{^@_vbj5e+n&d`t}OomDW=pj3@^dg$@KoZ8q%osgo}%9ri*x1=0fvi zsodQg=~(_Elvv7!8(}16epO~6So@qDepS8VPXoNx?{^ap4(<;mR|lsX={2|0r&;7% zc$LeQWsQj^0ZAAqu z-XxC;`^RSBE)U(~lbmtKQ}jGzY8LthyvcL~t&#(0f(Y;LT_&%g-s!4!0MZM0MOgrm ziUeK69huq>%yK}<;1YI>X3gOUejw;J*IHfdUR0>EW@~p65w*IhDpcgB1Y~Vsc|R+| z;S?!4)$bZ_uDkiYeqKn{Ek0>mU5`Go_;aTCviNGcT(TtSb0{iTIqZ88?^}1i9lF)z zipt)30>=JZnE_G){JqB`jt@dGM+8>Bk#{eKC2b(a%PQv+VMg(SHj$6VjC8GduOZQN z>9rX^my$_S&mC*87hX|ObfwR=ysv#W*UMk{9Lo(2Whm{}`u?;z%O4L(;ptK2a${0H zY@MT?r25yZ>H5r97Evw>AyBHyagTcQ1^9uc_`^^+|r3ei{li+Fs((9Te8F&l|@3 zh&>K#n7sIVb*x19erd5b?h80x8GUo^J6BcU>FqTLq`6du1dqN+kYmmvLWa%(KPsr_ zoB%si-ZQvuTS+z=Mb6uYARjD_ndEcoIIkNET6k<)rEM=~eV2W&r>5spQEQs|9vATg zPPu)48*5vUb#AMj%g}L-M{N7mJtIWEfu~!$*D!(%YOFZH>_` z7GW9NANsiX`-_kc(a>-XJt~f~;oVoms~HyGX!+Gn5VukWcnAFTz~;X4jH=X7`mAoG z?H=vv`P}oIXBqOPxnKShiF`eG8~L)`MieOlixbDCbUKch*B)k2-0}{omfU^;h|>f}C4v*zY_Qq{jxEZnrYA>T!;p@$4zmX?B)cl($<|!w^1p zz(7BxdE8zj*1RmK3AU2z1yGA7?CxGiB$8P0IX=C5*ITdZR?>JLTj?C6tseb=eAxc) z*0aK8a;aXWM^gGbYOMbNtu(v!CarfEa->}G4U8Iu3wLF)k|H00k(D_O^#lX>R$Z0! zvs_u;nVK;i`=!V6069`e-5oi_Rnqkl_FGeL3i=+}aL-FpejDc;7%isaMgp54ga@-E_v zGmtqvS0%5(9+Q0y_KMFlvj(09I3-3hN3K6Q=9^iX>rpupPF1!RBoMd+(V30_7;cT=3yRB!sRWO6y-%zFV9sY0E`xoz3@y zWAS_zda++7R6-1Z9m=hYjCvebFR5H=dQ(1`q}ek`2`4gPR!s4dPZ;{~0Pa8$zh2E2 zl`V&dB1i~kb;%&-ar)Oerg*H_>ayv#*3h$&^7*I<+IY@SL7pqd!{h1J#5CVEHP^58 z({At6z0$Nk8#9^sd!lOBQEB&SVHwk1F(_^t-H6E;Jx))0-0>WXZ+Pa=d2%nz26+Df z^;b`&>UJ7jH-2)BJWVKyHhEMdG24x!IT>c!2?Hb?X1vQ(x_D(}Nf?_|^6>IAyC?*5 zSdz!wl23Z*r$&x18!6NGQr111-Su{A>r1~jRZFUA#c0lVRPi0|zwr*kPn?&vTW!|i z0cJ@F!v-D40|&KrUI#BOkFRPH>UTC5GfwN|%NF$rK3u1vBpP1`T4{H8S2~NdO?Jva zb{{%qbB}7z@g}Qx;f9Xxc)ZOpzSAPGREc(;Sduu%1B{%J#xh603NWu-Q%Q40E2nE& zYqr|<>$bNuS}7#$f4u0l3pgj!S_IWT+V+zYZZJ;os9*>kI*!%5r0W-YJc4=D-B>tg znid%59~cgm;t8%`0(*Odvt(Z|7|8_V>5QLmQC^d%{8+P-!^LdL`(4Cj zpj0J6;1F9F2a$kIPdMmt%{XGPmE%e}({Ab8Wz|^O#Z-;n=XZZ`rFhQP>TN9-P_>N# zXGc}Y&orTKN`oSy zBXPz+AacgEH2rr`xzs01crGD^CMHnvZ5*D!^MjuLmBXB2)lORU+K(?w?{~edV+C!N zhfm?ph(pB|7jtQLR}kAk@AQ2~!*`Qk-NML`0p&8OA&+%Fp0()CwB=EA zw4|1vWK?pd)S>ttKBeL)bTzcn!yeJiB#UDQ%u zL!wVD&YvaB0IpjR&U>q4jlhA7`d5$JYs{+}MJd|Kysrg@bJy^zel+nN)y|J;G`FT$ z;BrZ+@a3J>n{boe+d^&TSKvQj)uVpL69-N_sk=DaPrX_0pQlDjk2GE0 zy$)LM<4=b?XJKV$4BIZ0tRG^yCO!ecP&#qj9jjh39i7mzmhEAV*qIdzaLjoiXY|c^ zKZ-saX}$>YK-6_$qC+5fEn{x1PJ40yuR)sFYwIQbv#iMIr9+T*FdU56ekyUHHCbK% z0EzA7zW)GU#HmVEBd&uyQ>)+HTa7|17K#hDFmH-aotuz)40?C&dSjlpIic4yt6Rs5 z?DsFSGXq9g6SycC1E(V=pcy9s6JBL|;(bQbOu5sV%Iw-B+Kt-2i(Ni{ z*1e3=QPJwZuBWE!tu=gl~y zkV#;;#_ZzFE1KlSM!p5cr?lm&)timiGJn^ep-nXIo}Z9f>9-o>5bCl(u}venf+>kH zpakq}9(f$(_p2Tb@OG)K>iUJn<=!pgjJaiC+h2@i4mtyxW_uIH3xe9o}1ZSLmcX6xSMQ#J)IT&t}9=+;~=Y;G$d1Vfps$zHyfu0uu zmPPIkd-@vR0iLWbGB~Q#laAh=m)r0E05hT!i+rcXw!ac%_>`JXuJ)F;ekIb2ME4f! zg~?nAcJu&^#|NQaIK_9`f9&t6_~XMG+FY45?M$t`#+z|7mB$XXF^%ZB~--WL2^_!@#BrR!e z18&p|1~`$11If6aaetqn1ut%8i)3jJK+%HPD6N$}KK5`y zud%K#EHvE;B}*7?Ejc6v-6n8I{7Jyc;0o}YU)lEZ(_hp)K-Ll1L8yXl>}`fE?MnsR zT%7!;Zg%>y=nZjXS%oE9t$N=R_;%M_tIzNlNQE6+g z=+jH2+Ceq!cUHF>r+g>`EPZ^iK5v)5I2kqcvBqKPR;f{4^DEhFdtG<)x1Y##{iLkc zo9dg>%Vn*%^*&tw(XjCDuck$)PBmzz+Sm5>GZl44A(}RqYhqG3=)re#3COP1bqlWo zE$B&Mv(t4;Ke5X?H<=rwHdv=(%eU^9RtyhLmFPYg_*?Mr#u|ZbE(={M!JXp$<;;Rj z%1W13Cj=65Go1R?pT!S?-Yu8I*7_Z--LzM+vQ2At!!TT|YC@=C!m$NUrbT5e=L;(F zrq?aLE^R(~U#{kogp_$%;@X$QjbB@}Ll&I2b}>S$YaFmYmnc*L<-;GCoMb3C$?1+M zEc|2P(F_@y;ii-V>wRvju{R`nZy!bE@NjTyYhT$@$J+gqUtVk4d^(&^wCQg)h?2|y0SZ&@9i%9KamKTCQshO*LTcDJ;pkHYYWDHFONm={g#bmcrB%z zdH2m0<#knHsJpR^s@!9_9MxQ<(h<8`#OP0Uf41qcULJmWpZa$;L*G^od!OIEvdetw#K z$f&H7w&sU~b;!Imu4;4nrbLjWkwOpO8(ViBFf+|`y62ClFASLz$r`Eho90DRoR42j zisoeaH4Vp%Hp{PEoh}v&5wTw><%x$Oi)U%W5J1SzX+9WuPF+(@zlo%{SVm(@n}yt^ zONJmG2`i4h^ItcM!r@}Rvr=1V^!b0o*o2_tYn^X|HLD`i-Q7a7OF2{JumuKKHb_0e z+Huqo?Ns$WLi148?rh`J?*u6mU~L`HUJbn}VSXe1hM2 z0&|c!sZsXQsX9LnuUECZcYW=?<*B&dD@E)5Bh{N!`*go!YlvXVK6esF3-XMCoSp}! zNXfv>XHDWOy)<22NvOTN7PfOwvQiDsko;lX=NtxURRwpgDrU>H)iq?ehKXLP>wbN~H*SAe> zM0J(+N2d5=#A~D5Xm^^NFkk72u-RR+OS)TW7%HnbKQRr#9E>(=q4C~>f8k50^-DCm zn#y!gvD-!ybF*&CF*w}7D)KXdUJs{3s@Un*Hag_7>DJe_&-Qp4Fn)CauGs(pl74KG zNgN)6u_gGE;x86iM{9GcUd+%i+iT}BNMsxm0qM|@=m^DM_FAZ&Sv&8qO?&V8oidc7 zmnvF*N7FtS(<7SR4Ql!r-sbKnw7raS7GS}lkem;gu2itbN{?FSymIN%%; zK-Ii1kwr4zNW)Q|WU$l)J$vS+|JAETslf)C@ zpo&@CsaZV6R+!|LQb6Ei9D~U{IOcyDX&OI^EPt`@OJ3Oi=|%e}l1pjRX;~G*4Xus| z3Oe=`=iVLrRlUxpw{gjBa_c3;4+KeUku!lLZm1PNjDn?A09~v$j2gG8d{6NXou(VR zZ8qBW(sdUX5Jp}iPD;K>0dh*`<;eMmImjI=4_Y-`aMDWFvvyD9-Tv>;=Zv{Dmiv?Z zWbnPp-CHG=m>%;{n8c>SHe-0~R7OIM3momj@=bZS!Ry;=FNkYxex+=uf>#3GjrP z9fqwWw=f&atBCADirmioVi{tjFbElnHb^;MFf)f){{Uv)M^V*bw$!{& zrzV?a{=ZAn<7d26vv5YZnjk#J;1*Iq9kX3`hW;j6cxLKTG}79UIa{kqa>%E#^i|`6 zcssCqit4VsR@&9%UtuHca)7aX-!xKgJC{C~AdgR&8s0gcF9j87`?lTd^X+~7%Tkq_ zdY(hB{5{q6?+0hWx^<*FEy5~Ir`^EOYANNp49z2!G6KV$qyh#)jOM);z;{)=5q_ zF=82H*g6alyK|qE^UZNy5wX7T#=3mXHr(C;wi!I6oe&opK<5HADV8Jxaxvc{)x+1a z<*Ov!?EPP3p3zsc)&Brj5v&r{(QiB}V!E}!hcR1Pr<9P~$&;{eBOfo#o;YlQTUxod zv+#AI-pv|H6cQ`tlBA#;bs_<4V;XqGx;*6>QrE^Tg2+QER^rH!x&TmnlGkQbgsd%wg_4|rEl@J*Du zMwhD#>&v@SEFNSfQRI_+u?bz6E8sah4$S`m78^C^o-FuzCyA|A#>-i=eNRoyYAy=J z=f=&-mLv_zxFGTvh8Z1e*M?ynR3l2d_g?pRv*_DbU)QNsHTP{}nbUObW8u6>;t%X- zbp%`Xw3}JKRk|UvRJ3t1AqII4;j(ZJI#*Q>!`p3ZLewriLn>eEQ(jDF)kLf&kyt5( zg6C@BbGHF@oO8u_wBHZ>NBxr)n|-R@TIts%n@_Z91d5NA-?>zr0#xIi;O+w*dwU%V zU(huR8@+b>?1|(@6@{(kv46F)C{|G+;dTJKah4*suRdrv;i@iWcfW7=eY>L%c}JC4 z)J&07$&;KI785{S#P%Zx^N2ppr=9P4YUaDu~P!5_$oW2h@*IUQ-dZ znmsejyP00%Net@*Nhg&EDg!U?0WL_B_iPgZQ0^iAT&p{+UR`1sBDqxZ<99vYeerwPWt%sKqCT{Lpg23CKRx;9fJ) ztu`Djao%n9X7im$m9ZXk#9dlO$5M-Pd3mj~{(-sMrE()XFmN#TfeR=c}cZX`tu8r(wI zju}|xL;R;XRFeypIT^_cLF{}p;@Ipo_@wh;JIf$dk$2@nla$Ut`GyG?JmWYx^Sx`t zF~b$qnq1YM-A%b}kES$#5Ut(fIR+96x(M8^;s_&=+m1&({+^-V^Zv)Gf?T-@H?d z8u6p<&)LnpF1vl*y-zn0OPV~(q1akQXl^8&VPrT26VkXZiSesl$21Z|vJ!|~9A%4P zS0~h-2djJ5p11ae2ki0{mDnnd0^sK)u{?JC&m$bgq}sfZiDz|Z#z|w+y&Nwk8eYbv zzu~t13@JiaQ+pibx~<-orI9<&EW2`0Ny+vZ#P43IWLEjB+pt z#~hGz#tkifKG6ljn^>o5qS~R^vkM#@!(#wgV;tpqJRS( zmNqSg^cB&kO<=Lhy)Y5R52bwN3Dkv8XI1I=3Y@up#&%u;_^k!p zFPF(jwt4jJpZ>jlH3=#ya_s*A5A$D->ouvy+8)@+4M`oA^dZJ^B26c_^EzXqPFwf>y!ssP*dI~4k;x>p zXP-*9srW=OjK~8500O#g9^MOUnC=v#6(NGMh8%i>!5+r6FJVbt7!if&E710HRO42R zwM0s{y~@$f_;$iQ6lEhTgIyHbsyM*_lis7avTQq&p>~a+9{l8EA5Q(xr#yC}e==N3 zs8k4sG89f26Sz_@R#_37juj|yQB+@#Y$l2l8xoFDiJ!uF5D17e4WD>UJgkdoz|RgNx{R8LCti28NGNEM#ltT z5PQ>;c!sa5{=cv5S>}5taULv|>fY>##uNjR4?f@4xfQ;?p7J##1e^!QIImIh>^AJJ zA`q-bYs@se;ZiJJI=0lT8+MRgOZm%?^CD}@@OV=jkxmHuS9PsCHdle;Wkw`3 z25zGtrAw$>HQjhp`E=+aQb;@l zfu5%o--z~6qY1?}w$pC}m?k}Sty1xoRQ)04({ z$FFnkj-$-7)AaCMVU%GAIX{hj!f};fYt5>CZMT23H}o z&Q9$8G1C}4`b6_3!Vm(hW2hL-bGI7Ylk9@sgPf?txg2C<=Lei*`<~;iO<>YNbC)~R z@sN2n35&RZa^IKbP^PDwlr4DoiFb-UdMNLnHb3`j;u1b}!agN%;&BOn}# z@3o%~M|lvqfQh4#L!^jzD}%@&j-t3da!EAXIgCOfI*qZ5i9ED<4+y2ONXrm10X+0Q zD_kU{Rq{bvQhRN$>HTbsCrvHH*K~8LDn!OuV`2lz5(i~m@> zsIQ_ed@~DKNUo~Imjk)_8sg^oV3!)#ov>{l)zyK_sLrgTHLpwhTl)MAs!@FpIhrj* zz-LKhUNeo=Lr=50^BVScV=v8uz#X|5?_SZYcwW~}w^U?uN|IS|v=NL0&T?{k3~+Of zGsmuw^3LMgGGW}?goI#@FaXB~uQ}%U#*1bc;p9t2~6~(z(0M739kpBP=+WPZ+@|&q-`Q(+$63j+UOL9(0 z$@R}Ct$Tjg3r+jlKB@WZe*XZ6%;c3cm81Q9%xyQt?$*qfmgN_53Ff}ys80Zp22MNR zeFbGRUOuG>^FMbJGXSxu$!)xr#^a5=4B%zB$*)1Q@WrN>zEr3f@qh(CQSjp|CgCCw zt~kYM^^6{0H7asV%YQ=Mq>_3W5qOc=mMPaCE;t=eZ|hnXI+ezobdo|_%iIeEJdS#b z;;l7bH^x@>5yEbzA*E=eQrJaO&Q3YNP)R+2C$)NQ&Wj>ht;{N28}E#Bj>fyOn9MaC zGmMs!S|_tKrA>1uF2}9tdbiqiY}`r-MBVcqqPWd}#Wq$Nnye5kk`e)CJPPBiuC&bVmZJLp_c@JSx8_(z{nu?rfRmbE9V=FkQt9$`+8UBal*zGqN?=i-&0iD zZp`aEOXDcDWs=DyXMmEJz(!@y1b|Ktwola6kB9ywweakXWL?4+%&|LdP%v=7W0RAP z+!N1Z#Kmv(8YfeQh{u-w=r1CCgpy>XIodH(rswX1Uz0)k<+Rit1AZR@@&y}8rEYEcZK z`DKl=tUV2QH89mHMpC67Zco2g=kt1NcGT8Xx`;@wY9w{0@n(SanXZc68% zuJ6F>e{HEpHor8|AlV#QQr=!$vVp+d#QF@9dz$jgUkPfubm^-Je5i-acKqEtFs|cH z@!RUQiju~D(GUZ0921`YoMygvAA_k?a!^UrmhD>q0DYamtD{)BLf6%!*}Orkh#BQe z$QsM+=xyUz$!-4tHs&w845Spm3Nf{gTW|}7r+9{0ZEhvIw~AFvEFf?gsCv< zMRPkzv>%nbeih8>-xRdn6Kqqu$8idfjuS1k<0=oK!2E}$dG&I+;px-E(r!<7*P^o0 z-#5PRR#a=Mv)ae9-D?+LV3G9n6SS|D5&^a$fG`4%nFRBbl6l4e<|VM!Z?2MCm^Y?M zunUC)xvoD#@!7k*H#UyZz~4HCY>*E)uTWo!QLUhcOLFSKCjb+ib+0$-^ypMnojYGk z_BEU4cD|^ld_8k8R*o&vsKFhv)c*h~^FJJ1YkG#dEg-dxU{=~9KsKrMIO=QCH6IuT zwenzMk1^VT&eF#O;Ch?_e*;o{GqSR_)EM1bDGph~?FylW6aqLrj)Vd*y?SS-4~DIV z_LZtD#c3C9K5qJXXzp^U z83Ul^ymgyxKIGfMB(JoMsJT`y#b*8b)-l79 zFEsYXp%_`lG9zukk<Hh#1;@dsN%RQ{4AtGa)+5IzKjXbv+{{VYU>!zyO z+ePI608vqt+_{;0&*Ahw8`LC<>Qfb|n2p#Z08e~!E75IqJqek&s^tji4X! zHO2Tp;x(qfa4fX~vr6b3mJ5NLkfDPd;dYap5J?1alibGk!kao<^mM@JCalJZu5y;q-}{#(QAb(V7KWZ5*}wk>!owD(8aU=bGwexVX`z zmQ6+%^DhF(-@bNGLMm_=kbs3BD&SovTJ-ut@jW^E5hC%vsuvTsxA;Q-6toXOm?po@kWEK>v{z0 zx?@d!=WdEf7z9{%k}x`g6akNV`V+(&beBl;S}Pe!ld>`aVdyeI$@d^uG`cmO9Lk0)b;DFpKtQ~HAc$TP4&O^Z=UC!_!Gif&a0uxYNRdf z7?i~E0?oG|VeybYbHF2Z0U+%dcZGD#OGfb{KC@x=sqO*Is_i&YkC!Ys2S1NB-wkTZ zL)7iBBWT+7qg0w`;@A%a;AbQNS+Fv3lY)AlraK!gU`=~=9JJAx%cO-E0x)*4AcO7> zMRL{6XGWux*Sh?+^xEsKwDr`eMw8MnUZ*9X>R$|gAwzek+gYxWt653}(V`;B^1fJQ zfdyD8Qb#x^1RN1#!-m60b7^KwmhN^Ic>+0J$K}A!(z9E@x(1ix3(ZQ@1)k$40ddYS zNj|mB>$Vzi2DHBv z>nsJ_mdPo}mUb^7IOl60r9pGy^zbIzFFS37Y9G##{_=y*zXp#I>9+comJPB*rA?}!UOoLtubVzC3#*9jZS5`Omh?(NNQce{&rim^ zY?;u-w+$27r_gmHC^VN-*ENp^>7FHmFB53iCeu)~Z!+UeW!f79HJ|?g3Ffon=Z@ay zB)7GTmR|nD+o7xf01I_X%}>G+%B?-7v27XqO{LocSPy>uQ!Xy-ubFQk+Y6(9_7ZV| zd)FM>RcA)Ld7K+b{{S!f`5DeGI(ITP-DcxZ)LTljjjdUsUou$KvYc%VlFg769{BqEb*-<3 zu}^(_sF_+tfhFG(70hx1yq($OqW0(1U}w_ywBdqoaknnr+w0}&Z@7p?Q+%l$z5f7@ zZ*;wKTOCHyRJ8LO$d%YTnDg@PAOOTJ20$6dL0yHuw(#k&U)o4oSeQs67#riqBmDYR z4~%+-k#`IhK~g>w1=#9PEMylAYez)!e3zaf zdr1k?Jhyv)A=!5^9X{zG1{h!uIAM?uK;`rpf3+qvOB+UbU}VU^Cxcf$Bzz^*Ztpa0 zN?Wh9OBKT0MSPO71o;?`lqn8L3b-YBB2{2yPCqO`s3siAkT z&3}EUcxLsL9L46_PDjei=zRz^TGPc=-W)Jk-#LWJyFqc4V~lV+nr@M%U29sE?x%9U z=}B(u7Z_4UQV+dkOEf+?v=(y0O!o4SCUq*xz~B&1TH}>EPOPcXi%`C{>3{fRsx8LX z)LAYxjV|Wm*7_Lr?J8AdHnRb;MP=kLJQ2I$7I66KdO@><^CFDw2R6l$nlNnZZ|f$5r0h1SShT3p)OrL?Rg zHjTtK(JXAh#<)3R#0KftIXgf+&x~{{PZ4-z={8r<*m#3UoE<+^w_wtFNh2(g?-(Qx zo;FsUXuPh1~Rq8Hz?5y#pRKYsO`reg*(N*Mu(MPk%OFL zJY*X6jbbkm-bHVx+gn);4Ud_dML13X9$Cr8F}vKF<72jmQ-U8VJ9#72+0eAiaEcIt zoDqTxaoZqd5ye%Ec$>hoqS$KDC8I^=HJqO}lrln*>~Mc0Th^U?Lu!q?*{kWNTRy)p zLZw+WoK}nOJCxKlEj--m79u}3M=K=8Fd3AAoVGK_;;Q(8@AL~ej76>|jyB522xS=? zRaf69B=!QZv>zT?_>fMQ7s~f)g=Lc50h8R1T7mhMaJpUCfL#MV9cGt+H;EY z+-hL!#yFeu#aXV`*Y3H6uJtJRNyD!p>|rsqsbX9`m$_!| zf5&e7b=4hJ=*Lc1f5`K;u+gu|Y7eYScc?~orIV&ygUdGWPf_hn(EK+9AKF)zk;HFG zhP~A8?bJCflB$kR8~^}OkSn6`SHlnO&k>DNN%CWD<<`PbKrfN=1L`?7<^CwW@io_r ztb8vWzxHw&)nZu#Mj4Jk+|9>t*1Bn89x*zUB>LU$YkqysS;jDqnzz&DdQ@6uT0Nz` z<;1BYADeh#v=g2`PxG3gt$6ASc_T}EN$#v-b@MJ^gs}erbOj&MxLc@iw7WaYO->7^ zc&6PV$Clny5_##j2PE{s&2~Qup_9ZqP2K*Zv7mr@3o*y~!}9@?$6hOo z6q=y%73IwpWu}*J^Zg7d(v!TcWQ~E~eLGQ^uXPC|RYoN(;b88BfN;mL{cDNw&ZDPm z23R!2Xr9DJ3lsaT_MyF6>Z?M@rvncX0y_yhT3a`WZOzeJ0X+I>OdyFzg5?Jbwrv55~Jc1b9Bz z;l6^p1dzctp?cz1yb%XzH>d$uJ%|`TjeEFykiy|p=d`u5O4@npcKrTBk2DfX`t7g$ zIg_jT-V(NpMAkN3X;UmJvZSQL=9S;&tLHgf44yg7Rq+1+!);1!apBU{MvwAcJ>#4z ztb=e;e~9%59madtqiR|firyXm!q%c&*e+&eh5~_hG0t4SUI(QI;x(V!6HF3Z8)&kw z;hCMt8TpvBvsn|`n z;zH$KX%(~P#zD%moP4+k9P?D|Zf+(Pv!#qzW6AqaoZKXW*X3{(qXd;=(UKK_I3VK; z3BvJ*i7c1IcUQ_FoUG-V8C;#jI0~#l8Dgh7H=CAVYgU6qaG)?X2H?+f18O{v3ng>GlKi)e_h?=$2lqL2sYT-S@Q^v?@f z-pxJASjTMuBH`kFl7Nk{0-%t3DBumx$y{c-e}MLFapNsUU24{QNE1qiOF7%-^P-Wr znH2Qf_Z0eLp0)GUy_{&z7bsdOeD9~6I+T6(umysVb&gIk% z@s-EQ*kR~%UgjDR$5T(4HKS?izRh&`8Bo+ zCvQAtj)Nwy=)N?c!1C(Jr`kv4+%ua?ILo?1%zji&%*}zyjm%C5B%W&le0}zNTWx1b zQy!r+gtdtq5?juscOS&3pUR>WYd#c~El*q1Ew4mMB$C!?12l-h6NW>>0NLY}JRU`R zGQrfT2S%+uJ9q8!?E2iKuQu&t);tNh&@*|9CKwVq$0rAo*n8*9JS*Y<01EhDOS|!BhHo0zN7OBrSGBm1 z6;}`gHV7TCMl;^LBg84)D{@a7y{%lC%7Gb zvCVfD{y2*B!}jwT$IBMY#PuNK2ailwO@pQu~QG*c(qEtr7Om6zuV#NdEeliP1Kg-Fze2|`iP{{Wl&zgu}&)|955mtUFE zX@3s14P(UzP1Vq9Q*F2w*UH-^jyCy~c;FDlvN|3QwOQ6a7wOte`el`djrO@(SlxW7 zNR^d{R%5%8dCoq#u0~%F+z4Lg-rh-d6k#exig!qTFgf(aHsi(mo~7auX|7r=hNW|0 zB}-@I#-K1D+1+|?Zrya%8>wGf!h>G4;a-ull2^?c~S=f)f$0dpGPJ7nm+NPU-d8SX|SzlMSM`>&?Yyo%L zKvnbj1UBu<*pL`xVo}A#u{3+u*YahqsPQKFLWJt;KznTdS0M1l% zj2w=jpOj-9=k$7X+?09x9r$ZO*|P@ESWB7X)5XY1S~%tc8PUO4K;RZaKsaHQ>ZdBe z5J0u8cxLf-N>QB%#_qiO*9W3$SDIFKc9ItxRp@lkI3o*S*{8iBKyWU|6;AXueH2E>&Rp$g$Z z2j)a>c`UoRAe!U!yT;RR0!#?O!Q+bh9{wvdvv>-rWf&Pe*OcG*Y)_~eSpu%($Q9Fr zt2(fhCl=E0*I&c$df!uuQku2VZ%6U%v>{N&(SkZxVWD`TZRRuD+!R>`*%4a-#zDdL z836tQyysW&zuDDYM@~T%YSPwy9)Bj{7LIrvthVz4q%b%t!-6_sjtzOWu#}s;U763T z)idZj9eitAOMdo4Vy+pJaq@(O91=Qiz-)KGH4wYAZaE`TQ?>GMd0D36!SHcO}%iXPX?-+Sf~*eG4`W;=mn+*dBRV_6#x^m!x{?kK8A%L1!NK*d@u-6teD>>H z-qli4r)@f0Z|l$V+`_C{kr9+z9v~% zPaYyW_n?fCvz&|)N#pC9@$V36EM`{aTd4M{ng*{uqAXV-nL)rg!8P_dg%^x=rP1Hb zJ-%H(0~p5ES3NEDqia6lF44Ow+uAo<^k<=Js8@{{SCX z>DVPVZ4ab0-ALUg@N?-=UHFkK?zR~oH%vJ@epUw{43UG=I0qw)@Oj7Z9EN*!jof9I zj(XL}^a-_Rf(ylp+U53cMVB$F9-un~`9c072ON+q<|(LQu?|gqKi0=xINfT@&lLEg z=Hl7dwjl~gV%P$_;-!(_(mhA07xLN1}luZ@bhUG$t+IsO%n%> zL?ujY4nYKTAos6x0bUrHNiF*RhLtJC$rvl*i_Jme^Im6`Co+Pvu-wEDcVPU(40vpe z;Pn8FT{}{{iB?H7yOIIwD!!YcELWuc{N!y@j&a3rTxk*6XsHZ-VKJaU(z`q0406m# zW9~^Oy>}{jdac55`t|kwcIyf=O339kJ#PC@xN#H$eagxM=KzpEJQ2vwM{I$djw`qL zcLm(`Q6#G`o~?{JkWL8)uRXZ+>sfj=yfMoN85@CO*f`@IPe6Gbcjp}qdPjw=s|mFlC+XUM2EN)$7?sQOci{+LMq{@bY=esEyg_Sl)_e{C>s(%?sNZ;8iEYRzt&)Uw9mRB-M~c^9)1oW# z?ikumNcPFC>r<)Bu@KnKbktRhy$4f*+WDfG6=DY`n(d@mmrs^^WNXJnEU`vZI}kqT z+(60c$ph4i^No8-xVF9fOh<+Jt7pUd__~EriwrO%D-|Gu4{xVV{S9^64r;xwp8o*S zx8Y-s4%Sa^>-yZK;^|~B<)|x`=C%GGX#p<$$roq<96{s0DhOgK9}~nc;4&b~FU$>J z@Ls7kv`&)88C7Ak=E-4!$r#}0JafPYz6IBeRvzo&f7kW@004?^^5%P{64j*1%#HiH(HE#jBj2s za&*|lX>dPw07oWGjOCG(HsHJgoG#*;F!G>@_OUTO_1eS&a7Q3@9G(vu!RiHW8@X>F zX8&D$rSZD;3e-l<*QW1M^KWLC4gcBe58yp0f#xk835!ClGMs=p+{f}lEh#FNyk&vjt3pN$5UEL zcx*lz(Miv4{{V>+gq-zy4b|;!n&<7GWmKAAAbYVE41zqcLfGUlnoc`;hx^sgXc~5r z9k-hz<<0>CbJXIv{a`$~D5oKgc*bi!d-ENmTHGLbCRT}~lmfB>IZ_V?j-%4Ku)0)? zq?_)ydT-nA{6#KRt@Jvt7U-IN;b~O#z*WU^8g81_+O#aO;B&hRgWDYE>-qk6qVWn{ zv=K<)7tY5d9o!SguK=IIwzQHbp5f9pUDzzaK-_Q!c=iB(0=luj(t_vZX*Jr$kyDFK z-`Dl`8<$>G)=LZ8n}}s4C1ba0ji;6bjE(~FlgQ&4Bazi2(=PVO9L=^lFdEu+QMn`p zsn|wv(d{7r0JNuq!0KxHfVQ{6ZZqq}dG+?E9p%*NCF_ABf}x7xK2R_)2OJa5PD$q& z0|lvkYuyI47V702S^Fd)Dplgdvo9h*nI6 zTqnv$IXTbMw{z1Rs_B#4#LCzT!1V20xsEQR>&DIdi0tH2f@}Kzm-W!|tLs}WFHF0& ze>fYF<;eEtMUg|DnGGMzpL_3uHucEHYYuyN@g3~uCn6NadB7&Nd`G95Wsw!gVBe)x z(41-cMu?Ki$ByQ{qLwQT@bu==ZEybo1NGF1Q+IbqAAP7_X-x3|oFyY;HL8t~*lG-$b=Y z!#&N&j0q%<sP zfm3QeGt;%*FUq!w%%Om2%agZ`qtJ0!kj-U$0=SS|FgXMa55}ur=t!4F>e3~FLL#t- zaTy8$Cvhh%c|EdD02l+a0bStRQQe~ir%qn;H5*wL_Gw^+1mu=4f>?sQ4!9U2Jv*Ol zYkV;9v)^CMcO(e%NXKuQ+fJMq|#k?f7h4!87CQ3x$i092sJh{+fNF@!O}!dPVm^^gT@G8MmqXqpD6f)@HO=< zN6v|v841rk8tgm`;>}M@wwBK4&7L(a7?NaVaLUC`1OithgV2l-kU_4B%U8UrW5ro)y&AF(3LXOUkD0s58-WNypTh z_iZKY-%5sdcYA{yDN%r@Bw+N;Mh-A>^BjZ5EovGaweyB(!Z%%~rfVWyE6eiW59P)S z5bd3$;E*^6l12w?o=6mCby#r z>?TsG@JNpw@W`Q(MI!}BJP<}k4_bAWhBVuDo>Av0!7KgI>ML%>XfI=e*4o}_w>Dl( ze6a!??kvP)Wp?3mM&-x~ays!DSz}th9oSp_b;dN_^eIDpRZtS8U7jrRY0pi04+7^du&^76s;}KNARbGtn4Ri%c-r}^@%RgA&m&h#t^d+fq;4Bf_TmZ`q9$7 zM|dGIi6lnJbsS)N*8!sVh+oO%PctNJHoNDQ2cfS1(@wI{ts|2;k7_E&i@3HZ=--p>c61lW!vc4sbr}aC-iA!fU=HztS}VrI`_k z1e)k`t2#XRX{(&^ZiM4EsjYLO#e9>%@@`@B;5W+Nwaa*WMbe_Uw7k2rmJvED`8h{q z1Ov2{W>Q9Rm0~>!9M@H*YZ`jmiCT3I!!47*uOj%B<9p3NU$z!oq=dfR!{y-j#YR@~ z4!^U8$=_ec@6+xyQM=}SPSx~P@ehpb?(MDNu(GovSk^^J0}$C63t3_q{dZEZu!>zy zK`ht|&5(C{*Is8!85t}@o}Cc66IPKGx4<*vT_{g}0DegUc7w-i<|O@=pww;Wf><8_ zVkcGrgOSO|IT*;t9FTLy53y;UF1puccrE2?c-eL&k%755>NAm$dE{fJL9T)gKV*Oe zl152hWqG7BI)-~pdXQ|3( zF##qyUbr;s@dbQWy;tFSZ?8wMmfzAal_Q9@xY~??$=|>SA1?r4aqXUcYnS+y;iIVd zT5)XwOReDuEJ4^o+{26zF@ux*LxavVylZ2sX?KIjRajT%-O%LY+mXkwABn3T8vAyk z6TzuA`3i!P77PwRBd30PcIbJpXALM|>AX#^CY6?&cfa*_zfnc|J2tL)*MPLar08E^ zyNV~DB~n68;yv-{*1As;SZXU}=34_M(gxO3h8V#h{VTih7sAUKbrU@9HyIcZ-1F1= zS7oT_x@DrvrOU7u`D0fqOsE3o)pAY&V5cB+>_I+mBRG%NuT673-k*7OD9TUTMd*Ch zKZxYeFC$<1X3DvYpSpcLtD@I@TDoqPV}4ltvKKLtz)mrPo=;ve#s^&SzvDlKYjF23 z1k&PV3oKAe<@u@@FbAmWs(A;H4mhk0C*dZk;unU_S$xRhzy(`5IOu<^dvL9T#l|(4 z%^XV)De9hwviM)cYp!@+JJG=^GxWz5mwT?;+}hmR+Q%Fh6TazKC_!Wo22ZKL1Mn5{uLJDr z&Bv?L&#tFTslSCiEOENb)7nTPyEp`Zr`EeYBUVT>qa1G`d7sU2^0SPERl8H2KwGgEG&( zqE9$oIy06C0b{e|1d);u1<5$|uPgC~h%fY;_#{YPSP368jN?5)s-N(&g|v#2O3J}- zU6((P73{nnTJVcnr@vRbdUdwCtLpVLr+Zod0AHu|_xgP51oEs5Fk3B-mDhMqMYq%4 zA#W*&U_!S&IsSFyc77|jwvBGQ_L4SINF4n?TIf7G<6Bv^u?4JztAM02<&HTu%To_h zDpG0N%cEc9Z|u|4t3BG%?AzHyvO?3Wj8TZ&h}5Yinb@ND zO{Kk~#iuOrtVFD0<{=w0js`$17b6(}oO+(M+W42jc2Q}p+G|9K5XvW&fKPqj<6eI) zjL)Z+iBdI=Ooqnt27YBAW41>-96WJsuwtyth)5 z9D|k^`@Z75(q9n8Jd*vI)#ON-kh>kO-2nM<{`Y@RUOt`pU#3}V7RdUGw9y1*NaQ=P zrHR6?U>TVDWS;q~e;E8D@SJz|?H1@{omhq3s=3-p$G3lemD!i#DXwVLyR%6z>HWsk z=-w&5r;=Xy(^J*_C2OL=YaPCyG>Vhn7U&BO7=MvpK{tYYC8Ow?LdkD$s9ixCjp>8D zmE>{TwRuF|2htM2$EVyIo7r&}n{Y4{NL-G4e1jP8k4p4U1o&Whdf8u7-7fW26A(xM zcJ{8kUR7HiO-l_Y7^dEuH-DM8JR>Kzp|0MihkQS%-&{?$HI77vC%`*T++TcuTHw4Z z@xNNttbgGiy^>u*!Wghwa0)gK&zOVJut@F!85zjv{xo<*c)l$*D;v)yAviL)Jb7Ro z{x!|PpGu7wbT0OzZ0IE6P~Ys z>u>mDrP2N)+F5AVJA}=0ujQ;!_XOiO?Np2=9c?bIr8b&03NB@lbF>eYes05-$EPE` zZRx%e(R3(0>6p)O2;A&CkaPU&N-3i8ee8N$K>?0NS7{gwj2xan0oJ^QQjQX>OD!XN z>uv0vmzvS}KOw0`@k?9(0AJJm$KG6|Hl|yDELu#1?94dbk3Wv=_Ax6H5rrUz9>9V@9lBz9qERt!2?Q28!%MBS0Bl zWb)W?!zn7u=s_U%HOFg~aCj<9cvfL4xklZU9AlsJ^{g!;U3>@1`w?nE8{Dwoa|I_4;vsr%Mq(daQYyz51&yzsTM#NjNX~Z!@93@k8sYrQI!@w$a6O z7U849mtLnGx{meM-gwUI#kS%}ry|}L3mON*0yz7+2Q3OQm?n zm^NHzjo9IV?b5cjO=b@nXyVcB?QX9X#$}Wg9f>;ng?>JDsj;EHPGnR{vNo3<@IZpc*I$TO#I9053LRr zlvWb8)O&P&O%+zt{;qS-_;W{ncJEczZWB?|CfxE~BCa9;fE99b4i8LYgUI7B9}4&x zgI-T2s*D)1GUb)I^dI3{`oD#2my=4`v$-Z4W-W{fq*lTa0W>g^f-7_#L86g=_ZoW?^Wf|ejAZCqSI@0 z({2*eK)Egw7_7o?`%@|N9z33Li~=wR;yv-Ht=Y}2*nP6b(2KaFQ5@t10x-BBjBt2)p01Bg7v5sqdy{{V#sfV4WvZGGD zcl(NdQog!$`Rmiv+H$0=CawKyd5pG}ek#!*y||rh@1kIf6v{T1#uKs|B!{#BLYy*5op z!}n%u`JUZLc^zA8r~~Cy1K%Hjs-f*@LC~D}T(nQ6llWigJJFJDZ>OiJhoJlx)3jZB zd);nJd;L1f<8`UHg<3gO09A<2NgJ`w4naLIPhH}D14p{iY_9E~X46%IDq6WfT=YJf z?afuR66&5DwYEtbc8b|!CR~SR^)+9^vFdgfS2}uTNLE$45K5q^VZq2e5#I*7Cn?qC zgsQaD*G(_m$mE=*3&)hm@n?m6UE}`%hEL&(O)|+gBd?iusU(FryL`VWDo7y)Gu4i8 za3}D8h4l}IeiG8seMK+yTWMk45?e63u!w+N2rO_84pb5WJ@L<3W#SuzTO@0V3&vT~ zCj&eNA5($(3O>_!9?z^+GRa06$QeI_$6lnec@p;xwmE0D08k`Z6)tvnAqx4jfNmUdgIZtk_qdy z16+^9-vVhG&Z&QVb?NQ3JJN2KNw&C)XO>w~G>$gsjIRg_V6ny&k@Odiej00@EwH`t z?VhI4$2qoVzj)8d9`YE6BO8cP2vd>9agm-He}TRd(B=?He?N$|V&@GEyIvrqguuz` zjk(S;3FDylmTg9qV5rVbyJ>6brJHX{dAF_AiiBKJleV9a_2hY#zrimTczfZ#sUDMc zZ+E05Pj@Z6^6Z*$3Cv4_@}376=OA;Fi*Wc)#hx+vZWBm}E)JD>Ev@Ld9$dTQjJ!ou zs^v%{Xuu?6Jm$Rz#{MF>_+9;-p<3y)-P_FzT%RC^ZTKT&1|uCo$s?se`p&&&t?F;1 zO>J`~qcYtI?nkLWg-|x&0nl^HA1c(_J z%{`RTt+dSsR7c49+~DIQBd1UgY#*jmLh+A?f3z$$JM(>|q@|SDM6t(lB4cASV>^DF zaZ7WgTzHGanx2*7%NcydCe!UR;nwaJTpk86>;2+-;Nas28ad=B)ThrlxTkq7A64bA zeHY8OJgHhbuRmS8TetL&6!0g8^v@LhKGA$bqgwv}X6kl;q=}utOgId{^bQqR9>mty zg1lvMGU|)0PLWBcfKn+V*vN8Kv)KOt4hZ$AtSoQ-9C+@}O40O7+ush|*`!yKNI+D4 z?FT0S@wD;KlU(n`JvpCKmf9FT-yP$|WVwlon{-js2$Al zzv2G?Q%*^zrq6Hd`mxvO{wF>l)oqq5hqnx`F=U!Dn-W2fGpRyKoG%|K&In~St$*OX zPJ4&c;?`|-1LUxh;-xgftJAJ=fSlYqPMt?-um)bV~ge%R;+vb_3G4c7`c(rqx#Vu+XK1 zrFch0t=5fed+m95>h?=yL}4jf`>!LD)NXZsFT}9jPkSRTL-XrwUNd2a}U;qZb+<8v_dAwr&jwQksc&)zqh_g%6#Yja@~O4?P-{Ij^m)o$uB z-_(276|cdu;G2tGL&W;cw0D;NUfVdKONE+9#z@&9D$DZag95nDK^dv`SX!+%`KHy9 zOI>#P+kFu+YBo=6p1a|%6?l8YmeQS1PP_YaDhMQ24v|XVD`W%8b+sAVmcZ$N!(g7|r9t9a*1hVRL90l0-@X;GCVKQf)I zj2vvi!Rp*+2OIi?XTmpnmY;Uki+IwXmfB6&SojJ-$WmPn`pdFd*N<^s> zlwX@K9C*rw-V~jnWOo`z#=Riv2HG2iNttj`T&ewQ%sfqFHoI&{h-U?lG6cRewIv}d2<9eKufov8-_^Y?~FHN#q) zsTEQx$^Jfnr^w};d38NU!oDMqNOZIg9w}3CFNG?0@&Fmn&Br8UbH_^azl++dw5v1( zfW)g}xqlLB_cpf)EK<5MuwYW9mg-G@(lIc}7-C85QAYx()s7+U)dXq2N3G7U;NgO13}-u)}q{B5pV>Nb#u5#azK^O8#BtqQCdU|IUpJX5xEj3DS5LR~_xYIBjnY>x_|wBt>j=%|{J`}i zt!nraM$@h>B(-M{N>^`S$?h@;{KZ_FPd*|*QNZATI^wnI<+70{w{sMszVe(J<-ulj zt4+ezV}(afJiDPjAoxe@cN0r!005lgyvF9%@uz{`Kf7>1^%dx`__FTr%#PrrGpSwf zp@9To_3zJK-A6T-eF8$O(fQ`8ScgWok@MAj`u8`EBi>R+PoTOjmh^0^4utfnX4e?+ zSsja$l1a%Xxr=RmZP&`eqJfN69Yb3V%t6T=FN}fy#qRA< z+SSrELF9=oS;ELR>}DIjZ=7V1arcP890_doXd||nCXtnb<~$%^&A4#KBWT;WA}y-&N$DMu&$<(c1vID*ZR{_kCi9y zY5jj+Gore_hD2v&%8oNxpKMwFL;!Qgtzlf;`AOxgmMwDGgh*o8 zs!?&Y9=YcnpS{%mJ8{cuHx`%j&utnQ)BzJ1_ohzKlC9UA=PGzP0~o-^>(3LVnq7ad z_$I5}daVwR!~P(S*5zRjwUlIYUF04hTgX&~B4y`zIACkSwJ2wR3xZ9%Z1W=CtUS@X zZ_CCyhUD?`k(}{ecZ9W%JrRrLv4hQcxZDkrtsNMrC9G}zey68N;xyB=z$KC-8Bb)O|8LO#}26K+501TgC2emr&;x??Z5b?U;s|xmXSuXQ`*_Hfw$R$Ww0^AXl7fR`S?i#3YJoV|hSiiy)E7BX-ggfIwm~ zoOL7v@8N037}O-v^S9?^f9uGqa*s97N3pttRJL&h%OE9}a<|Y7GOJp$FE-B(zg)Dr=u~G%WUOH^{y$}jYguhzJrxp z$;Ri_O9d`=s3)n$c>IldIhG!sTGwgm+sm(Bm-OEK4OICe znlZd(sp;ipSo13}IEus;G}?%``BVYG z;2aNXvEdI6&3T1w(J;s{J_`?+g>DHLAxRzZMsPtD>C?kPI7vxMMXUU_^|CZ`ZWm`& zdwSOnK^a^eU~`^3{&kgS6qYwlCqURF1Dfu%{{RX~mcUMd!txo1HBVKsHqI3Sl_#NE z!OoRBQCGWqd0YDXt<`PIDPHpazpo+~r29l{2n+Iz0Z)zX^x@_zD3QQcd~!g}0X~B# z^ECS_FWrO4#~3~9oAFHZMnXpTTo%TDVh8^KtbTRoM)l_5eHhZVrnTRRQo=(ZoR`Q7 ziat^~$tOG!#~9})k&bq>e;7w`q)%wKFBhCxI~k%-S4{*#K?8+M$BXw$5?ve(D;wCpq0R>1rf<1C8JnF*f?*+Wf<=b>#aw%ATMH^RW zQdc0iB%HAs0|0RL(Jk(#ftlmCk&rQB2^|4E;C0EtBi#3`n|u2k85p6Hb0Ag{G24Ji z7+l~UPa~7YdJK8LRE!@pckQ=(?a_Rf<_@L49-r4!t^BWNxwq^8)QTE4Oy+dTr-EoY7^g`SCD~!hyi&pUSgtpf?vU zx=)%`+}>le0rG+JgV1m|7#x$-oYg5)jF&uY^tW!`)7M^(_cMf)uHR4V@)q9J!C6%^ z>PKwWot5I-TF7T)hG5N=M=Unv?KvQFMhP8>BPSU(G?;HvDCCf&GHnIcCfE@h+(>F~dKb8afBrF%0NP`=o+K zN3kQ^){Udx0E==|?@(Sa+ms&rP6mFsA4>D_vy|lJ7M7Oj*{!G;%OB;6@$y}yM_Q@kn}9JE16z+g)Jz>F{jPZ_~DAmE9td@eOs6G%qV zc~%@6oGvPOKYmZ*{=Q$=OI{;R^1ImaZ8FwtI6TSh%(J|u-r=0(R~#y@L6S)%=Qvz# z!QI?=Ldp*iuF(Xr;mE|HcW}f5fJnguBfbd82OxSC?||(R*ftl?iq6t>Xgoc4J@u`! z%Q%lRM-nfcI;di?t1~DEa~y6s89g(R=wk71v_0>3T|0dL0L}I=RNp^){{Uat{5i3w zcyc`wI760pRRBu(%O6vK27Z86UatrCnn=# z08OKz?_8FzbQRI|mQvUyKqL=b5;I&l9L7+rqSe*>m-Y9gzacg8J^ui&>to71OX2H{ zdv&pxMFN}=yOtiGO6hz#rop0U!V(ppF_k#v+`Wy zMPZYSbs5O(!SBvG8rg3d$!{ESTR?~a&f~)x=9@&0U0?TT%wv6+DnL8~!97PKk;Za5 zgI!V3ZK73qmAufr1MOT`h*0EGmG7pH@4u&CpIyx+xmJY!9MtZwEt&5yW&i@}yH(Bx zNE?X*IpCcAdYP8-JjO>|(dRiGRMklLq%E~b0Avc}yh*Cv=?=FD7@^>W>J4zm4=haF z(zW|5FTmjUr!?Y@_fYXLwTWU~sTetsN~D%jrvP#>$y1(q$j3Pqd^_SH725eM&XN+? z3UIi`f5yCO_r+7{%^)6PE=ss{BPZIs&j8&qg#d6>xe9Pd-yKNi_7Cn4Bcs6 zrMIrT+g!$;5JVE!>Gq^(z|;<1*h6$xEbCK?W3Uo z0640YaM)Q(&2;=acQxHcFL(WaUP43QM7okUMrDg2;4#H->mLX#r?raWQYB?w!0i~% zule<^i$~LENE8+%1IQw;$Em&DlUut&w-A7{#}uAmicO)4X8`nIMnEJGa5G*cb4v7T zdrE0F9sYWMPouY68pfTKq6{%F=wjIFJW{{V#Y zFOjvR-i3h++ass*Kb?9@>y|ow;@V9z3#k#MjwW%vqf()m4x|F;3GKOa*0`?%=u+ts z0U~WzASz>?JJ(gL=o8B=yDLZbeU0WbG2g!{w*wf)H|L(q$0r{@iOhK_%MVv>pL1)c z{5yX&%Hgz@re?LT!p{Wifs!c;Fgw0+wT1>5cOdcz+7jgqj+PUR1kB;a-0q_0fz$QeF<9aC{q zcXrdgwZ7L~Z`brYF1?#goZpSEZYH_kDr|-^xp?8dYd6BaA+x-jWUffbQGf>1#sRMP zUeUkdBk<+V*yK+>SlB?*CiRJm;C!QLE0K~nWaRQV$C7E^4Q@1<1?*1OwsNA%3=R(Q z)k=}U1b45e!A@A{$=}}5Szg<1uU-CW+e;j@p&Pd?68^uh^E&(M?MFw{)FQu@&$M7O z1szUKFuaq-d9H(4vXVPrw#o7`fJ3_-;{J$^w2Cfg0>8Jb>?yY?# z^s%L^gtpur#4b*8fzQpo*I%RAYFbW^vI4B+ZrQO@AD3GB#y=5imcAp2#4@B& z$|NM5@#rh)F9~?&3+*qa(OlE*Z%+x^?gPh+LuzmklR}vW2xv5Jv;O0 zYtL`K0C;{bfrcX>e8(Uh9y8Emm$$Liq|$!NXKnVKEZC! zbW)P*uAim#>A2ild++}M5B+`HohQTQd)+Px@1v8?^2~ep5P3*13C#=3wfssyv^SKng$y83n##PFVAd0UfuA;kT5-5(aRsAxKjv3Z=Y7D zU;R0j0Xq$A|T#fv#DtrdDjKhGN(x0tp-wfKOma!Nv}6 zg01duG>Di&@!ZIF$i!y=bs6`rZ{i<_FKsTL4I|8;D#myO8v5vAzkKAV z1np2s3I%*^@b|M)yQ(%Mb^%o;_KIF@rWe9Q{s zHQGpK$p;w#ag&xDVTa|5p>-zZ-LVU4mOLKtuuc>Mt8_{lOvOl?I zDgzv2KX>b1AL0K19qZZ!(4DRcEZnP%gM? z^bPWd9E^`q)P4rGJVpNi2*#Aw*0*sBEBRCRer7QEInI4S!Tc+d@b0%|spyGeV>8-M zXzCQ4uHD(%x%a{Rjci}|PR{Bnt~KkKZ*CbjB#$V>T}D9zJpTYe?_PEbDy0b$li?2qxtE)T_MhR9t zaoBtJ{{SMcY5xEZEZ+TZ^*iG5My5rIN6s)tanO(n132z0(>y<^*y@^5wu)6$jW=x# zfS_~qCY?xC!OGMguS@iPNX1i(z2^Np*ynsJZzjB&ducO8ZKKJF6)o_>R3jmQ91uq# zg?eOKWr((xDW*u|IW4yw{ZD*zTqlJ*cc$50Tt#&M0O={a_oNM{2a(4-laIjrRhzAH zZB>oMqB)IJk-@<{9DZ5+jeMnQIEuKZdV6fU@4M4q%&EpSqMg63iaMXen_m;_5M4a+ z&uJQy8!Kg5P7hzh^sZaQeg%UF)>I)pmjVWEE6B3pn{Rdj=p^1ea)MI9Q zU>6ZC;s;*c>u%Fim}=>+=@&8k-V>CEQh-qG7|RwR1?*C!pibJo6wIh7es zahzOvY?FIR{kwJfT$hJ&etk_%L+!p9(sbQw?@U-8b!jDu0NNS3E%hXm`PK%F;<)ww zVrd>ViJN4EO5K#_Bh#E3>HK{@uYIFJ(h$}Guox0aAmH=q#d*4E+IhTZg4JVb24-bk zGn@gI^*8{10Ip0;8WNnGljgVe{c3WqT5+@0cJeShNvmq!Gx*Caeq*kg<@qZt7$HKB zy~rauB=QLMuFJ-LA&SQS)>~Mix59wQ9~lFot{cU^AAKTAQ>ENp!!6Cq1{SIrf)*RF zq^M(##EklmwaMSwYf|dbCFQl%w9%n5M=NAIXM((BU|?e>x$Xe^tUANi!$v8kW!Hao ze@n77vu$$4w9i%X&x&;YZ^KsiHj+mj{0)g?>w*2@Twj6wO+K}6cWn18*k({q2cFpc z>#Fdlfo0SzTI%j4i6u`n$ftBzIZ;-gZF zY04_;_P0*O&WnxG^*xipdd2pXvs-F*ZE>ZDKv{Qg1bSny6-!+3E|=q-0v$s9Lt}jf z*%op{erAbS6=Uz3&G7Tg{sPB+GoE#FAtHVatjW(KV%Kdui%&ywEW7YMIcUQfWM6p@E*>`U`$z;h9 zX}6H7tB^@ts6Iig++JAuUMIPjpqbhj?c{v1!1$^u;>a#7W0FXn z705C~cm)3d`tw=e4*W^0d`0lJ%-X8PZmgFpDsUxFaoE*)m&ns#YssUE*heEkU3W8X z9S(gzO6QcS`$tOFcda^?%llf-Jfi8UN_=`G_75{xi>RM9l*&4YPyDB%n)nTyaVC~ z)%1HyiEZPy)FFJNk#HSz#&gp((Tb+%!Bm8FzTH!PM>Kug^YkRYmsUv5uPkQT$)qV5 zV}p_HT^5V4nDpCNBm~=)4!P%RbKNv2Q zCAYWCHq03jB~VXLax>HJjz$hR=uLTZrt!Y)mc8xxZ~Z5+qG~Z|X#W7<{{WZu)c5Tr z^y}S9-sVMDlIeDYKX=nOKVG$gu3bx_c!qSb4S8vs1VT-usN6c@7F2;`8U-SUPir1c;cVh3%<*0`?>wws{a zv&6;(y4eiQIB2JZtRw4XyqyUg18EV9GvHaz`()IPa_=n+Ln4UZ49{^Z*fa+Iwuth!cqDKrR$V3WB;Q_$T^O6Qn87Bi5 z@4O!^-j@yh@he@##z;#400|(2pTd{HULCktbp2K4lFw3&nWndH3k;vo^{nNOal>-P zdujQ*X=c}(*tDCHQd%7Zx0?Nv$EL$=4V9V=yP^QHj1iI#QIlDQ&`Wb^rd+$vceD4B zv@Bygc7j-ev>%vY5(&Y^aB-=x__p5W(XV2HZ?wb%DzIpYQ;dcO1byy@q4yO{ra`GR z`C}Zg2U2T-mE{KI4oh9z&+cz~Y58t%QS!9A6?|2s=~mh

iNt(%RkCdYt5T{{U;! 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�>w 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ⅇJmNtt)`C{2Z)9&L+<^!Q_=*1Zt`6+lpq`nT=)Y{96c?r1@I I!oOet2d0g{ng9R* literal 0 HcmV?d00001 diff --git a/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoControlsView.swift b/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoControlsView.swift index 49a1a59040..6d77a1a418 100644 --- a/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoControlsView.swift +++ b/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoControlsView.swift @@ -481,8 +481,7 @@ private struct NCVideoControlsSwiftUIView: View { } .padding(.horizontal, 18) .frame(maxWidth: .infinity, maxHeight: .infinity) - .background(.white.opacity(0.92)) - .clipShape(Capsule()) + .controlGlassBackground(shape: Capsule()) .shadow(color: .black.opacity(0.16), radius: 18, x: 0, y: 5) .contentShape(Capsule()) } @@ -507,8 +506,7 @@ private struct NCVideoControlsSwiftUIView: View { width: NCVideoControlsView.topActionsButtonSize, height: NCVideoControlsView.topActionsButtonSize ) - .background(.white.opacity(0.92)) - .clipShape(Circle()) + .controlGlassBackground(shape: Circle()) .shadow(color: .black.opacity(0.16), radius: 14, x: 0, y: 4) case .vlcTracks: @@ -618,13 +616,12 @@ private struct NCVideoControlsSwiftUIView: View { ) -> some View { Image(systemName: systemName) .font(.system(size: pointSize, weight: .regular)) - .foregroundStyle(.black) + .foregroundStyle(.white) .frame( width: NCVideoControlsView.topActionsButtonSize, height: NCVideoControlsView.topActionsButtonSize ) - .background(.white.opacity(0.92)) - .clipShape(Circle()) + .controlGlassBackground(shape: Circle()) .shadow(color: .black.opacity(0.16), radius: 14, x: 0, y: 4) } @@ -644,10 +641,9 @@ private struct NCVideoControlsSwiftUIView: View { } label: { Image(systemName: systemName) .font(.system(size: pointSize, weight: .regular)) - .foregroundStyle(.black) + .foregroundStyle(.white) .frame(width: size, height: size) - .background(.white.opacity(0.92)) - .clipShape(Circle()) + .controlGlassBackground(shape: Circle()) .shadow(color: .black.opacity(0.16), radius: 14, x: 0, y: 4) } .buttonStyle(.plain) @@ -659,7 +655,7 @@ private struct NCVideoControlsSwiftUIView: View { private func timeLabel(_ text: String) -> some View { Text(text) .font(.system(size: 15, weight: .medium, design: .rounded).monospacedDigit()) - .foregroundStyle(.black.opacity(0.72)) + .foregroundStyle(.white) .lineLimit(1) .minimumScaleFactor(0.85) } @@ -671,8 +667,8 @@ private struct NCVideoAirPlayRoutePickerView: UIViewRepresentable { func makeUIView(context: Context) -> AVRoutePickerView { let routePickerView = AVRoutePickerView() routePickerView.backgroundColor = .clear - routePickerView.tintColor = .black - routePickerView.activeTintColor = .black + routePickerView.tintColor = .white + routePickerView.activeTintColor = .white routePickerView.prioritizesVideoDevices = true return routePickerView } @@ -683,6 +679,22 @@ private struct NCVideoAirPlayRoutePickerView: UIViewRepresentable { ) { } } +private extension View { + @ViewBuilder + func controlGlassBackground( + shape: BackgroundShape + ) -> some View { + if #available(iOS 26.0, *) { + self + .glassEffect(.regular, in: shape) + } else { + self + .background(.white.opacity(0.92)) + .clipShape(shape) + } + } +} + // MARK: - Preview #Preview("Video Controls") { @@ -696,6 +708,12 @@ private struct NCVideoControlsPreviewView: UIViewRepresentable { func makeUIView(context: Context) -> UIView { let containerView = UIView() containerView.backgroundColor = .black + containerView.clipsToBounds = true + + let imageView = UIImageView(image: UIImage(named: "testimage")) + imageView.contentMode = .scaleAspectFill + imageView.clipsToBounds = true + imageView.translatesAutoresizingMaskIntoConstraints = false let controlsView = NCVideoControlsView() controlsView.translatesAutoresizingMaskIntoConstraints = false @@ -715,9 +733,15 @@ private struct NCVideoControlsPreviewView: UIViewRepresentable { NCVideoTrackMenuItem(index: 2, title: "English", isSelected: false) ]) + containerView.addSubview(imageView) containerView.addSubview(controlsView) NSLayoutConstraint.activate([ + imageView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor), + imageView.trailingAnchor.constraint(equalTo: containerView.trailingAnchor), + imageView.topAnchor.constraint(equalTo: containerView.topAnchor), + imageView.bottomAnchor.constraint(equalTo: containerView.bottomAnchor), + controlsView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor), controlsView.trailingAnchor.constraint(equalTo: containerView.trailingAnchor), controlsView.topAnchor.constraint(equalTo: containerView.topAnchor), From 8ddd66c258aa3a23b8ef7aea9ecb1747baf080d2 Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Thu, 4 Jun 2026 15:45:11 +0200 Subject: [PATCH 59/61] glass Signed-off-by: Marino Faggiana --- .../Video/NCVideoPlaybackCoverView.swift | 59 +++++++++++++++++-- 1 file changed, 55 insertions(+), 4 deletions(-) diff --git a/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoPlaybackCoverView.swift b/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoPlaybackCoverView.swift index 97aad01420..bfd77ff8f2 100644 --- a/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoPlaybackCoverView.swift +++ b/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoPlaybackCoverView.swift @@ -52,10 +52,9 @@ struct NCVideoPlaybackCoverView: View { } label: { Image(systemName: "play.fill") .font(.system(size: 36, weight: .regular)) - .foregroundStyle(isPlayEnabled ? .black : .black.opacity(0.35)) + .foregroundStyle(isPlayEnabled ? .white : .black.opacity(0.35)) .frame(width: 62, height: 62) - .background(.white.opacity(isPlayEnabled ? 0.92 : 0.45)) - .clipShape(Circle()) + .coverPlayButtonBackground(isEnabled: isPlayEnabled) .shadow( color: .black.opacity(isPlayEnabled ? 0.16 : 0.08), radius: 14, @@ -63,7 +62,6 @@ struct NCVideoPlaybackCoverView: View { y: 4 ) } - .buttonStyle(.plain) .disabled(!isPlayEnabled || isLaunchingPlayback) .opacity(isLaunchingPlayback ? 0 : 1) .scaleEffect(isLaunchingPlayback ? 1.12 : 1) @@ -72,3 +70,56 @@ struct NCVideoPlaybackCoverView: View { } } } + +private extension View { + @ViewBuilder + func coverPlayButtonBackground(isEnabled: Bool) -> some View { + if #available(iOS 26.0, *) { + self + .glassEffect(.regular, in: .circle) + } else { + self + .background(.white.opacity(isEnabled ? 0.92 : 0.45)) + .clipShape(Circle()) + } + } +} + +#Preview("Video Playback Cover") { + NCVideoPlaybackCoverView( + previewURL: NCVideoPlaybackCoverPreviewImage.url, + isPlayEnabled: true, + isLaunchingPlayback: false, + onToggleChrome: {}, + onPlay: {} + ) +} + +#Preview("Video Playback Cover - Disabled") { + NCVideoPlaybackCoverView( + previewURL: NCVideoPlaybackCoverPreviewImage.url, + isPlayEnabled: false, + isLaunchingPlayback: false, + onToggleChrome: {}, + onPlay: {} + ) +} + +private enum NCVideoPlaybackCoverPreviewImage { + static var url: URL? { + guard let image = UIImage(named: "testimage"), + let data = image.jpegData(compressionQuality: 1) else { + return nil + } + + let url = FileManager.default.temporaryDirectory + .appendingPathComponent("NCVideoPlaybackCoverPreview-testimage.jpg") + + do { + try data.write(to: url, options: .atomic) + return url + } catch { + return nil + } + } +} From a3f17eb169667faa8e7716b03bb8ec40ff74d342 Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Thu, 4 Jun 2026 15:55:22 +0200 Subject: [PATCH 60/61] color controls Signed-off-by: Marino Faggiana --- .../Content/Video/NCVideoControlsView.swift | 12 ++++++------ .../Content/Video/NCVideoPlaybackCoverView.swift | 2 +- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoControlsView.swift b/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoControlsView.swift index 6d77a1a418..a3c73f0082 100644 --- a/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoControlsView.swift +++ b/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoControlsView.swift @@ -69,7 +69,7 @@ final class NCVideoControlsView: UIView { fileprivate static let centerControlsWidth: CGFloat = 220 fileprivate static let centerControlsHeight: CGFloat = 76 - fileprivate static let bottomControlsHeight: CGFloat = 52 + fileprivate static let bottomControlsHeight: CGFloat = 45 fileprivate static let bottomControlsHorizontalInset: CGFloat = 28 fileprivate static let bottomControlsBottomInset: CGFloat = 30 fileprivate static let topActionsHeight: CGFloat = 46 @@ -616,7 +616,7 @@ private struct NCVideoControlsSwiftUIView: View { ) -> some View { Image(systemName: systemName) .font(.system(size: pointSize, weight: .regular)) - .foregroundStyle(.white) + .foregroundStyle(.black.opacity(0.82)) .frame( width: NCVideoControlsView.topActionsButtonSize, height: NCVideoControlsView.topActionsButtonSize @@ -641,7 +641,7 @@ private struct NCVideoControlsSwiftUIView: View { } label: { Image(systemName: systemName) .font(.system(size: pointSize, weight: .regular)) - .foregroundStyle(.white) + .foregroundStyle(.black.opacity(0.82)) .frame(width: size, height: size) .controlGlassBackground(shape: Circle()) .shadow(color: .black.opacity(0.16), radius: 14, x: 0, y: 4) @@ -655,7 +655,7 @@ private struct NCVideoControlsSwiftUIView: View { private func timeLabel(_ text: String) -> some View { Text(text) .font(.system(size: 15, weight: .medium, design: .rounded).monospacedDigit()) - .foregroundStyle(.white) + .foregroundStyle(.black.opacity(0.82)) .lineLimit(1) .minimumScaleFactor(0.85) } @@ -667,8 +667,8 @@ private struct NCVideoAirPlayRoutePickerView: UIViewRepresentable { func makeUIView(context: Context) -> AVRoutePickerView { let routePickerView = AVRoutePickerView() routePickerView.backgroundColor = .clear - routePickerView.tintColor = .white - routePickerView.activeTintColor = .white + routePickerView.tintColor = .black + routePickerView.activeTintColor = .black routePickerView.prioritizesVideoDevices = true return routePickerView } diff --git a/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoPlaybackCoverView.swift b/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoPlaybackCoverView.swift index bfd77ff8f2..13ecb832f4 100644 --- a/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoPlaybackCoverView.swift +++ b/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoPlaybackCoverView.swift @@ -52,7 +52,7 @@ struct NCVideoPlaybackCoverView: View { } label: { Image(systemName: "play.fill") .font(.system(size: 36, weight: .regular)) - .foregroundStyle(isPlayEnabled ? .white : .black.opacity(0.35)) + .foregroundStyle(isPlayEnabled ? .black.opacity(0.82) : .black.opacity(0.35)) .frame(width: 62, height: 62) .coverPlayButtonBackground(isEnabled: isPlayEnabled) .shadow( From 017c073a77e8d06e64c51996a7b5b80301a30fef Mon Sep 17 00:00:00 2001 From: Marino Faggiana Date: Thu, 4 Jun 2026 16:03:13 +0200 Subject: [PATCH 61/61] color Signed-off-by: Marino Faggiana --- .../NCViewerMedia/Content/Video/NCVideoControlsView.swift | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoControlsView.swift b/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoControlsView.swift index a3c73f0082..cc329bfdba 100644 --- a/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoControlsView.swift +++ b/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoControlsView.swift @@ -473,7 +473,7 @@ private struct NCVideoControlsSwiftUIView: View { } ) .disabled(!state.isSeekingEnabled) - .tint(.black.opacity(0.38)) + .tint(.gray) .opacity(state.isSeekingEnabled ? 1 : 0.45) timeLabel(state.remainingText) @@ -655,7 +655,7 @@ private struct NCVideoControlsSwiftUIView: View { private func timeLabel(_ text: String) -> some View { Text(text) .font(.system(size: 15, weight: .medium, design: .rounded).monospacedDigit()) - .foregroundStyle(.black.opacity(0.82)) + .foregroundStyle(.gray) .lineLimit(1) .minimumScaleFactor(0.85) }