diff --git a/Brand/Database.swift b/Brand/Database.swift index f083885915..8c11531cbf 100644 --- a/Brand/Database.swift +++ b/Brand/Database.swift @@ -8,4 +8,4 @@ import Foundation // let databaseName = "nextcloud.realm" let tableAccountBackup = "tableAccountBackup.json" -let databaseSchemaVersion: UInt64 = 409 +let databaseSchemaVersion: UInt64 = 410 diff --git a/Nextcloud.xcodeproj/project.pbxproj b/Nextcloud.xcodeproj/project.pbxproj index 8a3eb1edb2..78fa0f1861 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 */; }; @@ -91,7 +90,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 */; }; @@ -149,7 +147,6 @@ F37208C62BAB63F0006B5430 /* LRUCache in Frameworks */ = {isa = PBXBuildFile; productRef = F37208C52BAB63F0006B5430 /* LRUCache */; }; F37208C82BAB63F1006B5430 /* KeychainAccess in Frameworks */ = {isa = PBXBuildFile; productRef = F37208C72BAB63F1006B5430 /* KeychainAccess */; }; F3754A7D2CF87D600009312E /* SetupPasscodeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3754A7C2CF87D600009312E /* SetupPasscodeView.swift */; }; - F376A3742E5CC6030067EE25 /* ContextMenuActions.swift in Sources */ = {isa = PBXBuildFile; fileRef = F376A3732E5CC5FF0067EE25 /* ContextMenuActions.swift */; }; F389C9F52CEE383300049762 /* SelectAlbumView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F389C9F42CEE383300049762 /* SelectAlbumView.swift */; }; F38F71252B6BBDC300473CDC /* NCCollectionViewCommonSelectTabBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = F38F71242B6BBDC300473CDC /* NCCollectionViewCommonSelectTabBar.swift */; }; F39170AD2CB82024006127BC /* FileAutoRenamer+Extensions.swift in Sources */ = {isa = PBXBuildFile; fileRef = F39170A82CB8201B006127BC /* FileAutoRenamer+Extensions.swift */; }; @@ -210,9 +207,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 */; }; @@ -225,6 +219,7 @@ F70CEF5623E9C7E50007035B /* UIColor+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = F70CEF5523E9C7E50007035B /* UIColor+Extension.swift */; }; F70D7C3725FFBF82002B9E34 /* NCCollectionViewCommon.swift in Sources */ = {isa = PBXBuildFile; fileRef = F70D7C3525FFBF81002B9E34 /* NCCollectionViewCommon.swift */; }; F70D8D8124A4A9BF000A5756 /* NCNetworkingProcess.swift in Sources */ = {isa = PBXBuildFile; fileRef = F70D8D8024A4A9BF000A5756 /* NCNetworkingProcess.swift */; }; + F7103F652FD6A8F800C6C8F1 /* NCMediaViewerThumbnail.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7103F642FD6A8F800C6C8F1 /* NCMediaViewerThumbnail.swift */; }; F71070AB2F7E49F200AEE58A /* NCEndToEndSetup.swift in Sources */ = {isa = PBXBuildFile; fileRef = F71070AA2F7E49E100AEE58A /* NCEndToEndSetup.swift */; }; F710D1F52405770F00A6033D /* NCViewerPDF.swift in Sources */ = {isa = PBXBuildFile; fileRef = F710D1F42405770F00A6033D /* NCViewerPDF.swift */; }; F710D2022405826100A6033D /* NCContextMenuViewer.swift in Sources */ = {isa = PBXBuildFile; fileRef = F710D2012405826100A6033D /* NCContextMenuViewer.swift */; }; @@ -258,9 +253,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 */; }; @@ -277,6 +273,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 */; }; @@ -330,7 +327,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, ); }; }; @@ -400,6 +396,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 */; }; @@ -417,12 +414,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 */; }; @@ -491,6 +491,8 @@ 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 */; }; + F7637D802FE28CBF00F4F90E /* NCContextMenuActions.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7637D7F2FE28CBF00F4F90E /* NCContextMenuActions.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 */; }; @@ -616,6 +618,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 */; }; @@ -656,7 +661,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 /* 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 */; }; 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 */; }; @@ -677,8 +685,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 */; }; @@ -713,6 +719,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 */; }; @@ -800,6 +810,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 */; }; @@ -909,6 +925,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 /* 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 */; }; F7EDE509262DA9D600414FE6 /* NCSelectCommandViewSelect.xib in Resources */ = {isa = PBXBuildFile; fileRef = F7EDE508262DA9D600414FE6 /* NCSelectCommandViewSelect.xib */; }; @@ -940,6 +962,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 */; }; @@ -1278,12 +1301,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 = ""; }; @@ -1308,7 +1329,6 @@ F36E64F62B9245210085ABB5 /* NCCollectionViewCommon+SelectTabBarDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NCCollectionViewCommon+SelectTabBarDelegate.swift"; sourceTree = ""; }; F37208742BAB4AB0006B5430 /* TestConstants.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = TestConstants.swift; sourceTree = ""; }; F3754A7C2CF87D600009312E /* SetupPasscodeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SetupPasscodeView.swift; sourceTree = ""; }; - F376A3732E5CC5FF0067EE25 /* ContextMenuActions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextMenuActions.swift; sourceTree = ""; }; F389C9F42CEE383300049762 /* SelectAlbumView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SelectAlbumView.swift; sourceTree = ""; }; F38F71242B6BBDC300473CDC /* NCCollectionViewCommonSelectTabBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCCollectionViewCommonSelectTabBar.swift; sourceTree = ""; }; F39170A82CB8201B006127BC /* FileAutoRenamer+Extensions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "FileAutoRenamer+Extensions.swift"; sourceTree = ""; }; @@ -1348,9 +1368,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 = ""; }; @@ -1363,6 +1380,7 @@ F70D7C3525FFBF81002B9E34 /* NCCollectionViewCommon.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NCCollectionViewCommon.swift; sourceTree = ""; }; F70D8D8024A4A9BF000A5756 /* NCNetworkingProcess.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCNetworkingProcess.swift; sourceTree = ""; }; F70F96AF2874394B006C8379 /* Nextcloud-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Nextcloud-Bridging-Header.h"; sourceTree = ""; }; + F7103F642FD6A8F800C6C8F1 /* NCMediaViewerThumbnail.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCMediaViewerThumbnail.swift; sourceTree = ""; }; F71070AA2F7E49E100AEE58A /* NCEndToEndSetup.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCEndToEndSetup.swift; sourceTree = ""; }; F710D1F42405770F00A6033D /* NCViewerPDF.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NCViewerPDF.swift; sourceTree = ""; }; F710D2012405826100A6033D /* NCContextMenuViewer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NCContextMenuViewer.swift; sourceTree = ""; }; @@ -1383,9 +1401,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 = ""; }; @@ -1393,6 +1412,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 = ""; }; @@ -1425,7 +1445,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 = ""; }; @@ -1453,6 +1472,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 = ""; }; @@ -1460,6 +1480,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 = ""; }; @@ -1468,6 +1489,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 = ""; }; @@ -1495,6 +1518,8 @@ 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 = ""; }; + F7637D7F2FE28CBF00F4F90E /* NCContextMenuActions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCContextMenuActions.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 = ""; }; @@ -1589,6 +1614,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 = ""; }; @@ -1617,6 +1645,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 /* 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 = ""; }; 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 = ""; }; @@ -1628,8 +1659,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 = ""; }; @@ -1642,6 +1671,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 = ""; }; @@ -1775,6 +1808,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 = ""; }; @@ -1845,6 +1884,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 /* 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 = ""; }; F7EDE51A262DD0C400414FE6 /* NCSelectCommandViewCopyMove.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; path = NCSelectCommandViewCopyMove.xib; sourceTree = ""; }; @@ -1868,6 +1912,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 = ""; }; @@ -2063,12 +2108,11 @@ 371B5A2F23D0B04B00FAFAE9 /* Menu */ = { isa = PBXGroup; children = ( - F376A3732E5CC5FF0067EE25 /* ContextMenuActions.swift */, + F7637D7F2FE28CBF00F4F90E /* NCContextMenuActions.swift */, 8932E90EC4278026D86CCCC9 /* NCContextMenuComment.swift */, F78C6FDD296D677300C952C3 /* NCContextMenuMain.swift */, F72EC7252F45C90600A2135C /* NCContextMenuNavigation.swift */, F7FAFD3928BFA947000777FE /* NCContextMenuNotification.swift */, - B4C7A5B36D1ED178FB6B76CB /* NCContextMenuPlayerTracks.swift */, F72EC7272F45FF0600A2135C /* NCContextMenuPlus.swift */, BB7697C94BA14450A0867940 /* NCContextMenuProfile.swift */, AF93471127E2341B002537EE /* NCContextMenuShare.swift */, @@ -2378,6 +2422,16 @@ path = Shares; sourceTree = ""; }; + F716DA682FA5F137006A6703 /* Content */ = { + isa = PBXGroup; + children = ( + F74E3EE42FB07F2500252FA0 /* Image */, + F74E3EE52FB07F3000252FA0 /* Audio */, + F78448AE2FB1BE9000F2909A /* Video */, + ); + path = Content; + sourceTree = ""; + }; F720B5B72507B9A5008C94E5 /* Cell */ = { isa = PBXGroup; children = ( @@ -2501,6 +2555,15 @@ path = NCViewerDirectEditing; sourceTree = ""; }; + F749ED342FAF0EE200CE8DFA /* Core */ = { + isa = PBXGroup; + children = ( + F7CDB5BB2FA33CA300F72306 /* NCMediaViewerView.swift */, + F7CDB5B82FA33CA300F72306 /* NCMediaViewerModel.swift */, + ); + path = Core; + sourceTree = ""; + }; F74D3DB81BAC1941000BAE4B /* Networking */ = { isa = PBXGroup; children = ( @@ -2525,6 +2588,24 @@ path = Networking; sourceTree = ""; }; + F74E3EE42FB07F2500252FA0 /* Image */ = { + isa = PBXGroup; + children = ( + F7CDB5B62FA33CA300F72306 /* NCImageViewerContentView.swift */, + F716DA642FA4E878006A6703 /* NCImageZoomView.swift */, + F7EDBB4A2FA89F6500098C42 /* NCLivePhotoViewerContentView.swift */, + ); + path = Image; + sourceTree = ""; + }; + F74E3EE52FB07F3000252FA0 /* Audio */ = { + isa = PBXGroup; + children = ( + F7CDB5D22FA3448A00F72306 /* NCAudioViewerContentView.swift */, + ); + path = Audio; + sourceTree = ""; + }; F757CC8929E82D0500F31428 /* Groupfolders */ = { isa = PBXGroup; children = ( @@ -2577,7 +2658,6 @@ children = ( F75FE06B2BB01D0D00A0EFEF /* Cell */, F70D7C3525FFBF81002B9E34 /* NCCollectionViewCommon.swift */, - F76995F32F9A4AC000291FA7 /* NCCollectionViewCommon+UIEditMenuInteractionDelegate.swift */, F7CAFE172F164B9200DB35A5 /* NCCollectionViewCommon+CellDelegate.swift */, F7743A132C33F13A0034F670 /* NCCollectionViewCommon+CollectionViewDataSource.swift */, F74D50342C9855A000BBBF4C /* NCCollectionViewCommon+CollectionViewDataSourcePrefetching.swift */, @@ -2589,6 +2669,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 */, @@ -2795,6 +2877,42 @@ path = Toolbar; sourceTree = ""; }; + F78448AE2FB1BE9000F2909A /* Video */ = { + isa = PBXGroup; + children = ( + F78448A82FB1BE9000F2909A /* NCVideoViewerContentView.swift */, + F78448A32FB1BE9000F2909A /* NCVideoPlaybackController.swift */, + F7A98A4F2FC9744A009E6313 /* NCVideoPlaybackCoverView.swift */, + F7A98A4D2FC97414009E6313 /* NCVideoURLResolver.swift */, + F7547FE52FB76C1800E372C3 /* NCVideoControlsView.swift */, + F78448BF2FB1C78900F2909A /* AVPlayer */, + F78448C02FB1C79A00F2909A /* VLC */, + ); + path = Video; + sourceTree = ""; + }; + F78448BF2FB1C78900F2909A /* AVPlayer */ = { + isa = PBXGroup; + children = ( + F7A98A512FC97464009E6313 /* NCVideoViewerContentView+AVPlayer.swift */, + F7948DE62FBAE52F00253D1C /* NCVideoAVPlayerPresenter.swift */, + F7948DE82FBAEC5300253D1C /* NCVideoAVPlayerViewController.swift */, + F7FAAC212FB773CA00DCA45B /* NCVideoAVPlayerViewControls.swift */, + ); + path = AVPlayer; + sourceTree = ""; + }; + F78448C02FB1C79A00F2909A /* VLC */ = { + isa = PBXGroup; + children = ( + F7A98A532FC9746C009E6313 /* NCVideoViewerContentView+VLC.swift */, + F7635D8C2FB1F81D007F658D /* NCVideoVLCPresenter.swift */, + F78448BD2FB1C33B00F2909A /* NCVideoVLCViewController.swift */, + F7547FE22FB7429200E372C3 /* NCVideoVLCViewControls.swift */, + ); + path = VLC; + sourceTree = ""; + }; F78ACD4721903F850088454D /* Cell */ = { isa = PBXGroup; children = ( @@ -2836,25 +2954,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 */, @@ -2876,16 +2981,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 = ( @@ -3131,6 +3226,40 @@ path = More; sourceTree = ""; }; + F7CDB5C12FA33CA300F72306 /* NCViewerMedia */ = { + isa = PBXGroup; + children = ( + F7EDBB5B2FA8DBE600098C42 /* NCMediaViewerPresenter.swift */, + F7EDBB512FA8CACA00098C42 /* NCMediaViewerHostingController.swift */, + F749ED342FAF0EE200CE8DFA /* Core */, + 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 */, + F79377042FBD86AE00DE56DE /* NCMediaViewerFloatingTitleView.swift */, + F7103F642FD6A8F800C6C8F1 /* NCMediaViewerThumbnail.swift */, + F749ED302FADD62400CE8DFA /* NCMediaViewerDetailView.swift */, + ); + path = Views; + sourceTree = ""; + }; F7D4BF0A2CA2E8D800A5E746 /* Models */ = { isa = PBXGroup; children = ( @@ -3267,6 +3396,16 @@ path = Media; sourceTree = ""; }; + F7EDBB592FA8D09E00098C42 /* Helpers */ = { + isa = PBXGroup; + children = ( + F7EDBB572FA8CFFF00098C42 /* NCMediaViewerAppearance.swift */, + F7EDBB542FA8CEBE00098C42 /* NCMediaViewerTransitionSource.swift */, + F74E3EEA2FB0AD7800252FA0 /* Notification+Extension.swift */, + ); + path = Helpers; + sourceTree = ""; + }; F7EF2AEA2E43157B0081B2C9 /* Notification */ = { isa = PBXGroup; children = ( @@ -4090,7 +4229,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 */, @@ -4105,7 +4243,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 */, @@ -4356,6 +4493,7 @@ F76340F82EBDE9760056F538 /* NCManageDatabaseCore.swift in Sources */, F79ED0F12D2FCA5B00A389D9 /* NCSectionFirstHeader.swift in Sources */, F79B646126CA661600838ACA /* UIControl+Extension.swift in Sources */, + F7EDBB562FA8CEC900098C42 /* NCMediaViewerTransitionSource.swift in Sources */, F77C973A2953143A00FDDD09 /* NCCameraRoll.swift in Sources */, F740BEF02A35C2AD00E9B6D5 /* UILabel+Extension.swift in Sources */, F7C30E01291BD2610017149B /* NCNetworkingE2EERename.swift in Sources */, @@ -4576,7 +4714,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 */, @@ -4593,6 +4730,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 */, @@ -4602,6 +4740,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 */, @@ -4621,24 +4760,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 /* NCMediaViewerFloatingTitleView.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 */, @@ -4650,6 +4796,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 */, @@ -4657,6 +4804,9 @@ F743C89E2E5B25A1000173A9 /* UIScene+Extension.swift in Sources */, F72944F52A8424F800246839 /* NCEndToEndMetadataV1.swift in Sources */, 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 */, @@ -4679,6 +4829,7 @@ F77BB746289984CA0090FC19 /* UIViewController+Extension.swift in Sources */, F700510522DF6A89003A3356 /* NCShare.swift in Sources */, F72D1007210B6882009C96B7 /* NCPushNotificationEncryption.m in Sources */, + F7EDBB552FA8CEBE00098C42 /* NCMediaViewerTransitionSource.swift in Sources */, F71638942FA0F65A00A913B7 /* NCMoreModel.swift in Sources */, F76882362C0DD1E7001CF441 /* NCAcknowledgementsView.swift in Sources */, F785EE9D246196DF00B3F945 /* NCNetworkingE2EE.swift in Sources */, @@ -4718,6 +4869,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 */, @@ -4743,7 +4895,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 */, @@ -4767,15 +4918,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 */, @@ -4809,10 +4959,10 @@ AF93471227E2341B002537EE /* NCContextMenuShare.swift in Sources */, F7EFA47825ADBA500083159A /* NCViewerProviderContextMenu.swift in Sources */, F755BD9B20594AC7008C5FBB /* NCService.swift in Sources */, - F376A3742E5CC6030067EE25 /* ContextMenuActions.swift in Sources */, 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 */, @@ -4823,6 +4973,7 @@ AAFC0D042F9AA10000F0A001 /* NCFocusedAutoUploadIntroView.swift in Sources */, AAFC0D052F9AA10000F0A001 /* NCFocusedAutoUploadProgressView.swift in Sources */, AAFC0D062F9AA10000F0A001 /* NCFocusedAutoUploadScreenDimmer.swift in Sources */, + F7637D802FE28CBF00F4F90E /* NCContextMenuActions.swift in Sources */, AAFC0D092F9AA10000F0A001 /* NCFocusedAutoUploadCloudAnimation.swift in Sources */, AAFC0D0B2F9AA10000F0A001 /* NCAutoUploadCounter.swift in Sources */, F36E64F72B9245210085ABB5 /* NCCollectionViewCommon+SelectTabBarDelegate.swift in Sources */, @@ -4833,6 +4984,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 */, @@ -4844,7 +4996,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 */, @@ -4857,15 +5008,21 @@ 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 */, + F7A98A522FC97464009E6313 /* NCVideoViewerContentView+AVPlayer.swift in Sources */, + F7103F652FD6A8F800C6C8F1 /* NCMediaViewerThumbnail.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 */, @@ -4880,6 +5037,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 */, @@ -4895,6 +5053,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 */, @@ -4926,12 +5085,15 @@ 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 */, 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+CreateMetadata.swift b/iOSClient/Data/NCManageDatabase+CreateMetadata.swift index 5c2fb757e9..2e690992b2 100644 --- a/iOSClient/Data/NCManageDatabase+CreateMetadata.swift +++ b/iOSClient/Data/NCManageDatabase+CreateMetadata.swift @@ -9,7 +9,7 @@ import NextcloudKit import Photos final class NCManageDatabaseCreateMetadata { - func convertFileToMetadataAsync(_ file: NKFile, mediaSearch: Bool = false, isDirectoryE2EE: Bool? = nil) async -> tableMetadata { + func convertFileToMetadataAsync(_ file: NKFile, isDirectoryE2EE: Bool? = nil) async -> tableMetadata { let metadata = self.createMetadata(file) let e2eEncryptedDirectory: Bool if let value = isDirectoryE2EE { @@ -40,7 +40,6 @@ final class NCManageDatabaseCreateMetadata { metadata.iconName = results.iconName metadata.classFile = results.classFile metadata.typeIdentifier = results.typeIdentifier - metadata.mediaSearch = mediaSearch } return metadata.detachedCopy() @@ -76,7 +75,7 @@ final class NCManageDatabaseCreateMetadata { completion(metadata) } - func convertFilesToMetadatasAsync(_ files: [NKFile], serverUrlMetadataFolder: String? = nil, mediaSearch: Bool = false) async -> (metadataFolder: tableMetadata, metadatas: [tableMetadata]) { + func convertFilesToMetadatasAsync(_ files: [NKFile], serverUrlMetadataFolder: String? = nil) async -> (metadataFolder: tableMetadata, metadatas: [tableMetadata]) { var counter: Int = 0 var isDirectoryE2EE: Bool = false var listServerUrl: [String: Bool] = [:] @@ -93,7 +92,7 @@ final class NCManageDatabaseCreateMetadata { } #endif - let metadata = await convertFileToMetadataAsync(file, mediaSearch: mediaSearch, isDirectoryE2EE: isDirectoryE2EE) + let metadata = await convertFileToMetadataAsync(file, isDirectoryE2EE: isDirectoryE2EE) if serverUrlMetadataFolder == metadata.serverUrlFileName || metadata.fileName == NextcloudKit.shared.nkCommonInstance.rootFileName { metadataFolder = metadata diff --git a/iOSClient/Data/NCManageDatabase+Metadata.swift b/iOSClient/Data/NCManageDatabase+Metadata.swift index 2892c7cae0..0135c4dc62 100644 --- a/iOSClient/Data/NCManageDatabase+Metadata.swift +++ b/iOSClient/Data/NCManageDatabase+Metadata.swift @@ -86,7 +86,7 @@ class tableMetadata: Object { @objc public var lockOwnerDisplayName = "" @objc public var lockTime: Date? @objc public var lockTimeOut: Date? - @objc dynamic var mediaSearch: Bool = false + @objc dynamic var placeholder: Bool = false @objc dynamic var path = "" @objc dynamic var permissions = "" @objc dynamic var placePhotos: String? @@ -696,46 +696,51 @@ extension NCManageDatabase { } } - /// Asynchronously updates a list of `tableMetadata` entries in Realm for a given account and server URL. + /// Asynchronously refreshes the `tableMetadata` entries stored in Realm for the specified account and server URL. /// /// This function performs the following steps: - /// 1. Skips all entries with `status != metadataStatusNormal`. - /// 2. Deletes existing metadata entries with `status == metadataStatusNormal` that are not in the skip list. - /// 3. Copies matching `mediaSearch` from previously deleted metadata to the incoming list. - /// 4. Inserts or updates new metadata entries into Realm, except those in the skip list. + /// 1. Collects all metadata entries with `status != metadataStatusNormal` and protects them from refresh. + /// 2. Deletes existing normal metadata entries for the specified account and server URL, excluding the root entry. + /// 3. Skips incoming metadata entries whose `ocId` belongs to a protected non-normal metadata entry. + /// 4. Inserts or updates the remaining incoming metadata entries into Realm. /// /// - Parameters: /// - metadatas: An array of incoming detached `tableMetadata` objects to insert or update. - /// - serverUrl: The server URL associated with the metadata entries. - /// - account: The account identifier used to scope the metadata update. - func updateMetadatasFilesAsync(_ metadatas: [tableMetadata], serverUrl: String, account: String) async { + /// - serverUrl: The server URL used to scope the metadata refresh. + /// - account: The account identifier used to scope the metadata refresh. + func updateMetadatasFilesAsync( + _ metadatas: [tableMetadata], + serverUrl: String, + account: String + ) async { await core.performRealmWriteAsync { realm in + // Collect metadata currently involved in non-normal operations. + // These entries must not be deleted or overwritten by the refresh. let ocIdsToSkip = Set( realm.objects(tableMetadata.self) .filter("status != %d", NCGlobal.shared.metadataStatusNormal) .map(\.ocId) ) + // Delete current normal metadata for this account and server URL, + // excluding the root entry and protected non-normal entries. let resultsToDelete = realm.objects(tableMetadata.self) - .filter("account == %@ AND serverUrl == %@ AND status == %d AND fileName != %@", account, serverUrl, NCGlobal.shared.metadataStatusNormal, NextcloudKit.shared.nkCommonInstance.rootFileName) + .filter( + "account == %@ AND serverUrl == %@ AND status == %d AND fileName != %@", + account, + serverUrl, + NCGlobal.shared.metadataStatusNormal, + NextcloudKit.shared.nkCommonInstance.rootFileName + ) .filter { !ocIdsToSkip.contains($0.ocId) } - // Cache mediaSearch (and anything else needed) before deletion, keyed by ocId. - let metadatasByOcId: [String: tableMetadata] = Dictionary( - uniqueKeysWithValues: resultsToDelete.map { object in - (object.ocId, tableMetadata(value: object)) - } - ) - realm.delete(resultsToDelete) + // Insert the refreshed metadata list, skipping protected entries. for metadata in metadatas { guard !ocIdsToSkip.contains(metadata.ocId) else { continue } - if let previous = metadatasByOcId[metadata.ocId] { - metadata.mediaSearch = previous.mediaSearch - } realm.add(metadata.detachedCopy(), update: .all) } @@ -846,34 +851,109 @@ extension NCManageDatabase { } } - /// Syncs the remote and local metadata. - /// Returns true if there were changes (additions or deletions), false if everything was already up-to-date. - func mergeRemoteMetadatasAsync(remoteMetadatas: [tableMetadata], localMetadatas: [tableMetadata]) async -> Bool { - // Set of ocId - let remoteOcIds = Set(remoteMetadatas.map { $0.ocId }) - let localOcIds = Set(localMetadatas.map { $0.ocId }) + func syncPlaceholderMetadatasAsync( + files: [NKFile], + metadatas: [tableMetadata] + ) async -> (inserted: Int, updated: Int, deleted: [tableMetadata]) { + guard !files.isEmpty else { + return (0, 0, []) + } + + // Build lookup maps for fast diffing. + // Using merge strategy avoids crashes when duplicated ocIds are present. + let filesByOcId: [String: NKFile] = Dictionary( + files.map { ($0.ocId, $0) }, + uniquingKeysWith: { _, new in new } + ) + + // Store detached copies because returned metadata objects must remain usable + // outside the Realm lifecycle. + let metadatasByOcId: [String: tableMetadata] = Dictionary( + metadatas.map { ($0.ocId, $0.detachedCopy()) }, + uniquingKeysWith: { _, new in new } + ) + + let fileOcIds = Set(filesByOcId.keys) + let metadataOcIds = Set(metadatasByOcId.keys) + + // INSERT: Remote files that are not present in the local date-window metadata list. + let toInsertOcIds = fileOcIds.subtracting(metadataOcIds) + + // DELETE CANDIDATES: Local metadata entries that are no longer present + // in the current remote date-window result. + // They are returned to the caller and must be validated/deleted outside this function. + let toDeleteOcIds = metadataOcIds.subtracting(fileOcIds) + + let deletedMetadatas: [tableMetadata] = toDeleteOcIds.compactMap { ocId in + metadatasByOcId[ocId] + } + + // UPDATE: Existing placeholder metadata entries whose etag changed. + let toUpdateOcIds: [String] = Array(fileOcIds.intersection(metadataOcIds)).filter { ocId in + guard let file = filesByOcId[ocId], + let metadata = metadatasByOcId[ocId] else { + return false + } + + return file.etag != metadata.etag + } - // Calculate diffs - let toDeleteOcIds = localOcIds.subtracting(remoteOcIds) - let toAddOcIds = remoteOcIds.subtracting(localOcIds) + let hasChanges = !toInsertOcIds.isEmpty || + !toUpdateOcIds.isEmpty - guard !toDeleteOcIds.isEmpty || !toAddOcIds.isEmpty else { - return false // No changes needed + guard hasChanges else { + return ( + inserted: 0, + updated: 0, + deleted: deletedMetadatas + ) } - let toDeleteKeys = Array(toDeleteOcIds) + let createMetadata = NCManageDatabaseCreateMetadata() await core.performRealmWriteAsync { realm in - let toAdd = remoteMetadatas.filter { toAddOcIds.contains($0.ocId) } - let toDelete = toDeleteKeys.compactMap { - realm.object(ofType: tableMetadata.self, forPrimaryKey: $0) + // MODIFY: Update lightweight fields for existing placeholder metadata entries. + if !toUpdateOcIds.isEmpty { + let resultsToModify = realm.objects(tableMetadata.self) + .filter("ocId IN %@", Array(toUpdateOcIds)) + + for metadata in resultsToModify { + guard let file = filesByOcId[metadata.ocId] else { + continue + } + + metadata.etag = file.etag + metadata.date = file.date as NSDate + + if let date = file.creationDate as? NSDate { + metadata.creationDate = date + } + } } - realm.delete(toDelete) - realm.add(toAdd, update: .modified) + // INSERT: Add placeholder metadata entries for files not currently present in the local date-window metadata list. + if !toInsertOcIds.isEmpty { + let insertedMetadatas: [tableMetadata] = toInsertOcIds.compactMap { ocId in + guard let file = filesByOcId[ocId] else { + return nil + } + + let metadata = createMetadata.createMetadata(file) + metadata.placeholder = true + return metadata + } + + if !insertedMetadatas.isEmpty { + realm.add(insertedMetadatas, update: .modified) + } + } } - return true + return ( + inserted: toInsertOcIds.count, + updated: toUpdateOcIds.count, + deleted: deletedMetadatas + ) } // MARK: - Realm Read @@ -1014,6 +1094,31 @@ extension NCManageDatabase { } ?? [] } + /// Returns the ocIds that do not have a matching `tableMetadata` object in the local Realm database. + /// + /// - Parameter ocIds: The ocId strings to verify against the local Realm database. + /// - Returns: A set containing the ocIds that were not found locally. Returns an empty set when all ocIds exist locally. + func getMissingLocalMetadataOcIdsAsync(_ ocIds: [String]) async -> Set { + let requestedOcIds = Set(ocIds) + + guard !requestedOcIds.isEmpty else { + return [] + } + + let existingOcIdsArray: [String] = await core.performRealmReadAsync { realm in + let results = realm.objects(tableMetadata.self) + .where { + $0.ocId.in(Array(requestedOcIds)) + } + + return Array(results.map { $0.ocId }) + } ?? [] + + let existingOcIds = Set(existingOcIdsArray) + + return requestedOcIds.subtracting(existingOcIds) + } + func getMetadataFromOcIdAndocIdTransferAsync(_ ocId: String?) async -> tableMetadata? { guard let ocId else { return nil @@ -1067,6 +1172,24 @@ extension NCManageDatabase { } } + /// Returns `true` if at least one metadata entry for the specified account and server URL is marked as placeholder. + /// + /// - Parameters: + /// - account: The account identifier used to scope the metadata lookup. + /// - serverUrl: The server URL used to scope the metadata lookup. + /// - Returns: `true` if at least one matching placeholder metadata exists; otherwise `false`. + func getMetadataFolderPlaceholderAsync(account: String, serverUrl: String) async -> Bool { + return await core.performRealmReadAsync { realm in + !realm.objects(tableMetadata.self) + .filter( + "account == %@ AND serverUrl == %@ AND placeholder == true", + account, + serverUrl + ) + .isEmpty + } ?? false + } + func getMetadataLivePhoto(metadata: tableMetadata) -> tableMetadata? { guard metadata.isLivePhoto else { return nil @@ -1371,6 +1494,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/Data/NCMetadataTranfersSuccess.swift b/iOSClient/Data/NCMetadataTranfersSuccess.swift index 572974c9dd..215b027484 100644 --- a/iOSClient/Data/NCMetadataTranfersSuccess.swift +++ b/iOSClient/Data/NCMetadataTranfersSuccess.swift @@ -35,7 +35,6 @@ actor NCMetadataTranfersSuccess { etag: String?, ownerId: String? = nil, permissions: String? = nil) async { - let status = metadata.status metadata.ocId = ocId metadata.uploadDate = (date as? NSDate) ?? NSDate() metadata.etag = etag ?? "" diff --git a/iOSClient/Files/NCFiles.swift b/iOSClient/Files/NCFiles.swift index 68c13890b2..d1e50dc8c9 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) @@ -132,12 +126,6 @@ class NCFiles: NCCollectionViewCommon { } } - override func viewDidDisappear(_ animated: Bool) { - super.viewDidDisappear(animated) - - fileNameBlink = nil - } - // MARK: - DataSource override func reloadDataSource() async { @@ -190,7 +178,9 @@ class NCFiles: NCCollectionViewCommon { return } - let resultsReadFolder = await networkReadFolderAsync(serverUrl: self.serverUrl, forced: forced) + let hasPlaceholder = await database.getMetadataFolderPlaceholderAsync(account: self.session.account, serverUrl: self.serverUrl) + let effectiveForced = forced || hasPlaceholder + let resultsReadFolder = await networkReadFolderAsync(serverUrl: self.serverUrl, forced: effectiveForced) guard resultsReadFolder.error == .success, resultsReadFolder.reloadRequired else { return } @@ -355,31 +345,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/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 0000000000..73273ca29c Binary files /dev/null and b/iOSClient/Images.xcassets/testimage.imageset/testimage.jpg differ diff --git a/iOSClient/Main/Collection Common/Cell/NCCellMain.swift b/iOSClient/Main/Collection Common/Cell/NCCellMain.swift index 71b9ec76c6..769e007dbc 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() -> NCMediaViewerTransitionSource? } extension NCCellMainProtocol { @@ -38,6 +39,17 @@ extension NCCellMainProtocol { get { return nil } set {} } + + func viewerTransitionSource() -> NCMediaViewerTransitionSource? { + guard let imageView = previewImg, + let image = imageView.image, + let window = imageView.window else { + return nil + } + let sourceFrame = imageView.convert(imageView.bounds, to: window) + + return NCMediaViewerTransitionSource(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..eff565f4ca 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() -> NCMediaViewerTransitionSource? { + guard let imageView = image, + let image = imageView.image, + let window = imageView.window else { + return nil + } + let sourceFrame = imageView.convert(imageView.bounds, to: window) + + return NCMediaViewerTransitionSource(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..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, metadata: metadata, page: .sharing) + NCCreate().createShare(controller: self.controller, presentViewController: self.controller, metadata: metadata, page: .sharing) } } } diff --git a/iOSClient/Main/Collection Common/NCCollectionViewCommon+CollectionViewDataSource.swift b/iOSClient/Main/Collection Common/NCCollectionViewCommon+CollectionViewDataSource.swift index da6923e610..f46340e29b 100644 --- a/iOSClient/Main/Collection Common/NCCollectionViewCommon+CollectionViewDataSource.swift +++ b/iOSClient/Main/Collection Common/NCCollectionViewCommon+CollectionViewDataSource.swift @@ -48,7 +48,7 @@ extension NCCollectionViewCommon: UICollectionViewDataSource { if metadata.hasPreview, !existsImagePreview, - self.networking.downloadThumbnailQueue.operations.filter({ ($0 as? NCMediaDownloadThumbnail)?.metadata.ocId == metadata.ocId }).isEmpty { + self.networking.downloadThumbnailQueue.operations.filter({ ($0 as? NCMediaDownloadThumbnail)?.compactMetadata.ocId == metadata.ocId }).isEmpty { self.networking.downloadThumbnailQueue.addOperation(NCCollectionViewDownloadThumbnail(metadata: metadata, collectionView: collectionView, ext: ext)) } } diff --git a/iOSClient/Main/Collection Common/NCCollectionViewCommon+CollectionViewDelegate.swift b/iOSClient/Main/Collection Common/NCCollectionViewCommon+CollectionViewDelegate.swift index 4a57032ba3..41b9f2f136 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: NCMediaViewerTransitionSource?) 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: NCMediaViewerTransitionSource? 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+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/Collection Common/NCCollectionViewCommon+TransitionSourceBlink.swift b/iOSClient/Main/Collection Common/NCCollectionViewCommon+TransitionSourceBlink.swift new file mode 100644 index 0000000000..be85c23e9f --- /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) -> NCMediaViewerTransitionSource? { + 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 NCMediaViewerTransitionSource( + 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 NCMediaViewerTransitionSource( + 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..a6665c2893 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: NCMediaViewerTransitionSource?) { 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..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) + func tapRecommendations(with metadata: tableMetadata, viewerTransitionSource: NCMediaViewerTransitionSource?) } 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..6e95e84b91 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?, 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 { - controller.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 } @@ -303,14 +303,21 @@ 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 { - popover.sourceView = controller.view + // Fallback: anchor the popover to the center of the presenting 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 +325,7 @@ class NCCreate: NSObject { } } - controller.present(activityViewController, animated: true) + presentViewController.present(activityViewController, animated: true) } // MARK: - Private helper diff --git a/iOSClient/Main/NCMainNavigationController.swift b/iOSClient/Main/NCMainNavigationController.swift index ccce146116..877de565fe 100644 --- a/iOSClient/Main/NCMainNavigationController.swift +++ b/iOSClient/Main/NCMainNavigationController.swift @@ -105,7 +105,7 @@ class NCMainNavigationController: UINavigationController, UINavigationController transfersButtonItem.title = NSLocalizedString("_transfers_", comment: "") transfersButtonItem.tintColor = NCBrandColor.shared.iconImageColor transfersButtonItem.primaryAction = UIAction(handler: { _ in - let rootView = TransfersView(session: self.session, onClose: { [weak self] in + let rootView = TransfersView(session: self.session, onClose: { [weak self = self] in self?.dismiss(animated: true) }) let hosting = UIHostingController(rootView: rootView) @@ -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/NCMainTabBarController.swift b/iOSClient/Main/NCMainTabBarController.swift index cf1b8dd8db..f97a65e81d 100644 --- a/iOSClient/Main/NCMainTabBarController.swift +++ b/iOSClient/Main/NCMainTabBarController.swift @@ -75,7 +75,7 @@ class NCMainTabBarController: UITabBarController { NotificationCenter.default.addObserver(forName: UIApplication.didBecomeActiveNotification, object: nil, queue: nil) { _ in if !isAppInBackground { - self.timerTask = Task { @MainActor [weak self] in + self.timerTask = Task { @MainActor [weak self = self] in await self?.timerCheck() } } 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+CollectionViewDataSource.swift b/iOSClient/Media/NCMedia+CollectionViewDataSource.swift index 1a051bbde3..3e6de42228 100644 --- a/iOSClient/Media/NCMedia+CollectionViewDataSource.swift +++ b/iOSClient/Media/NCMedia+CollectionViewDataSource.swift @@ -20,8 +20,8 @@ extension NCMedia: UICollectionViewDataSource { return header } else { guard let footer = collectionView.dequeueReusableSupplementaryView(ofKind: kind, withReuseIdentifier: "sectionFooter", for: indexPath) as? NCSectionFooter else { return NCSectionFooter() } - let images = dataSource.metadatas.filter({ $0.isImage }).count - let video = dataSource.metadatas.count - images + let images = dataSource.compactMetadatas.filter({ $0.isImage }).count + let video = dataSource.compactMetadatas.count - images footer.setTitleLabel("\(images) " + NSLocalizedString("_images_", comment: "") + " • " + "\(video) " + NSLocalizedString("_video_", comment: "")) return footer @@ -29,26 +29,26 @@ extension NCMedia: UICollectionViewDataSource { } func collectionView(_ collectionView: UICollectionView, numberOfItemsInSection section: Int) -> Int { - let numberOfItemsInSection = dataSource.metadatas.count + let numberOfItemsInSection = dataSource.compactMetadatas.count self.numberOfColumns = getColumnCount() return numberOfItemsInSection } func collectionView(_ collectionView: UICollectionView, didEndDisplaying cell: UICollectionViewCell, forItemAt indexPath: IndexPath) { - guard let metadata = dataSource.getMetadata(indexPath: indexPath) else { return } + guard let compactMetadata = dataSource.getcompactMetadata(indexPath: indexPath) else { return } if !collectionView.indexPathsForVisibleItems.contains(indexPath) { - for case let operation as NCMediaDownloadThumbnail in networking.downloadThumbnailQueue.operations where operation.metadata.ocId == metadata.ocId { + for case let operation as NCMediaDownloadThumbnail in networking.downloadThumbnailQueue.operations where operation.compactMetadata.ocId == compactMetadata.ocId { operation.cancel() } } } func collectionView(_ collectionView: UICollectionView, willDisplay cell: UICollectionViewCell, forItemAt indexPath: IndexPath) { - guard let metadata = dataSource.getMetadata(indexPath: indexPath) else { return } - if !utilityFileSystem.fileProviderStorageImageExists(metadata.ocId, etag: metadata.etag, userId: self.session.userId, urlBase: self.session.urlBase), - NCNetworking.shared.downloadThumbnailQueue.operations.filter({ ($0 as? NCMediaDownloadThumbnail)?.metadata.ocId == metadata.ocId }).isEmpty { - NCNetworking.shared.downloadThumbnailQueue.addOperation(NCMediaDownloadThumbnail(metadata: metadata, media: self)) + guard let compactMetadata = dataSource.getcompactMetadata(indexPath: indexPath) else { return } + if !utilityFileSystem.fileProviderStorageImageExists(compactMetadata.ocId, etag: compactMetadata.etag, userId: self.session.userId, urlBase: self.session.urlBase), + NCNetworking.shared.downloadThumbnailQueue.operations.filter({ ($0 as? NCMediaDownloadThumbnail)?.compactMetadata.ocId == compactMetadata.ocId }).isEmpty { + NCNetworking.shared.downloadThumbnailQueue.addOperation(NCMediaDownloadThumbnail(compactMetadata: compactMetadata, media: self)) } } @@ -56,25 +56,25 @@ extension NCMedia: UICollectionViewDataSource { guard let cell = (collectionView.dequeueReusableCell(withReuseIdentifier: "mediaCell", for: indexPath) as? NCMediaCell) else { fatalError("Unable to dequeue MediaCell with identifier mediaCell") } - guard let metadata = dataSource.getMetadata(indexPath: indexPath) else { return cell } + guard let compactMetadata = dataSource.getcompactMetadata(indexPath: indexPath) else { return cell } let ext = global.getSizeExtension(column: self.numberOfColumns) - let imageCache = imageCache.getImageCache(ocId: metadata.ocId, etag: metadata.etag, ext: ext) + let imageCache = imageCache.getImageCache(ocId: compactMetadata.ocId, etag: compactMetadata.etag, ext: ext) cell.imageItem.image = imageCache - cell.date = metadata.date - cell.ocId = metadata.ocId + cell.date = compactMetadata.date + cell.ocId = compactMetadata.ocId cell.imageStatus.image = nil if cell.imageItem.frame.width > 60 { - if metadata.isVideo { + if compactMetadata.isVideo { cell.imageStatus.image = playImage - } else if metadata.isLivePhoto { + } else if compactMetadata.isLivePhoto { cell.imageStatus.image = livePhotoImage } } - if isEditMode, fileSelect.contains(metadata.ocId) { + if isEditMode, fileSelect.contains(compactMetadata.ocId) { cell.selected(true, color: NCBrandColor.shared.getElement(account: session.account)) } else { cell.selected(false, color: NCBrandColor.shared.getElement(account: session.account)) @@ -82,15 +82,15 @@ extension NCMedia: UICollectionViewDataSource { if cell.imageItem.image == nil { if isPinchGestureActive || ext == global.previewExt512 || ext == global.previewExt1024 { - cell.imageItem.image = utility.getImage(ocId: metadata.ocId, etag: metadata.etag, ext: ext, userId: self.session.userId, urlBase: self.session.urlBase) + cell.imageItem.image = utility.getImage(ocId: compactMetadata.ocId, etag: compactMetadata.etag, ext: ext, userId: self.session.userId, urlBase: self.session.urlBase) } else { let session = self.session DispatchQueue.global(qos: .userInteractive).async { - let image = self.utility.getImage(ocId: metadata.ocId, etag: metadata.etag, ext: ext, userId: session.userId, urlBase: session.urlBase) + let image = self.utility.getImage(ocId: compactMetadata.ocId, etag: compactMetadata.etag, ext: ext, userId: session.userId, urlBase: session.urlBase) DispatchQueue.main.async { if let currentCell = collectionView.cellForItem(at: indexPath) as? NCMediaCell, - currentCell.ocId == metadata.ocId, let image { - self.imageCache.addImageCache(ocId: metadata.ocId, etag: metadata.etag, image: image, ext: ext, cost: indexPath.row) + currentCell.ocId == compactMetadata.ocId, let image { + self.imageCache.addImageCache(ocId: compactMetadata.ocId, etag: compactMetadata.etag, image: image, ext: ext, cost: indexPath.row) currentCell.imageItem.image = image } } diff --git a/iOSClient/Media/NCMedia+CollectionViewDataSourcePrefetching.swift b/iOSClient/Media/NCMedia+CollectionViewDataSourcePrefetching.swift index bca997b063..1b5e854eab 100644 --- a/iOSClient/Media/NCMedia+CollectionViewDataSourcePrefetching.swift +++ b/iOSClient/Media/NCMedia+CollectionViewDataSourcePrefetching.swift @@ -12,12 +12,12 @@ extension NCMedia: UICollectionViewDataSourcePrefetching { return } let cost = indexPaths.first?.row ?? 0 - let metadatas = self.dataSource.getMetadatas(indexPaths: indexPaths) + let compactMetadatas = self.dataSource.getcompactMetadatas(indexPaths: indexPaths) - metadatas.forEach { metadata in - if self.imageCache.getImageCache(ocId: metadata.ocId, etag: metadata.etag, ext: ext) == nil, - let image = self.utility.getImage(ocId: metadata.ocId, etag: metadata.etag, ext: ext, userId: self.session.userId, urlBase: self.session.urlBase) { - self.imageCache.addImageCache(ocId: metadata.ocId, etag: metadata.etag, image: image, ext: ext, cost: cost) + compactMetadatas.forEach { compactMetadata in + if self.imageCache.getImageCache(ocId: compactMetadata.ocId, etag: compactMetadata.etag, ext: ext) == nil, + let image = self.utility.getImage(ocId: compactMetadata.ocId, etag: compactMetadata.etag, ext: ext, userId: self.session.userId, urlBase: self.session.urlBase) { + self.imageCache.addImageCache(ocId: compactMetadata.ocId, etag: compactMetadata.etag, image: image, ext: ext, cost: cost) } } } diff --git a/iOSClient/Media/NCMedia+CollectionViewDelegate.swift b/iOSClient/Media/NCMedia+CollectionViewDelegate.swift index fde3168ff7..4df09e84fb 100644 --- a/iOSClient/Media/NCMedia+CollectionViewDelegate.swift +++ b/iOSClient/Media/NCMedia+CollectionViewDelegate.swift @@ -9,42 +9,110 @@ import RealmSwift extension NCMedia: UICollectionViewDelegate { func collectionView(_ collectionView: UICollectionView, didSelectItemAt indexPath: IndexPath) { Task { - guard let metadata = dataSource.getMetadata(indexPath: indexPath), + guard let compactMetadata = dataSource.getcompactMetadata(indexPath: indexPath), let cell = collectionView.cellForItem(at: indexPath) as? NCMediaCell else { return } if isEditMode { - if let index = fileSelect.firstIndex(of: metadata.ocId) { + if let index = fileSelect.firstIndex(of: compactMetadata.ocId) { fileSelect.remove(at: index) cell.selected(false, color: NCBrandColor.shared.getElement(account: session.account)) } else { - fileSelect.append(metadata.ocId) + fileSelect.append(compactMetadata.ocId) cell.selected(true, color: NCBrandColor.shared.getElement(account: session.account)) } tabBarSelect.selectCount = fileSelect.count - } else if let metadata = await self.database.getMetadataFromOcIdAsync(metadata.ocId) { + } else if let metadata = await self.database.getMetadataFromOcIdAsync(compactMetadata.ocId) { let image = utility.getImage(ocId: metadata.ocId, etag: metadata.etag, ext: global.previewExt1024, userId: metadata.userId, urlBase: metadata.urlBase) - let ocIds = dataSource.metadatas.map { $0.ocId } + var viewerTransitionSource: NCMediaViewerTransitionSource? + let ocIds = dataSource.compactMetadatas.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 = 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) { + 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) -> NCMediaViewerTransitionSource? { + 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 NCMediaViewerTransitionSource( + 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 NCMediaViewerTransitionSource( + image: UIImage(), + sourceFrame: sourceFrame, + cornerRadius: 6 + ) + } + func collectionView(_ collectionView: UICollectionView, contextMenuConfigurationForItemAt indexPath: IndexPath, point: CGPoint) -> UIContextMenuConfiguration? { - guard let ocId = dataSource.getMetadata(indexPath: indexPath)?.ocId, + guard let ocId = dataSource.getcompactMetadata(indexPath: indexPath)?.ocId, let metadata = database.getMetadataFromOcId(ocId) else { return nil } 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() }) } diff --git a/iOSClient/Media/NCMedia+Command.swift b/iOSClient/Media/NCMedia+Command.swift index 38733c2260..8e57a727e6 100644 --- a/iOSClient/Media/NCMedia+Command.swift +++ b/iOSClient/Media/NCMedia+Command.swift @@ -9,7 +9,7 @@ import SwiftUI extension NCMedia { func setEditMode(_ editMode: Bool) { - if dataSource.metadatas.isEmpty { + if dataSource.compactMetadatas.isEmpty { isEditMode = false } else { isEditMode = editMode @@ -36,7 +36,7 @@ extension NCMedia { if let layoutAttributes = collectionView.collectionViewLayout.layoutAttributesForElements(in: collectionView.bounds) { let sortedAttributes = layoutAttributes.sorted { $0.frame.minY < $1.frame.minY || ($0.frame.minY == $1.frame.minY && $0.frame.minX < $1.frame.minX) } - if let firstAttribute = sortedAttributes.first, let metadata = dataSource.getMetadata(indexPath: firstAttribute.indexPath) { + if let firstAttribute = sortedAttributes.first, let metadata = dataSource.getcompactMetadata(indexPath: firstAttribute.indexPath) { titleDate?.text = utility.getTitleFromDate(metadata.date) return } @@ -49,7 +49,7 @@ extension NCMedia { let highTextTitle = titleDate.frame.height let isOver = self.collectionView.contentOffset.y + highTextTitle <= -view.safeAreaInsets.top && self.collectionView.contentOffset.y != -view.safeAreaInsets.top - if isOver || dataSource.metadatas.isEmpty { + if isOver || dataSource.compactMetadatas.isEmpty { UIView.animate(withDuration: 0.3) { [self] in gradientView.isHidden = true titleDate?.textColor = NCBrandColor.shared.textColor @@ -95,6 +95,7 @@ extension NCMedia: NCMediaSelectTabBarDelegate { await NCCreate().createActivityViewController( selectedMetadata: metadatas, controller: self.controller, + presentViewController: self, sender: nil) } } @@ -131,7 +132,7 @@ extension NCMedia: NCMediaSelectTabBarDelegate { func deleteImage(with ocId: String) async { guard let metadata = await self.database.getMetadataFromOcIdAsync(ocId) else { await MainActor.run { - self.dataSource.removeMetadata([ocId]) + self.dataSource.removecompactMetadata([ocId]) self.collectionViewReloadData() } return @@ -155,11 +156,11 @@ extension NCMedia: NCMediaSelectTabBarDelegate { await MainActor.run { if let indexPath = self.dataSource.indexPath(forOcId: ocId) { self.collectionView.performBatchUpdates { - self.dataSource.removeMetadata([ocId]) + self.dataSource.removecompactMetadata([ocId]) self.collectionView.deleteItems(at: [indexPath]) } } else { - self.dataSource.removeMetadata([ocId]) + self.dataSource.removecompactMetadata([ocId]) self.collectionViewReloadData() } } diff --git a/iOSClient/Media/NCMedia+DragDrop.swift b/iOSClient/Media/NCMedia+DragDrop.swift index dd34c6a52d..004bf7d71f 100644 --- a/iOSClient/Media/NCMedia+DragDrop.swift +++ b/iOSClient/Media/NCMedia+DragDrop.swift @@ -12,7 +12,7 @@ extension NCMedia: UICollectionViewDragDelegate { func collectionView(_ collectionView: UICollectionView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath) -> [UIDragItem] { if isEditMode { return NCDragDrop().performDrag(fileSelect: fileSelect) - } else if let ocId = dataSource.getMetadata(indexPath: indexPath)?.ocId, + } else if let ocId = dataSource.getcompactMetadata(indexPath: indexPath)?.ocId, let metadata = database.getMetadataFromOcId(ocId) { return NCDragDrop().performDrag(metadata: metadata) } diff --git a/iOSClient/Media/NCMedia+MediaLayout.swift b/iOSClient/Media/NCMedia+MediaLayout.swift index 8b247c01bf..92c71d07fc 100644 --- a/iOSClient/Media/NCMedia+MediaLayout.swift +++ b/iOSClient/Media/NCMedia+MediaLayout.swift @@ -25,14 +25,14 @@ extension NCMedia: NCMediaLayoutDelegate { func collectionView(_ collectionView: UICollectionView, layout: UICollectionViewLayout, heightForHeaderInSection section: Int) -> Float { var height: Double = 0 - if dataSource.metadatas.count == 0 { + if dataSource.compactMetadatas.count == 0 { height = utility.getHeightHeaderEmptyData(view: view, portraitOffset: 0, landscapeOffset: -20) } return Float(height) } func collectionView(_ collectionView: UICollectionView, layout: UICollectionViewLayout, heightForFooterInSection section: Int) -> Float { - if dataSource.metadatas.count == 0 { + if dataSource.compactMetadatas.count == 0 { return .zero } else { return 100.0 @@ -59,10 +59,10 @@ extension NCMedia: NCMediaLayoutDelegate { if typeLayout == global.mediaLayoutSquare { return CGSize(width: collectionView.frame.width / CGFloat(columnCount), height: collectionView.frame.width / CGFloat(columnCount)) } else { - guard let metadata = dataSource.getMetadata(indexPath: indexPath) else { return .zero } + guard let compactMetadata = dataSource.getcompactMetadata(indexPath: indexPath) else { return .zero } - if metadata.imageSize != CGSize.zero { - return metadata.imageSize + if compactMetadata.imageSize != CGSize.zero { + return compactMetadata.imageSize } else { return CGSize(width: collectionView.frame.width / CGFloat(columnCount), height: collectionView.frame.width / CGFloat(columnCount)) } diff --git a/iOSClient/Media/NCMedia+Netwoking.swift b/iOSClient/Media/NCMedia+Netwoking.swift index e00c439176..c51e93d71d 100644 --- a/iOSClient/Media/NCMedia+Netwoking.swift +++ b/iOSClient/Media/NCMedia+Netwoking.swift @@ -7,27 +7,33 @@ import NextcloudKit import Alamofire extension NCMedia { - func searchMediaAsync(path: String = "", - lessDate: Date, - greaterDate: Date, - limit: Int, - account: String, - options: NKRequestOptions = NKRequestOptions(), - taskHandler: @escaping (_ task: URLSessionTask) -> Void = { _ in } - ) async -> (account: String, files: [NKFile]?, error: NKError) { + func searchVerifyNetworkMedia(path: String, + firstDate: Date, + lastDate: Date, + account: String, + paginate: Bool, + limit: Int, + taskHandler: @escaping (_ task: URLSessionTask) -> Void = { _ in }, + update: @escaping (_ files: [NKFile]) -> Void, + finish: @escaping () -> Void) async { guard let nkSession = NextcloudKit.shared.nkCommonInstance.nksessions.session(forAccount: account) else { - return (account, nil, .urlError) + finish() + return } - let files: [NKFile] = [] + let nkComm = NextcloudKit.shared.nkCommonInstance let href = "/files/" + nkSession.userId + path - let elementDate = "d:getlastmodified" - let lessDateString = lessDate.formatted(using: "yyyy-MM-dd'T'HH:mm:ssZZZZZ") - let greaterDateString = greaterDate.formatted(using: "yyyy-MM-dd'T'HH:mm:ssZZZZZ") + let elementDate = "d:" + global.mediaPropOrder + let lessDateString = firstDate.formatted(using: "yyyy-MM-dd'T'HH:mm:ssZZZZZ") + let greaterDateString = lastDate.formatted(using: "yyyy-MM-dd'T'HH:mm:ssZZZZZ") + + var paginateToken: String? + var error = NKError() + let paginateCount = 200 + var page = 0 + var paginateOffset = 0 let httpBodyString = String(format: getRequestBodySearchMedia( - createProperties: options.createProperties, - removeProperties: options.removeProperties, href: href, elementDate: elementDate, lessDate: lessDateString, @@ -36,35 +42,78 @@ extension NCMedia { ) guard let httpBody = httpBodyString.data(using: .utf8) else { - return (account, files, .invalidData) + finish() + return } - let results = await NextcloudKit.shared.searchAsync(serverUrl: nkSession.urlBase, httpBody: httpBody, showHiddenFiles: false, includeHiddenFiles: [], account: account, options: options, taskHandler: taskHandler) - - return(results.account, results.files, results.error) + while true { + var isPaginate: Bool = false + let options = NKRequestOptions(timeout: 180, + taskDescription: self.global.taskDescriptionRetrievesProperties, + paginate: paginate, + paginateToken: paginateToken, + paginateOffset: paginateOffset, + paginateCount: paginateCount, + queue: NextcloudKit.shared.nkCommonInstance.backgroundQueue) + + let results = await NextcloudKit.shared.searchAsync(serverUrl: nkSession.urlBase, httpBody: httpBody, showHiddenFiles: false, includeHiddenFiles: [], account: account, options: options, taskHandler: taskHandler) + error = results.error + + if error == .success { + if let filesUnordered = results.files { + let files = filesUnordered.sorted { + $0.date > $1.date + } + update(files) + } + let allHeaderFields = results.responseData?.response?.allHeaderFields + if let result = nkComm.findHeader("x-nc-paginate-token", allHeaderFields: allHeaderFields) { + paginateToken = result + } + if let result = nkComm.findHeader("x-nc-paginate", allHeaderFields: allHeaderFields) { + isPaginate = Bool(result) ?? false + } + } else { + finish() + break + } + + nkLog(info: "\(lessDateString) - \(greaterDateString) - \(isPaginate)", consoleOnly: true) + + if !isPaginate || (results.files?.count ?? 0) < paginateCount { + finish() + break + } + + page += 1 + paginateOffset = page * paginateCount + } } - func getRequestBodySearchMedia(createProperties: [NKProperties]?, - removeProperties: [NKProperties] = [], - href: String, - elementDate: String, - lessDate: String, - greaterDate: String, - limit: String) -> String { - // Build the DAV property list (merged create/remove rules) - let properties = NKProperties.properties(createProperties: createProperties, removeProperties: removeProperties) - + private func getRequestBodySearchMedia(href: String, + elementDate: String, + lessDate: String, + greaterDate: String, + limit: String) -> String { let request = """ - + - \(properties) + + + + + + + + + @@ -77,26 +126,10 @@ extension NCMedia { - - - - - - - - <\(elementDate)/> - - - - - - - - - + @@ -113,21 +146,36 @@ extension NCMedia { - + - + <\(elementDate)/> \(lessDate) - - + + <\(elementDate)/> \(greaterDate) - + - + + + + + + + + <\(elementDate)/> + + + + + + + + @@ -138,7 +186,7 @@ extension NCMedia { """ - return request + return request } } diff --git a/iOSClient/Media/NCMedia.swift b/iOSClient/Media/NCMedia.swift index 7399eb86ce..8b62c51324 100644 --- a/iOSClient/Media/NCMedia.swift +++ b/iOSClient/Media/NCMedia.swift @@ -52,8 +52,8 @@ class NCMedia: UIViewController { var numberOfColumns: Int = 0 var lastNumberOfColumns: Int = 0 - let debouncerLoadDataSource = NCDebouncer(maxEventCount: 10) - let debouncerSearch = NCDebouncer(maxEventCount: 10) + let debouncerLoadDataSource = NCDebouncer(delay: .seconds(3), maxEventCount: 10) + let debouncerSearch = NCDebouncer(delay: .seconds(2), maxEventCount: 10) @MainActor var session: NCSession.Session { @@ -148,7 +148,7 @@ class NCMedia: UIViewController { NotificationCenter.default.addObserver(forName: NSNotification.Name(rawValue: global.notificationCenterClearCache), object: nil, queue: nil) { _ in Task { - await self.dataSource.clearMetadatas() + await self.dataSource.clearcompactMetadatas() self.imageCache.removeAll() await self.searchMediaUI(true) } @@ -175,10 +175,8 @@ class NCMedia: UIViewController { } } - if dataSource.metadatas.isEmpty { - Task { - await loadDataSource() - } + Task { + await loadDataSource() } } @@ -262,7 +260,7 @@ class NCMedia: UIViewController { extension NCMedia: UIScrollViewDelegate { func scrollViewDidScroll(_ scrollView: UIScrollView) { - if !dataSource.metadatas.isEmpty { + if !dataSource.compactMetadatas.isEmpty { setTitleDate() setNeedsStatusBarAppearanceUpdate() } diff --git a/iOSClient/Media/NCMediaDataSource.swift b/iOSClient/Media/NCMediaDataSource.swift index fee20fe315..f13bcc13f2 100644 --- a/iOSClient/Media/NCMediaDataSource.swift +++ b/iOSClient/Media/NCMediaDataSource.swift @@ -11,13 +11,13 @@ extension NCMedia { guard let tblAccount = await self.database.getTableAccountAsync(predicate: NSPredicate(format: "account == %@", self.session.account)) else { return } - let mediaPredicate = self.imageCache.getMediaPredicate(session: self.session, - mediaPath: tblAccount.mediaPath, - showOnlyImages: self.showOnlyImages, - showOnlyVideos: self.showOnlyVideos) - let sortedByKeyPath = "date" + let mediaPredicate = self.imageCache.getMediaPredicate( + session: self.session, + mediaPath: tblAccount.mediaPath, + showOnlyImages: self.showOnlyImages, + showOnlyVideos: self.showOnlyVideos) - if let metadatas = await self.database.getMetadatasAsync(predicate: mediaPredicate, sortedByKeyPath: sortedByKeyPath, ascending: false) { + if let metadatas = await self.database.getMetadatasAsync(predicate: mediaPredicate, sortedByKeyPath: "date", ascending: false) { self.database.filterAndNormalizeLivePhotos(from: metadatas) { metadatas in Task { @MainActor in self.dataSource = NCMediaDataSource(metadatas: metadatas) @@ -26,7 +26,7 @@ extension NCMedia { } } else { await MainActor.run { - self.dataSource.clearMetadatas() + self.dataSource.clearcompactMetadatas() self.collectionViewReloadData() } } @@ -47,8 +47,7 @@ extension NCMedia { !self.isPinchGestureActive, !self.showOnlyImages, !self.showOnlyVideos, - !self.isEditMode, - self.networking.downloadThumbnailQueue.operationCount == 0 else { + !self.isEditMode else { return false } self.searchMediaInProgress = true @@ -58,19 +57,17 @@ extension NCMedia { guard shouldContinue, let tblAccount = await self.database.getTableAccountAsync(predicate: NSPredicate(format: "account == %@", session.account)) else { - await MainActor.run { - self.activityIndicator.stopAnimating() - self.searchMediaInProgress = false - } return } - var lessDate = Date.distantFuture - var greaterDate = Date.distantPast + var firstDateNew = Date.distantFuture + var lastDateNew = Date.distantPast + var firstDate: Date? + var lastDate: Date? var visibleCells: [NCMediaCell] = [] await MainActor.run { - if self.dataSource.metadatas.isEmpty { + if self.dataSource.compactMetadatas.isEmpty { self.collectionViewReloadData() } let sortedIndexPaths = collectionView.indexPathsForVisibleItems.sorted { @@ -104,88 +101,178 @@ extension NCMedia { return date1 > date2 } + firstDate = visibleCells.first?.date + lastDate = visibleCells.last?.date + if !visibleCells.isEmpty, !distant { let firstCellDate = visibleCells.first?.date let lastCellDate = visibleCells.last?.date if collectionView.contentOffset.y <= 0 { - lessDate = .distantFuture + firstDateNew = .distantFuture } else { - lessDate = Calendar.current.date(byAdding: .second, value: 1, to: firstCellDate ?? .distantFuture) ?? .distantFuture + firstDateNew = Calendar.current.date(byAdding: .second, value: 1, to: firstCellDate ?? .distantFuture) ?? .distantFuture } - if lastCellDate == self.dataSource.metadatas.last?.date { - greaterDate = .distantPast + if lastCellDate == self.dataSource.compactMetadatas.last?.date { + lastDateNew = .distantPast } else { - greaterDate = Calendar.current.date(byAdding: .second, value: -1, to: lastCellDate ?? .distantPast) ?? .distantPast + lastDateNew = Calendar.current.date(byAdding: .second, value: -1, to: lastCellDate ?? .distantPast) ?? .distantPast } } } - let limit = await MainActor.run { - max(self.collectionView.visibleCells.count * 3, 300) - } - - let options = NKRequestOptions(timeout: 180, taskDescription: self.global.taskDescriptionRetrievesProperties, queue: NextcloudKit.shared.nkCommonInstance.backgroundQueue) - - let result = await searchMediaAsync(path: tblAccount.mediaPath, - lessDate: lessDate, - greaterDate: greaterDate, - limit: limit, - account: self.session.account, - options: options) { task in - Task { - let identifier = await NCNetworking.shared.networkingTasks.createIdentifier(account: self.session.account, - name: "searchMedia") - await NCNetworking.shared.networkingTasks.track(identifier: identifier, task: task) + // SEARCH NEW MEDIA + // + if firstDateNew == .distantFuture || lastDateNew == .distantPast { + await self.searchNetworkNewMedia(firstDate: firstDateNew, + lastDate: lastDateNew, + mediaPath: tblAccount.mediaPath) { + Task { + await self.loadDataSource() + } } } - guard result.error == .success, let files = result.files, !self.showOnlyImages, !self.showOnlyVideos else { - nkLog(error: "Media search failed: \(result.error.errorDescription)") - await MainActor.run { - self.searchMediaInProgress = false - self.collectionViewReloadData() + guard let firstDate, let lastDate else { + Task { @MainActor in self.activityIndicator.stopAnimating() + self.searchMediaInProgress = false } return } - if lessDate == .distantFuture, greaterDate == .distantPast, files.isEmpty { - await MainActor.run { - self.dataSource.clearMetadatas() - self.collectionViewReloadData() + await self.verifyNetworkMedia(firstDate: firstDate, + lastDate: lastDate, + mediaPath: tblAccount.mediaPath) { + Task { + await self.debouncerLoadDataSource.call { + await self.loadDataSource() + } } - } - - Task.detached(priority: .userInitiated) { [weak self] in - guard let self else { - return + } finish: { + Task { @MainActor in + self.activityIndicator.stopAnimating() + self.searchMediaInProgress = false } - let (_, remoteMetadatas) = await NCManageDatabaseCreateMetadata().convertFilesToMetadatasAsync(files, mediaSearch: true) - let mediaPredicate = await self.imageCache.getMediaPredicate(session: session, - mediaPath: tblAccount.mediaPath, - showOnlyImages: self.showOnlyImages, - showOnlyVideos: self.showOnlyVideos) + } + } - let predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [ - NSPredicate(format: "date >= %@ AND date <= %@ AND mediaSearch == true", greaterDate as NSDate, lessDate as NSDate), - mediaPredicate - ]) + internal func searchNetworkNewMedia(firstDate: Date, + lastDate: Date, + mediaPath: String, + update: @escaping () -> Void) async { + let limit = await MainActor.run { + max(self.collectionView.visibleCells.count * 3, 300) + } - let localMetadatas = await self.database.getMetadatasAsync(predicate: predicate) + await self.searchVerifyNetworkMedia(path: mediaPath, + firstDate: firstDate, + lastDate: lastDate, + account: self.session.account, + paginate: false, + limit: limit) { task in + Task { + let identifier = await NCNetworking.shared.networkingTasks.createIdentifier( + account: self.session.account, + name: "searchMedia") + await NCNetworking.shared.networkingTasks.track( + identifier: identifier, + task: task) + } + } update: { files in + if firstDate == .distantFuture, + lastDate == .distantPast, + files.isEmpty { + Task { @MainActor in + self.dataSource.clearcompactMetadatas() + self.collectionViewReloadData() + } + } else { + Task.detached { + await self.updateMediaMetadatas(files: files, + firstDate: firstDate as NSDate, + lastDate: lastDate as NSDate, + mediaPath: mediaPath) { + update() + } + } + } + } finish: { } + } - await MainActor.run { - self.activityIndicator.stopAnimating() - self.searchMediaInProgress = false + internal func verifyNetworkMedia(firstDate: Date, + lastDate: Date, + mediaPath: String, + update: @escaping () -> Void, + finish: @escaping () -> Void) async { + await self.searchVerifyNetworkMedia( + path: mediaPath, + firstDate: firstDate, + lastDate: lastDate, + account: self.session.account, + paginate: true, + limit: 100000) { task in + Task.detached { + let identifier = await NCNetworking.shared.networkingTasks.createIdentifier( + account: self.session.account, + name: "verifyNetworkMedia" + ) + await NCNetworking.shared.networkingTasks.track( + identifier: identifier, + task: task) + } + } update: { files in + Task.detached { + if let firstDate = files.first?.date as? NSDate, + let lastDate = files.last?.date as? NSDate { + await self.updateMediaMetadatas(files: files, + firstDate: firstDate, + lastDate: lastDate, + mediaPath: mediaPath) { + update() + } + } + } + } finish: { + finish() } + } - if await database.mergeRemoteMetadatasAsync(remoteMetadatas: remoteMetadatas, localMetadatas: localMetadatas) { - await loadDataSource() - } else if await self.dataSource.isEmpty() { - await self.collectionViewReloadData() + private func updateMediaMetadatas(files: [NKFile], + firstDate: NSDate, + lastDate: NSDate, + mediaPath: String, + update: @escaping () -> Void) async { + // DB + let mediaPredicate = self.imageCache.getMediaPredicate( + session: self.session, + mediaPath: mediaPath, + showOnlyImages: self.showOnlyImages, + showOnlyVideos: self.showOnlyVideos) + let predicate = NSCompoundPredicate(andPredicateWithSubpredicates: [ + NSPredicate(format: "date >= %@ AND date <= %@", lastDate, firstDate), mediaPredicate + ]) + let metadatas = await self.database.getMetadatasAsync( + predicate: predicate, + sortedByKeyPath: "date", + ascending: false) ?? [] + let results = await self.database.syncPlaceholderMetadatasAsync(files: files, + metadatas: metadatas) + + // DELETE + var ocIdsToDelete: [String] = [] + for metadata in results.deleted { + let existsResult = await self.networking.fileExists(serverUrlFileName: metadata.serverUrlFileName, + account: metadata.account) + if existsResult.errorCode == 404 { + ocIdsToDelete.append(metadata.ocId) } } + await self.database.deleteMetadatasAsync(ocIds: ocIdsToDelete) + if ocIdsToDelete.count > 0 || results.inserted > 0 || results.updated > 0 { + update() + } } } @@ -193,7 +280,7 @@ extension NCMedia { @MainActor public class NCMediaDataSource: NSObject { - public class Metadata: NSObject { + public class NCCompactMetadata: NSObject { let date: Date let etag: String let imageSize: CGSize @@ -221,77 +308,68 @@ public class NCMediaDataSource: NSObject { private let utilityFileSystem = NCUtilityFileSystem() private let global = NCGlobal.shared - private(set) var metadatas: [Metadata] = [] + private(set) var compactMetadatas: [NCCompactMetadata] = [] override init() { super.init() } init(metadatas: [tableMetadata]) { super.init() - self.metadatas = metadatas.map { getMetadataFromTableMetadata($0) } - } - - private func insertInMetadatas(metadata: Metadata) { - for i in 0.. self.metadatas[i].date { - self.metadatas.insert(metadata, at: i) - return - } + self.compactMetadatas = metadatas.map { + getcompactMetadataFromMetadata($0) } - - self.metadatas.append(metadata) } - private func getMetadataFromTableMetadata(_ metadata: tableMetadata) -> Metadata { + private func getcompactMetadataFromMetadata(_ metadata: tableMetadata) -> NCCompactMetadata { let date = metadata.date as Date - return Metadata(date: date, - etag: metadata.etag, - imageSize: CGSize(width: metadata.width, height: metadata.height), - isImage: metadata.classFile == NKTypeClassFile.image.rawValue, - isLivePhoto: !metadata.livePhotoFile.isEmpty, - isVideo: metadata.classFile == NKTypeClassFile.video.rawValue, - ocId: metadata.ocId) + return NCCompactMetadata(date: date, + etag: metadata.etag, + imageSize: CGSize(width: metadata.width, height: metadata.height), + isImage: metadata.classFile == NKTypeClassFile.image.rawValue, + isLivePhoto: !metadata.livePhotoFile.isEmpty, + isVideo: metadata.classFile == NKTypeClassFile.video.rawValue, + ocId: metadata.ocId) } // MARK: - - func clearMetadatas() { - metadatas.removeAll() + func clearcompactMetadatas() { + self.compactMetadatas.removeAll() } func isEmpty() -> Bool { - return self.metadatas.isEmpty + return self.compactMetadatas.isEmpty } func indexPath(forOcId ocId: String) -> IndexPath? { - guard let index = self.metadatas.firstIndex(where: { $0.ocId == ocId }) else { + guard let index = self.compactMetadatas.firstIndex(where: { $0.ocId == ocId }) else { return nil } return IndexPath(item: index, section: 0) } - func getMetadata(indexPath: IndexPath) -> Metadata? { - if indexPath.row < self.metadatas.count { - return self.metadatas[indexPath.row] + func getcompactMetadata(indexPath: IndexPath) -> NCCompactMetadata? { + if indexPath.row < self.compactMetadatas.count { + return self.compactMetadatas[indexPath.row] } return nil } - func getMetadatas(indexPaths: [IndexPath]) -> [Metadata] { - var metadatas: [Metadata] = [] + func getcompactMetadatas(indexPaths: [IndexPath]) -> [NCCompactMetadata] { + var metadatas: [NCCompactMetadata] = [] for indexPath in indexPaths { - if indexPath.row < self.metadatas.count { - metadatas.append(self.metadatas[indexPath.row]) + if indexPath.row < self.compactMetadatas.count { + metadatas.append(self.compactMetadatas[indexPath.row]) } } return metadatas } - func removeMetadata(_ ocId: [String]) { - self.metadatas.removeAll { item in + func removecompactMetadata(_ ocId: [String]) { + self.compactMetadatas.removeAll { item in ocId.contains(item.ocId) } } diff --git a/iOSClient/Media/NCMediaDownloadThumbnail.swift b/iOSClient/Media/NCMediaDownloadThumbnail.swift index 1d62075fe3..4127fe7c59 100644 --- a/iOSClient/Media/NCMediaDownloadThumbnail.swift +++ b/iOSClient/Media/NCMediaDownloadThumbnail.swift @@ -7,15 +7,15 @@ import NextcloudKit import Queuer class NCMediaDownloadThumbnail: ConcurrentOperation, @unchecked Sendable { - var metadata: NCMediaDataSource.Metadata + var compactMetadata: NCMediaDataSource.NCCompactMetadata let utilityFileSystem = NCUtilityFileSystem() let global = NCGlobal.shared let media: NCMedia var session: NCSession.Session @MainActor - init(metadata: NCMediaDataSource.Metadata, media: NCMedia) { - self.metadata = metadata + init(compactMetadata: NCMediaDataSource.NCCompactMetadata, media: NCMedia) { + self.compactMetadata = compactMetadata self.media = media self.session = media.session } @@ -23,16 +23,21 @@ class NCMediaDownloadThumbnail: ConcurrentOperation, @unchecked Sendable { override func start() { Task { guard !isCancelled, - let tblMetadata = await NCManageDatabase.shared.getMetadataFromOcIdAsync(self.metadata.ocId) else { + let tblMetadata = await NCManageDatabase.shared.getMetadataFromOcIdAsync(self.compactMetadata.ocId) else { return self.finish() } var image: UIImage? - let resultsDownloadPreview = await NextcloudKit.shared.downloadPreviewAsync(fileId: tblMetadata.fileId, etag: tblMetadata.etag, account: tblMetadata.account, options: NKRequestOptions(queue: NextcloudKit.shared.nkCommonInstance.backgroundQueue)) { task in + let resultsDownloadPreview = await NextcloudKit.shared.downloadPreviewAsync( + fileId: tblMetadata.fileId, + etag: tblMetadata.etag, + account: tblMetadata.account, + options: NKRequestOptions(queue: NextcloudKit.shared.nkCommonInstance.backgroundQueue)) { task in Task { - let identifier = await NCNetworking.shared.networkingTasks.createIdentifier(account: tblMetadata.account, - path: tblMetadata.fileId, - name: "DownloadPreview") + let identifier = await NCNetworking.shared.networkingTasks.createIdentifier( + account: tblMetadata.account, + path: tblMetadata.fileId, + name: "DownloadPreview") await NCNetworking.shared.networkingTasks.track(identifier: identifier, task: task) } } @@ -40,7 +45,12 @@ class NCMediaDownloadThumbnail: ConcurrentOperation, @unchecked Sendable { if resultsDownloadPreview.error == .success, let data = resultsDownloadPreview.responseData?.data { NCUtility().createImageFileFrom(data: data, metadata: tblMetadata) - image = await NCUtility().getImage(ocId: tblMetadata.ocId, etag: tblMetadata.etag, ext: NCGlobal.shared.getSizeExtension(column: self.media.numberOfColumns), userId: tblMetadata.userId, urlBase: tblMetadata.urlBase) + image = await NCUtility().getImage( + ocId: tblMetadata.ocId, + etag: tblMetadata.etag, + ext: NCGlobal.shared.getSizeExtension(column: self.media.numberOfColumns), + userId: tblMetadata.userId, + urlBase: tblMetadata.urlBase) } Task { @MainActor in @@ -48,7 +58,10 @@ class NCMediaDownloadThumbnail: ConcurrentOperation, @unchecked Sendable { if cell.ocId == tblMetadata.ocId { if image == nil { cell.imageItem.contentMode = .scaleAspectFit - image = NCUtility().loadImage(named: tblMetadata.iconName, useTypeIconFile: true, account: tblMetadata.account) + image = NCUtility().loadImage( + named: tblMetadata.iconName, + useTypeIconFile: true, + account: tblMetadata.account) } else { cell.imageItem.contentMode = .scaleAspectFill } diff --git a/iOSClient/Media/NCMediaNavigationController.swift b/iOSClient/Media/NCMediaNavigationController.swift index c517a91508..fc1f534456 100644 --- a/iOSClient/Media/NCMediaNavigationController.swift +++ b/iOSClient/Media/NCMediaNavigationController.swift @@ -77,7 +77,7 @@ class NCMediaNavigationController: NCMainNavigationController { media.setEditMode(true) } - let viewFilterMenu = UIMenu(title: "", options: .displayInline, children: [ + let viewFilterMenu = UIMenu(title: "", options: [.singleSelection, .displayInline], children: [ UIAction(title: NSLocalizedString("_media_viewimage_show_", comment: ""), image: utility.loadImage(named: "photo")) { _ in media.showOnlyImages = true media.showOnlyVideos = false @@ -94,7 +94,7 @@ class NCMediaNavigationController: NCMainNavigationController { await media.networkRemoveAll() } }, - UIAction(title: NSLocalizedString("_media_show_all_", comment: ""), image: utility.loadImage(named: "photo.on.rectangle")) { _ in + UIAction(title: NSLocalizedString("_media_show_all_", comment: ""), image: utility.loadImage(named: "photo.on.rectangle"), state: .on) { _ in media.showOnlyImages = false media.showOnlyVideos = false Task { @@ -121,16 +121,27 @@ class NCMediaNavigationController: NCMainNavigationController { ]) let viewFolderMedia = UIMenu(title: "", options: .displayInline, children: [ - UIAction(title: NSLocalizedString("_select_media_folder_", comment: ""), image: utility.loadImage(named: "folder"), handler: { _ in - guard let navigationController = UIStoryboard(name: "NCSelect", bundle: nil).instantiateInitialViewController() as? UINavigationController, - let viewController = navigationController.topViewController as? NCSelect else { return } - viewController.delegate = media - viewController.typeOfCommandView = .select - viewController.type = "mediaFolder" - viewController.session = self.session - viewController.controller = self.controller - self.present(navigationController, animated: true) - }) + UIDeferredMenuElement.uncached { [weak self] completion in + guard let self else { + completion([]) + return + } + + let mediaPath = self.database.getTableAccount(account: self.session.account)?.mediaPath.replacingOccurrences(of: "/", with: "") ?? "" + + let selectMediaFolderAction = UIAction(title: NSLocalizedString("_select_media_folder_", comment: ""), subtitle: mediaPath, image: self.utility.loadImage(named: "folder"), handler: { _ in + guard let navigationController = UIStoryboard(name: "NCSelect", bundle: nil).instantiateInitialViewController() as? UINavigationController, + let viewController = navigationController.topViewController as? NCSelect else { return } + viewController.delegate = media + viewController.typeOfCommandView = .select + viewController.type = "mediaFolder" + viewController.session = self.session + viewController.controller = self.controller + self.present(navigationController, animated: true) + }) + + completion([selectMediaFolderAction]) + } ]) let playFile = UIAction(title: NSLocalizedString("_play_from_files_", comment: ""), image: utility.loadImage(named: "play.circle")) { _ in @@ -159,7 +170,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/ContextMenuActions.swift b/iOSClient/Menu/ContextMenuActions.swift deleted file mode 100644 index b730f71e21..0000000000 --- a/iOSClient/Menu/ContextMenuActions.swift +++ /dev/null @@ -1,155 +0,0 @@ -// SPDX-FileCopyrightText: Nextcloud GmbH -// SPDX-FileCopyrightText: 2025 Milen Pivchev -// SPDX-License-Identifier: GPL-3.0-or-later - -import NextcloudKit - -/// A collection of default UI actions used throughout the app -enum ContextMenuActions { - static func delete(metadatas: [tableMetadata], - controller: NCMainTabBarController?, - completion: (() -> Void)? = nil) -> UIAction { - return UIAction( - title: NSLocalizedString("_delete_", comment: ""), - image: UIImage(systemName: "trash"), - attributes: [.destructive] - ) { _ in - let alert = UIAlertController.alertDeleteFileOrFolder( - titleString: NSLocalizedString("_delete_", comment: "") + "?", - message: NSLocalizedString("_want_delete_", comment: ""), - canDeleteServer: true, - metadatas: metadatas - ) { _ in - completion?() - } - controller?.present(alert, animated: true) - } - } - - static func share(metadatas: [tableMetadata], - controller: NCMainTabBarController?, - sender: Any?, - completion: (() -> Void)? = nil) -> UIAction { - UIAction( - title: NSLocalizedString("_share_", comment: ""), - image: UIImage(systemName: "square.and.arrow.up") - ) { _ in - Task { - await NCCreate().createActivityViewController( - selectedMetadata: metadatas, - controller: controller, - sender: sender - ) - completion?() - } - } - } - - static func setAvailableOffline(metadatas: [tableMetadata], - isAnyOffline: Bool, - controller: NCMainTabBarController?, - completion: (() -> Void)? = nil) -> UIAction { - UIAction( - title: isAnyOffline - ? NSLocalizedString("_remove_available_offline_", comment: "") - : NSLocalizedString("_set_available_offline_", comment: ""), - image: UIImage(systemName: "icloud.and.arrow.down") - ) { _ in - if !isAnyOffline, metadatas.count > 3 { - let alert = UIAlertController( - title: NSLocalizedString("_set_available_offline_", comment: ""), - message: NSLocalizedString("_select_offline_warning_", comment: ""), - preferredStyle: .alert - ) - alert.addAction(UIAlertAction(title: NSLocalizedString("_continue_", comment: ""), style: .default) { _ in - Task { - for metadata in metadatas { - await NCNetworking.shared.setMetadataAvalableOffline(metadata, isOffline: isAnyOffline) - } - completion?() - } - }) - alert.addAction(UIAlertAction(title: NSLocalizedString("_cancel_", comment: ""), style: .cancel)) - controller?.present(alert, animated: true) - } else { - Task { - for metadata in metadatas { - await NCNetworking.shared.setMetadataAvalableOffline(metadata, isOffline: isAnyOffline) - } - completion?() - } - } - } - } - - static func moveOrCopy(metadatas: [tableMetadata], - account: String, - controller: NCMainTabBarController?, - completion: (() -> Void)? = nil) -> UIAction { - UIAction( - title: NSLocalizedString("_move_or_copy_", comment: ""), - image: UIImage(systemName: "rectangle.portrait.and.arrow.right") - ) { _ in - Task { @MainActor in - guard let controller else { - completion?() - return - } - var fileNameError: NKError? - let capabilities = await NKCapabilities.shared.getCapabilities(for: account) - - for metadata in metadatas { - if let sceneIdentifier = metadata.sceneIdentifier, - let controller = SceneManager.shared.getController(sceneIdentifier: sceneIdentifier), - let checkError = FileNameValidator.checkFileName(metadata.fileNameView, - account: controller.account, - capabilities: capabilities) { - fileNameError = checkError - break - } - } - - if let fileNameError { - let message = "\(fileNameError.errorDescription) \(NSLocalizedString("_please_rename_file_", comment: ""))" - await UIAlertController.warningAsync(message: message, presenter: controller) - } else { - NCSelectOpen.shared.openView(items: metadatas, controller: controller) - } - completion?() - } - } - } - - static func lockUnlock(isLocked: Bool, - metadata: tableMetadata, - controller: NCMainTabBarController?, - completion: (() -> Void)? = nil) -> UIAction { - let titleKey: String - var subtitleKey: String = "" - let image: UIImage? - if !metadata.canUnlock(as: metadata.userId), isLocked { - titleKey = String(format: NSLocalizedString("_locked_by_", comment: ""), metadata.lockOwnerDisplayName) - image = UIImage(systemName: "lock") - } else { - titleKey = isLocked ? "_unlock_file_" : "_lock_file_" - image = UIImage(systemName: isLocked ? "lock.open" : "lock") - subtitleKey = !metadata.lockOwnerDisplayName.isEmpty ? String(format: NSLocalizedString("_locked_by_", comment: ""), metadata.lockOwnerDisplayName) : "" - } - - return UIAction( - title: NSLocalizedString(titleKey, comment: ""), - subtitle: subtitleKey, - image: image, - attributes: metadata.canUnlock(as: metadata.userId) ? [] : [.disabled] - ) { _ in - Task { - let error = await NCNetworking.shared.lockUnlockFile(metadata, shouldLock: !isLocked) - if error != .success { - let windowScene = await SceneManager.shared.getWindowScene(controller: controller) - await showErrorBanner(windowScene: windowScene, error: error) - } - completion?() - } - } - } -} diff --git a/iOSClient/Menu/NCContextMenuActions.swift b/iOSClient/Menu/NCContextMenuActions.swift new file mode 100644 index 0000000000..38895f8609 --- /dev/null +++ b/iOSClient/Menu/NCContextMenuActions.swift @@ -0,0 +1,325 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2025 Milen Pivchev +// SPDX-License-Identifier: GPL-3.0-or-later + +import UIKit +import NextcloudKit + +/// A collection of default UI actions used throughout the app. +enum NCContextMenuActions { + static func inlineMenu( + children: [UIMenuElement], + preferredElementSize: UIMenu.ElementSize? = nil + ) -> UIMenu? { + guard !children.isEmpty else { return nil } + + let menu = UIMenu(title: "", options: .displayInline, children: children) + if let preferredElementSize { + menu.preferredElementSize = preferredElementSize + } + return menu + } + + static func detail( + metadata: tableMetadata, + controller: NCMainTabBarController?, + presentViewController: UIViewController? + ) -> UIAction { + UIAction( + title: NSLocalizedString("_details_", comment: ""), + image: NCUtility().loadImage(named: "info.circle.fill") + ) { _ in + NCCreate().createShare( + controller: controller, + presentViewController: presentViewController, + metadata: metadata, + page: .activity + ) + } + } + + static func favorite( + metadata: tableMetadata, + completion: (() -> Void)? = nil + ) -> UIAction { + UIAction( + title: metadata.favorite + ? NSLocalizedString("_remove_favorites_", comment: "") + : NSLocalizedString("_add_favorites_", comment: ""), + image: NCUtility().loadImage( + named: metadata.favorite ? "star.slash.fill" : "star.fill", + colors: [NCBrandColor.shared.yellowFavorite] + ) + ) { _ in + Task { + await NCNetworking.shared.setStatusWaitFavorite(metadata) + completion?() + } + } + } + + static func share( + metadatas: [tableMetadata], + controller: NCMainTabBarController?, + presentViewController: UIViewController?, + sender: Any?, + completion: (() -> Void)? = nil + ) -> UIAction { + UIAction( + title: NSLocalizedString("_share_", comment: ""), + image: NCUtility().loadImage(named: "square.and.arrow.up.fill") + ) { _ in + Task { @MainActor in + await NCCreate().createActivityViewController( + selectedMetadata: metadatas, + controller: controller, + presentViewController: presentViewController, + sender: sender + ) + completion?() + } + } + } + + static func saveLivePhoto( + metadata: tableMetadata, + metadataMOV: tableMetadata, + windowScene: UIWindowScene? + ) -> UIAction { + UIAction( + title: NSLocalizedString("_livephoto_save_", comment: ""), + image: NCUtility().loadImage(named: "livephoto", colors: [NCBrandColor.shared.iconImageColor]) + ) { _ in + NCNetworking.shared.saveLivePhotoQueue.addOperation( + NCOperationSaveLivePhoto( + metadata: metadata, + metadataMOV: metadataMOV, + windowScene: windowScene + ) + ) + } + } + + static func delete( + metadatas: [tableMetadata], + controller: NCMainTabBarController?, + completion: (() -> Void)? = nil + ) -> UIAction { + UIAction( + title: NSLocalizedString("_delete_", comment: ""), + image: NCUtility().loadImage(named: "trash"), + attributes: [.destructive] + ) { _ in + let alert = UIAlertController.alertDeleteFileOrFolder( + titleString: NSLocalizedString("_delete_", comment: "") + "?", + message: NSLocalizedString("_want_delete_", comment: ""), + canDeleteServer: true, + metadatas: metadatas + ) { _ in + completion?() + } + controller?.present(alert, animated: true) + } + } + + static func setAvailableOffline( + metadatas: [tableMetadata], + isAnyOffline: Bool, + controller: NCMainTabBarController?, + completion: (() -> Void)? = nil + ) -> UIAction { + UIAction( + title: isAnyOffline + ? NSLocalizedString("_remove_available_offline_", comment: "") + : NSLocalizedString("_set_available_offline_", comment: ""), + image: UIImage(systemName: "icloud.and.arrow.down") + ) { _ in + if !isAnyOffline, metadatas.count > 3 { + let alert = UIAlertController( + title: NSLocalizedString("_set_available_offline_", comment: ""), + message: NSLocalizedString("_select_offline_warning_", comment: ""), + preferredStyle: .alert + ) + alert.addAction(UIAlertAction(title: NSLocalizedString("_continue_", comment: ""), style: .default) { _ in + Task { + for metadata in metadatas { + await NCNetworking.shared.setMetadataAvalableOffline(metadata, isOffline: isAnyOffline) + } + completion?() + } + }) + alert.addAction(UIAlertAction(title: NSLocalizedString("_cancel_", comment: ""), style: .cancel)) + controller?.present(alert, animated: true) + } else { + Task { + for metadata in metadatas { + await NCNetworking.shared.setMetadataAvalableOffline(metadata, isOffline: isAnyOffline) + } + completion?() + } + } + } + } + + static func saveAsScan( + metadata: tableMetadata, + sceneIdentifier: String? + ) -> UIAction { + UIAction( + title: NSLocalizedString("_save_as_scan_", comment: ""), + image: NCUtility().loadImage(named: "doc.viewfinder", colors: [NCBrandColor.shared.iconImageColor]) + ) { _ in + Task { + if NCUtilityFileSystem().fileProviderStorageExists(metadata) { + await NCNetworking.shared.transferDispatcher.notifyAllDelegates { delegate in + delegate.transferChange( + status: NCGlobal.shared.networkingStatusDownloaded, + account: metadata.account, + fileName: metadata.fileName, + serverUrl: metadata.serverUrl, + selector: NCGlobal.shared.selectorSaveAsScan, + ocId: metadata.ocId, + destination: nil, + error: .success + ) + } + } else if let metadata = await NCManageDatabase.shared.setMetadataSessionInWaitDownloadAsync( + ocId: metadata.ocId, + session: NCNetworking.shared.sessionDownload, + selector: NCGlobal.shared.selectorSaveAsScan, + sceneIdentifier: sceneIdentifier + ) { + await NCNetworking.shared.downloadFile(metadata: metadata) + } + } + } + } + + static func rename( + metadata: tableMetadata, + presenter: UIViewController, + windowScene: UIWindowScene? + ) -> UIAction { + UIAction( + title: NSLocalizedString("_rename_", comment: ""), + image: NCUtility().loadImage(named: "text.cursor", colors: [NCBrandColor.shared.iconImageColor]) + ) { _ in + Task { @MainActor in + let capabilities = await NKCapabilities.shared.getCapabilities(for: metadata.account) + let fileNameNew = await UIAlertController.renameFileAsync( + fileName: metadata.fileNameView, + isDirectory: metadata.directory, + capabilities: capabilities, + account: metadata.account, + presenter: presenter + ) + + if await NCManageDatabase.shared.getMetadataAsync( + predicate: NSPredicate( + format: "account == %@ AND serverUrl == %@ AND fileName == %@", + metadata.account, + metadata.serverUrl, + fileNameNew + ) + ) != nil { + await showErrorBanner( + windowScene: windowScene, + text: "_rename_already_exists_", + errorCode: 0 + ) + return + } + + let error = await NCNetworking.shared.setStatusWaitRename( + metadata, + fileNameNew: fileNameNew, + windowScene: windowScene + ) + if error != .success { + await showErrorBanner(windowScene: windowScene, error: error) + } + } + } + } + + static func moveOrCopy( + metadatas: [tableMetadata], + account: String, + controller: NCMainTabBarController?, + completion: (() -> Void)? = nil + ) -> UIAction { + UIAction( + title: NSLocalizedString("_move_or_copy_", comment: ""), + image: UIImage(systemName: "rectangle.portrait.and.arrow.right") + ) { _ in + Task { @MainActor in + guard let controller else { + completion?() + return + } + + var fileNameError: NKError? + let capabilities = await NKCapabilities.shared.getCapabilities(for: account) + + for metadata in metadatas { + if let sceneIdentifier = metadata.sceneIdentifier, + let controller = SceneManager.shared.getController(sceneIdentifier: sceneIdentifier), + let checkError = FileNameValidator.checkFileName( + metadata.fileNameView, + account: controller.account, + capabilities: capabilities + ) { + fileNameError = checkError + break + } + } + + if let fileNameError { + let message = "\(fileNameError.errorDescription) \(NSLocalizedString("_please_rename_file_", comment: ""))" + await UIAlertController.warningAsync(message: message, presenter: controller) + } else { + NCSelectOpen.shared.openView(items: metadatas, controller: controller) + } + completion?() + } + } + } + + static func lockUnlock( + isLocked: Bool, + metadata: tableMetadata, + controller: NCMainTabBarController?, + completion: (() -> Void)? = nil + ) -> UIAction { + let titleKey: String + var subtitleKey = "" + let image: UIImage? + + if !metadata.canUnlock(as: metadata.userId), isLocked { + titleKey = String(format: NSLocalizedString("_locked_by_", comment: ""), metadata.lockOwnerDisplayName) + image = UIImage(systemName: "lock") + } else { + titleKey = isLocked ? "_unlock_file_" : "_lock_file_" + image = UIImage(systemName: isLocked ? "lock.open" : "lock") + subtitleKey = !metadata.lockOwnerDisplayName.isEmpty + ? String(format: NSLocalizedString("_locked_by_", comment: ""), metadata.lockOwnerDisplayName) + : "" + } + + return UIAction( + title: NSLocalizedString(titleKey, comment: ""), + subtitle: subtitleKey, + image: image, + attributes: metadata.canUnlock(as: metadata.userId) ? [] : [.disabled] + ) { _ in + Task { + let error = await NCNetworking.shared.lockUnlockFile(metadata, shouldLock: !isLocked) + if error != .success { + let windowScene = await SceneManager.shared.getWindowScene(controller: controller) + await showErrorBanner(windowScene: windowScene, error: error) + } + completion?() + } + } + } +} diff --git a/iOSClient/Menu/NCContextMenuMain.swift b/iOSClient/Menu/NCContextMenuMain.swift index f6a646f842..4a7d3cda50 100644 --- a/iOSClient/Menu/NCContextMenuMain.swift +++ b/iOSClient/Menu/NCContextMenuMain.swift @@ -43,120 +43,78 @@ class NCContextMenuMain: NSObject { } let topMenuItems = buildTopMenuItems(metadata: metadata) - - let mainActionsMenu = buildMainActionsMenu( - metadata: metadata, - capabilities: capabilities - ) - - let clientIntegrationMenu = buildClientIntegrationMenuItems( - capabilities: capabilities, - metadata: metadata - ) - + let mainActionsMenu = buildMainActionsMenu(metadata: metadata, capabilities: capabilities) + let clientIntegrationMenu = buildClientIntegrationMenuItems(capabilities: capabilities, metadata: metadata) let deleteMenu = buildDeleteMenu(metadata: metadata) - // Assemble final menu - let baseChildren = [ - UIMenu(title: "", options: .displayInline, children: mainActionsMenu), - UIMenu(title: "", options: .displayInline, children: clientIntegrationMenu), - UIMenu(title: "", options: .displayInline, children: deleteMenu) - ] + var menuElements: [UIMenuElement] = [] - let finalMenu = UIMenu(title: "", children: topMenuItems + baseChildren) - finalMenu.preferredElementSize = .medium // top menu items are shown in a short format style + if let topMenu = NCContextMenuActions.inlineMenu(children: topMenuItems, preferredElementSize: .medium) { + menuElements.append(topMenu) + } - return finalMenu + [mainActionsMenu, clientIntegrationMenu, deleteMenu] + .compactMap { NCContextMenuActions.inlineMenu(children: $0) } + .forEach { menuElements.append($0) } + + return UIMenu(title: "", children: menuElements) } // MARK: Top Menu Items - private func buildTopMenuItems(metadata: tableMetadata, appending items: [UIMenuElement] = []) -> [UIMenuElement] { + private func buildTopMenuItems(metadata: tableMetadata) -> [UIMenuElement] { var topActionsMenu: [UIMenuElement] = [] if metadata.canShare { - topActionsMenu.append(makeShareAction()) + topActionsMenu.append( + NCContextMenuActions.share( + metadatas: [metadata], + controller: controller, + presentViewController: controller, + sender: sender + ) + ) } - topActionsMenu.append(makeDetailAction(metadata: metadata)) + topActionsMenu.append( + NCContextMenuActions.detail( + metadata: metadata, + controller: controller, + presentViewController: controller + ) + ) if !metadata.lock { - topActionsMenu.append(makeFavoriteAction(metadata: metadata)) + topActionsMenu.append(NCContextMenuActions.favorite(metadata: metadata)) } return topActionsMenu } - // MARK: Basic Actions - - private func makeDetailAction(metadata: tableMetadata) -> UIAction { - return UIAction( - title: NSLocalizedString("_details_", comment: ""), - image: utility.loadImage(named: "info.circle.fill") - ) { _ in - NCCreate().createShare(controller: self.controller, - metadata: metadata, - page: .activity) - } - } - - private func makeFavoriteAction(metadata: tableMetadata) -> UIAction { - return UIAction( - title: metadata.favorite ? - NSLocalizedString("_remove_favorites_", comment: "") : - NSLocalizedString("_add_favorites_", comment: ""), - image: utility.loadImage( - named: metadata.favorite ? "star.slash.fill" : "star.fill", - colors: [NCBrandColor.shared.yellowFavorite] - ) - ) { _ in - Task { - await NCNetworking.shared.setStatusWaitFavorite(metadata) - } - } - } - - private func makeShareAction() -> UIAction { - return UIAction( - title: NSLocalizedString("_share_", comment: ""), - image: utility.loadImage(named: "square.and.arrow.up.fill") - ) { _ in - Task { @MainActor in - await NCCreate().createActivityViewController( - selectedMetadata: [self.metadata], - controller: self.controller, - sender: self.sender - ) - } - } - } - // MARK: Main Actions Menu private func buildMainActionsMenu( metadata: tableMetadata, capabilities: NKCapabilities.Capabilities ) -> [UIMenuElement] { - var mainActionsMenu: [UIMenuElement] = [] - // Lock/Unlock + var menuElements: [UIMenuElement] = [] + if NCNetworking.shared.isOnline, !metadata.directory, !capabilities.filesLockVersion.isEmpty { - mainActionsMenu.append( - ContextMenuActions.lockUnlock(isLocked: metadata.lock, + menuElements.append( + NCContextMenuActions.lockUnlock(isLocked: metadata.lock, metadata: metadata, controller: controller) ) } - // E2EE actions - addE2EEActions(metadata: metadata, capabilities: capabilities, mainActionsMenu: &mainActionsMenu) + addE2EEActions(metadata: metadata, capabilities: capabilities, mainActionsMenu: &menuElements) - // Offline if NCNetworking.shared.isOnline, metadata.canSetAsAvailableOffline { - mainActionsMenu.append( - ContextMenuActions.setAvailableOffline( + menuElements.append( + NCContextMenuActions.setAvailableOffline( metadatas: [metadata], isAnyOffline: metadata.isOffline, controller: controller @@ -164,27 +122,23 @@ class NCContextMenuMain: NSObject { ) } - // Save Live Photo if NCNetworking.shared.isOnline, let metadataMOV = NCManageDatabase.shared.getMetadataLivePhoto(metadata: metadata) { - mainActionsMenu.append(makeSaveLivePhotoAction(metadata: metadata, metadataMOV: metadataMOV)) + menuElements.append(NCContextMenuActions.saveLivePhoto(metadata: metadata, metadataMOV: metadataMOV, windowScene: windowScene)) } - // Save as scan if NCNetworking.shared.isOnline, metadata.isSavebleAsImage { - mainActionsMenu.append(makeSaveAsScanAction(metadata: metadata)) + menuElements.append(NCContextMenuActions.saveAsScan(metadata: metadata, sceneIdentifier: sceneIdentifier)) } - // Rename if metadata.isRenameable { - mainActionsMenu.append(makeRenameAction(metadata: metadata)) + menuElements.append(NCContextMenuActions.rename(metadata: metadata, presenter: viewController, windowScene: windowScene)) } - // Move/Copy if metadata.isCopyableMovable { - mainActionsMenu.append( - ContextMenuActions.moveOrCopy( + menuElements.append( + NCContextMenuActions.moveOrCopy( metadatas: [metadata], account: metadata.account, controller: controller @@ -192,19 +146,17 @@ class NCContextMenuMain: NSObject { ) } - // Modify with Quick Look if NCNetworking.shared.isOnline, metadata.isModifiableWithQuickLook { - mainActionsMenu.append(makeModifyWithQuickLookAction(metadata: metadata)) + menuElements.append(makeModifyWithQuickLookAction(metadata: metadata)) } - // Color folder if viewController is NCFiles, metadata.directory { - mainActionsMenu.append(makeColorFolderAction(metadata: metadata)) + menuElements.append(makeColorFolderAction(metadata: metadata)) } - return mainActionsMenu + return menuElements } // MARK: E2EE Actions @@ -296,85 +248,6 @@ class NCContextMenuMain: NSObject { // MARK: File Actions - private func makeSaveLivePhotoAction(metadata: tableMetadata, metadataMOV: tableMetadata) -> UIAction { - return UIAction( - title: NSLocalizedString("_livephoto_save_", comment: ""), - image: utility.loadImage(named: "livephoto", colors: [NCBrandColor.shared.iconImageColor]) - ) { _ in - NCNetworking.shared.saveLivePhotoQueue.addOperation(NCOperationSaveLivePhoto(metadata: metadata, metadataMOV: metadataMOV, windowScene: self.windowScene)) - } - } - - private func makeSaveAsScanAction(metadata: tableMetadata) -> UIAction { - return UIAction( - title: NSLocalizedString("_save_as_scan_", comment: ""), - image: utility.loadImage(named: "doc.viewfinder", colors: [NCBrandColor.shared.iconImageColor]) - ) { _ in - Task { - if self.utilityFileSystem.fileProviderStorageExists(metadata) { - await NCNetworking.shared.transferDispatcher.notifyAllDelegates { delegate in - delegate.transferChange( - status: NCGlobal.shared.networkingStatusDownloaded, - account: metadata.account, - fileName: metadata.fileName, - serverUrl: metadata.serverUrl, - selector: NCGlobal.shared.selectorSaveAsScan, - ocId: metadata.ocId, - destination: nil, - error: .success - ) - } - } else { - if let metadata = await NCManageDatabase.shared.setMetadataSessionInWaitDownloadAsync( - ocId: metadata.ocId, - session: NCNetworking.shared.sessionDownload, - selector: NCGlobal.shared.selectorSaveAsScan, - sceneIdentifier: self.sceneIdentifier - ) { - await NCNetworking.shared.downloadFile(metadata: metadata) - } - } - } - } - } - - private func makeRenameAction(metadata: tableMetadata) -> UIAction { - return UIAction( - title: NSLocalizedString("_rename_", comment: ""), - image: utility.loadImage(named: "text.cursor", colors: [NCBrandColor.shared.iconImageColor]) - ) { _ in - Task { @MainActor in - let capabilities = await NKCapabilities.shared.getCapabilities(for: metadata.account) - let fileNameNew = await UIAlertController.renameFileAsync( - fileName: metadata.fileNameView, - isDirectory: metadata.directory, - capabilities: capabilities, - account: metadata.account, - presenter: self.viewController - ) - - if await NCManageDatabase.shared.getMetadataAsync( - predicate: NSPredicate( - format: "account == %@ AND serverUrl == %@ AND fileName == %@", - metadata.account, - metadata.serverUrl, - fileNameNew - ) - ) != nil { - await showErrorBanner(windowScene: self.windowScene, - text: "_rename_already_exists_", - errorCode: 0) - return - } - - let error = await NCNetworking.shared.setStatusWaitRename(metadata, fileNameNew: fileNameNew, windowScene: self.windowScene) - if error != .success { - await showErrorBanner(windowScene: self.windowScene, error: error) - } - } - } - } - private func makeModifyWithQuickLookAction(metadata: tableMetadata) -> UIAction { return UIAction( title: NSLocalizedString("_modify_", comment: ""), @@ -420,7 +293,7 @@ class NCContextMenuMain: NSObject { ), let hex = tableDirectory.colorFolder, let color = UIColor(hex: hex) { picker.selectedColor = color } - picker.onColorSelected = { [weak self] hexColor in + picker.onColorSelected = { [weak self = self] hexColor in Task { @MainActor in await NCManageDatabase.shared.updateDirectoryColorFolderAsync(hexColor, metadata: metadata, serverUrl: metadata.serverUrlFileName) (self?.viewController as? NCFiles)?.collectionView.reloadData() @@ -442,18 +315,6 @@ class NCContextMenuMain: NSObject { private func buildDeleteMenu(metadata: tableMetadata) -> [UIMenuElement] { var deleteMenu: [UIMenuElement] = [] - /* - let deleteConfirmLocal = makeDeleteLocalAction(metadata: metadata) - let deleteConfirmFile = makeDeleteFileAction(metadata: metadata) - - let deleteSubMenu = UIMenu( - title: NSLocalizedString("_delete_", comment: ""), - image: utility.loadImage(named: "trash"), - options: .destructive, - children: [deleteConfirmLocal, deleteConfirmFile] - ) - */ - deleteMenu.append(makeDeleteLocalAction(metadata: metadata)) if metadata.isDeletable { 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..00d3c107cf 100644 --- a/iOSClient/Menu/NCContextMenuViewer.swift +++ b/iOSClient/Menu/NCContextMenuViewer.swift @@ -11,18 +11,25 @@ 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 private let utility = NCUtility() + private let utilityFileSystem = NCUtilityFileSystem() internal var windowScene: UIWindowScene? { 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 } @@ -34,90 +41,132 @@ class NCContextMenuViewer: NSObject { return nil } + var topMenuItems: [UIMenuElement] = [] var menuElements: [UIMenuElement] = [] let localFile = database.getTableLocalFile(predicate: NSPredicate(format: "ocId == %@", metadata.ocId)) let isOffline = localFile?.offline == true - // DETAIL - if !(!capabilities.fileSharingApiEnabled && !capabilities.filesComments && capabilities.activity.isEmpty) { - menuElements.append(makeDetailAction(metadata: metadata, controller: controller)) + // SHARE + if !webView, + metadata.canShare { + topMenuItems.append( + NCContextMenuActions.share( + metadatas: [metadata], + controller: controller, + presentViewController: viewController, + sender: sender + ) + ) } - // VIEW IN FOLDER - if !webView { - menuElements.append(makeViewInFolderAction(metadata: metadata, controller: controller)) + if shouldShowDetails(for: capabilities) { + topMenuItems.append( + NCContextMenuActions.detail( + metadata: metadata, + controller: controller, + presentViewController: viewController + ) + ) } - // FAVORITE if !metadata.lock { - menuElements.append(makeFavoriteAction(metadata: metadata, controller: controller)) + topMenuItems.append(NCContextMenuActions.favorite(metadata: metadata)) + } + + if NCNetworking.shared.isOnline, + !capabilities.filesLockVersion.isEmpty { + menuElements.append(NCContextMenuActions.lockUnlock(isLocked: metadata.lock, metadata: metadata, controller: controller)) + } + + if !webView { + menuElements.append(makeViewInFolderAction(metadata: metadata, controller: controller, viewController: viewController)) } - // OFFLINE if !webView, metadata.canSetAsAvailableOffline { - menuElements.append(ContextMenuActions.setAvailableOffline(metadatas: [metadata], isAnyOffline: isOffline, controller: controller)) + menuElements.append(NCContextMenuActions.setAvailableOffline(metadatas: [metadata], isAnyOffline: isOffline, controller: controller)) } - // LIVE PHOTO if !webView, NCNetworking.shared.isOnline, - let metadataMOV = NCManageDatabase.shared.getMetadataLivePhoto(metadata: metadata) { - menuElements.append(makeSaveLivePhotoAction(metadata: metadata, metadataMOV: metadataMOV)) + metadata.isSavebleAsImage { + menuElements.append(NCContextMenuActions.saveAsScan(metadata: metadata, sceneIdentifier: controller.sceneIdentifier)) } - // SHARE - if !webView, metadata.canShare { - menuElements.append(ContextMenuActions.share(metadatas: [metadata], controller: controller, sender: sender)) + if !webView, + metadata.isRenameable { + menuElements.append(NCContextMenuActions.rename(metadata: metadata, presenter: viewController ?? controller, windowScene: windowScene)) + } + + if !webView, + metadata.isCopyableMovable { + menuElements.append(NCContextMenuActions.moveOrCopy(metadatas: [metadata], account: metadata.account, controller: controller)) + } + + if !webView, + NCNetworking.shared.isOnline, + let metadataMOV = NCManageDatabase.shared.getMetadataLivePhoto(metadata: metadata) { + menuElements.append(NCContextMenuActions.saveLivePhoto(metadata: metadata, metadataMOV: metadataMOV, windowScene: windowScene)) } - // PDF ACTIONS if metadata.isPDF { menuElements.append(contentsOf: makePDFActions()) } - // DELETE - if !webView, metadata.isDeletable { - menuElements.append(ContextMenuActions.delete(metadatas: [metadata], controller: controller)) + if metadata.isImage, + utilityFileSystem.fileSizeIfExists(metadata) { + menuElements.append(makeModifyPhoto()) } - return UIMenu(title: "", children: menuElements) + if !webView, + metadata.isDeletable { + menuElements.append(UIMenu(options: .displayInline, children: [ + NCContextMenuActions.delete(metadatas: [metadata], controller: controller) + ])) + } + + var finalMenuElements: [UIMenuElement] = [] + + if let topMenu = NCContextMenuActions.inlineMenu(children: topMenuItems, preferredElementSize: .medium) { + finalMenuElements.append(topMenu) + } + + if let baseMenu = NCContextMenuActions.inlineMenu(children: menuElements) { + finalMenuElements.append(baseMenu) + } + + return UIMenu(title: "", children: finalMenuElements) } // MARK: - Private Action Makers - private func makeDetailAction(metadata: tableMetadata, controller: NCMainTabBarController) -> UIAction { - UIAction( - title: NSLocalizedString("_details_", comment: ""), - image: UIImage(systemName: "info") - ) { _ in - NCCreate().createShare(controller: controller, - metadata: metadata, - page: .activity) - } + private func shouldShowDetails(for capabilities: NKCapabilities.Capabilities) -> Bool { + capabilities.fileSharingApiEnabled || capabilities.filesComments || !capabilities.activity.isEmpty } - 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) - } - } - } - - private func makeFavoriteAction(metadata: tableMetadata, controller: NCMainTabBarController) -> UIAction { - UIAction( - title: metadata.favorite - ? NSLocalizedString("_remove_favorites_", comment: "") - : NSLocalizedString("_add_favorites_", comment: ""), - image: utility.loadImage(named: metadata.favorite ? "star.slash" : "star", colors: [NCBrandColor.shared.yellowFavorite]) - ) { _ in - Task { - await NCNetworking.shared.setStatusWaitFavorite(metadata) + 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) + } } } } @@ -143,12 +192,25 @@ class NCContextMenuViewer: NSObject { ] } - private func makeSaveLivePhotoAction(metadata: tableMetadata, metadataMOV: tableMetadata) -> UIAction { + private func makeModifyPhoto() -> UIAction { return UIAction( - title: NSLocalizedString("_livephoto_save_", comment: ""), - image: utility.loadImage(named: "livephoto", colors: [NCBrandColor.shared.iconImageColor]) + title: NSLocalizedString("_modify_", comment: ""), + image: utility.loadImage(named: "pencil.tip.crop.circle", colors: [NCBrandColor.shared.iconImageColor]) ) { _ in - NCNetworking.shared.saveLivePhotoQueue.addOperation(NCOperationSaveLivePhoto(metadata: metadata, metadataMOV: metadataMOV, windowScene: self.windowScene)) + Task { + await NCNetworking.shared.transferDispatcher.notifyAllDelegates { delegate in + delegate.transferChange( + status: NCGlobal.shared.networkingStatusDownloaded, + account: self.metadata.account, + fileName: self.metadata.fileName, + serverUrl: self.metadata.serverUrl, + selector: NCGlobal.shared.selectorLoadFileQuickLook, + ocId: self.metadata.ocId, + destination: nil, + error: .success + ) + } + } } } } diff --git a/iOSClient/More/NCMoreView.swift b/iOSClient/More/NCMoreView.swift index 7210624641..e5c6f41efa 100644 --- a/iOSClient/More/NCMoreView.swift +++ b/iOSClient/More/NCMoreView.swift @@ -183,7 +183,7 @@ struct NCMoreView: View { .background(Color(.secondarySystemGroupedBackground)) .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) } - + private func menuRow(_ item: NCMoreModel.Item) -> some View { Button { model.perform(item.destination) diff --git a/iOSClient/NCGlobal.swift b/iOSClient/NCGlobal.swift index 813c75d117..32d28416d8 100644 --- a/iOSClient/NCGlobal.swift +++ b/iOSClient/NCGlobal.swift @@ -74,6 +74,9 @@ final class NCGlobal: Sendable { } } + // MEDIA SEARCH + let mediaPropOrder = "getlastmodified" + // E2EE // let e2eePassphraseTest = "more over television factory tendency independence international intellectual impress interest sentence pony" @@ -382,6 +385,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/NCImageCache.swift b/iOSClient/NCImageCache.swift index eb40357d0f..3c33ec54d4 100644 --- a/iOSClient/NCImageCache.swift +++ b/iOSClient/NCImageCache.swift @@ -143,7 +143,6 @@ final class NCImageCache: @unchecked Sendable { let showBothPredicate = """ account == %@ AND serverUrl BEGINSWITH %@ AND - mediaSearch == true AND hasPreview == true AND ( classFile == '\(NKTypeClassFile.image.rawValue)' OR classFile == '\(NKTypeClassFile.video.rawValue)' @@ -154,7 +153,6 @@ final class NCImageCache: @unchecked Sendable { let showOnlyPredicateImage = """ account == %@ AND serverUrl BEGINSWITH %@ AND - mediaSearch == true AND hasPreview == true AND ( classFile == '\(NKTypeClassFile.image.rawValue)' OR (classFile == '\(NKTypeClassFile.video.rawValue)' AND livePhotoFile != '') @@ -165,7 +163,6 @@ final class NCImageCache: @unchecked Sendable { let showOnlyPredicateVideo = """ account == %@ AND serverUrl BEGINSWITH %@ AND - mediaSearch == true AND hasPreview == true AND classFile == 'video' AND NOT (status IN %@) diff --git a/iOSClient/Networking/NCNetworking+Download.swift b/iOSClient/Networking/NCNetworking+Download.swift index 4d9fe0c51c..722e8b8de0 100644 --- a/iOSClient/Networking/NCNetworking+Download.swift +++ b/iOSClient/Networking/NCNetworking+Download.swift @@ -29,6 +29,14 @@ extension NCNetworking { return(metadata.account, metadata.etag, metadata.date as Date, metadata.size, .success) } + // Update metadata placeholder + if metadata.placeholder { + let resultsReadFile = await NCNetworking.shared.readFileAsync(serverUrlFileName: metadata.serverUrlFileName, account: metadata.account) + if resultsReadFile.error == .success, let metadata = resultsReadFile.metadata { + await NCManageDatabase.shared.addMetadataAsync(metadata) + } + } + let results = await NextcloudKit.shared.downloadAsync(serverUrlFileName: metadata.serverUrlFileName, fileNameLocalPath: fileNameLocalPath, account: metadata.account, 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 9e3ee32fc5..dc6fe30795 100644 --- a/iOSClient/Networking/NCNetworking+TransferDelegate.swift +++ b/iOSClient/Networking/NCNetworking+TransferDelegate.swift @@ -88,14 +88,14 @@ 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) 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) } } @@ -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: @@ -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 @@ -293,7 +293,7 @@ extension NCNetworking: NCTransferDelegate { if results.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) } } @@ -316,52 +316,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/Networking/NCNetworking+WebDAV.swift b/iOSClient/Networking/NCNetworking+WebDAV.swift index 9671a57355..32bc1cab8a 100644 --- a/iOSClient/Networking/NCNetworking+WebDAV.swift +++ b/iOSClient/Networking/NCNetworking+WebDAV.swift @@ -629,6 +629,7 @@ extension NCNetworking { // MARK: - Favorite + @discardableResult func setStatusWaitFavorite(_ metadata: tableMetadata) async -> NKError { if metadata.status != global.metadataStatusNormal, metadata.status != global.metadataStatusWaitFavorite { diff --git a/iOSClient/RichWorkspace/NCViewerRichWorkspaceWebView.swift b/iOSClient/RichWorkspace/NCViewerRichWorkspaceWebView.swift index 70d5358c0a..8145f0e0bf 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, + presentViewController: self.controller, metadata: metadata!, page: .sharing) } diff --git a/iOSClient/Select/NCSelect.swift b/iOSClient/Select/NCSelect.swift index 775de939ab..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) { } + func tapRecommendations(with metadata: tableMetadata, viewerTransitionSource: NCMediaViewerTransitionSource?) { } // MARK: - Push metadata diff --git a/iOSClient/Share/NCShare.swift b/iOSClient/Share/NCShare.swift index 2f2a765db8..3839c6f563 100644 --- a/iOSClient/Share/NCShare.swift +++ b/iOSClient/Share/NCShare.swift @@ -108,6 +108,15 @@ class NCShare: UIViewController, NCSharePagingContent { checkSharedWithYou() } + // Update metadata placeholder + if metadata.placeholder { + let results = await NCNetworking.shared.readFileAsync(serverUrlFileName: metadata.serverUrlFileName, account: metadata.account) + if results.error == .success, let metadata = results.metadata { + await database.addMetadataAsync(metadata) + self.metadata = metadata + } + } + reloadData() networking = NCShareNetworking(metadata: metadata, view: self.view, delegate: self, session: session, controller: controller) diff --git a/iOSClient/Supporting Files/en.lproj/Localizable.strings b/iOSClient/Supporting Files/en.lproj/Localizable.strings index 77b957c141..57af0d1eee 100644 --- a/iOSClient/Supporting Files/en.lproj/Localizable.strings +++ b/iOSClient/Supporting Files/en.lproj/Localizable.strings @@ -23,7 +23,7 @@ "_cancel_" = "Cancel"; "_edit_" = "Edit"; "_tap_to_cancel_" = "Tap to cancel"; -"_tap_to_min_max_" = "Tap to minimize"; +"_tap_to_min_max_" = "Tap to minimize / maximize"; "_cancel_request_" = "Do you want to cancel?"; "_upload_file_" = "Upload file"; "_download_file_" = "Download file"; @@ -166,8 +166,8 @@ "_keep_both_for_all_action_title_" = "Keep both for all"; "_more_action_title_" = "More Details"; "_wait_file_preparation_" = "Preparing file upload. Please wait …"; -"_keep_active_for_upload_" = "Keep application open until upload is completed …"; -"_keep_active_for_transfers_" = "Keep application open until the transfers are completed …"; +"_keep_active_for_upload_" = "Keep application active until upload is completed …"; +"_keep_active_for_transfers_" = "Keep application active until the transfers are completed …"; "_wait_file_encryption_" = "Please wait, encrypting file…"; "_passcode_counter_fail_" = "Too many failed attempts, please wait before reattempting."; "_seconds_" = "seconds"; @@ -601,8 +601,8 @@ "_undo_modify_" = "Undo modifying"; "_rename_already_exists_" = "A file with this name already exists."; "_group_folders_" = "Team folders"; -"_play_from_files_" = "Play movie from a file"; -"_play_from_url_" = "Play movie from URL"; +"_play_from_files_" = "Play video from a file"; +"_play_from_url_" = "Play video from a link"; "_valid_video_url_" = "Insert a valid URL"; "_chunk_move_" = "The sent file could not be reassembled, please check the server log."; "_download_image_" = "Download image"; @@ -671,17 +671,30 @@ "_delete_in_progress_" = "Delete in progress …"; "_download_in_progress_" = "Download in progress …"; "_upload_in_progress_" = "Upload in progress …"; -"_transfer_in_progress_" = "Transfer in progress …"; -"_in_waiting_" = "Scheduled"; +"_transfer_in_progress_" = "Transfer in progress …"; +"_in_waiting_" = "In waiting"; "_in_progress_" = "In progress"; -"_in_error_" = "Failed"; +"_in_error_" = "In error"; "_retry_minutes_" = "min left to retry"; "_retry_seconds_" = "sec left to retry"; "_retry_soon_" = "retrying soon …"; -"_large_upload_tip_" = "Large files require the app to remain open until the transfer is complete."; -"_e2ee_upload_tip_" = "End-to-end encrypted files require the app to remain open until the transfer is complete."; +"_large_upload_tip_" = "Large files require the app to remain open until the transfer is complete"; +"_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."; +"_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"; +"_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 @@ -765,20 +778,6 @@ You can stop it at any time, adjust the settings, and enable it again."; "_back_up_new_photos_only_" = "Back up new photos/videos only"; "_auto_upload_all_photos_warning_title_" = "Are you sure you want to upload all photos?"; "_auto_upload_all_photos_warning_message_" = "This can take some time to process depending on the amount of photos."; -"_auto_upload_no_new_items_to_upload_" = "No new items to upload"; -"_focused_auto_upload_" = "Focused Auto Upload"; -"_focused_auto_upload_settings_footer_" = "Keep the app open and darken the screen while auto upload is backing up at higher speed."; -"_focused_auto_upload_intro_heading_" = "Keep backing up with the screen darkened."; -"_focused_auto_upload_intro_message_" = "Photos and videos will continue backing up at higher speed while the screen is darkened. During the process:"; -"_focused_auto_upload_wifi_" = "Connect to Wi-Fi"; -"_focused_auto_upload_charger_" = "Connect to charger"; -"_focused_auto_upload_do_not_exit_" = "Do not exit the app"; -"_enable_focused_auto_upload_" = "Enable Focused Auto Upload"; -"_focused_auto_upload_backing_up_" = "Backing up"; -"_focused_auto_upload_completed_" = "Upload completed!"; -"_focused_auto_upload_countdown_" = "Do not lock the screen or exit the app. The screen will turn dark in %d seconds."; -"_stop_focused_auto_upload_" = "Stop Focused Auto Upload"; -"_finish_" = "Finish"; "_item_with_same_name_already_exists_" = "An item with the same name already exists."; // MARK: Migration Multi Domains diff --git a/iOSClient/Utility/NCUtilityFileSystem.swift b/iOSClient/Utility/NCUtilityFileSystem.swift index 46332bebbb..db4bdbf854 100644 --- a/iOSClient/Utility/NCUtilityFileSystem.swift +++ b/iOSClient/Utility/NCUtilityFileSystem.swift @@ -119,6 +119,7 @@ final class NCUtilityFileSystem: NSObject, @unchecked Sendable { return path } + @discardableResult func cleanDirectoryProviderStorageOcId(_ ocId: String, userId: String, urlBase: String) -> String { let path = getDocumentStorage(userId: userId, urlBase: urlBase) + "/" + ocId @@ -244,6 +245,17 @@ final class NCUtilityFileSystem: NSObject, @unchecked Sendable { return false } + /// Returns the file size for a path if the file exists and can be read. + func fileSizeIfExists(_ metadata: tableMetadata) -> Bool { + let path = getDirectoryProviderStorageOcId(metadata.ocId, fileName: metadata.fileNameView, userId: metadata.userId, urlBase: metadata.urlBase) + do { + let attributes = try fileManager.attributesOfItem(atPath: path) + return attributes[.size] as? UInt64 ?? 0 > 0 + } catch { + return false + } + } + func fileProviderStorageSize(_ ocId: String, fileName: String, userId: String, urlBase: String) -> UInt64 { let fileNamePath = getDirectoryProviderStorageOcId(ocId, fileName: fileName, userId: userId, urlBase: urlBase) do { @@ -864,4 +876,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..c11838e872 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: NCMediaViewerTransitionSource?) async -> UIViewController? { let session = NCSession.shared.getSession(account: metadata.account) // Set Last Opening Date await self.database.setLocalFileLastOpeningDateAsync(metadata: metadata) @@ -41,18 +42,36 @@ 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] + var metadata = metadata + let mediaOcIds = ocIds ?? [metadata.ocId] + + // Update metadata placeholder + if metadata.placeholder { + let results = await NCNetworking.shared.readFileAsync(serverUrlFileName: metadata.serverUrlFileName, account: metadata.account) + if results.error == .success, let resultsMetadata = results.metadata { + metadata = resultsMetadata + await NCManageDatabase.shared.addMetadataAsync(resultsMetadata) + } } - return viewerMediaPageContainer + let model = NCMediaViewerModel(currentMetadata: metadata, ocIds: mediaOcIds, session: session, 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 @@ -187,7 +206,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 915636d72a..9028e3b4dd 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, + presentViewController: 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..2ea0307da2 --- /dev/null +++ b/iOSClient/Viewer/NCViewerMedia/Content/Audio/NCAudioViewerContentView.swift @@ -0,0 +1,582 @@ +// 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 + +struct NCAudioViewerContentView: View { + let metadata: tableMetadata + let localURL: URL + let previewURL: URL? + let backgroundStyle: NCViewerBackgroundStyle + let canGoPrevious: Bool + let canGoNext: Bool + let shouldAutoPlay: Bool + let onPrevious: (_ shouldAutoPlay: Bool) -> Void + let onNext: (_ shouldAutoPlay: Bool) -> Void + let onAutoPlayConsumed: () -> Void + let onToggleChrome: () -> Void + + @StateObject private var model: NCAudioViewerModel + + init( + metadata: tableMetadata, + localURL: URL, + previewURL: URL? = nil, + backgroundStyle: NCViewerBackgroundStyle = .system, + canGoPrevious: Bool = false, + canGoNext: Bool = false, + shouldAutoPlay: Bool = false, + onPrevious: @escaping (_ shouldAutoPlay: Bool) -> Void = { _ in }, + onNext: @escaping (_ shouldAutoPlay: Bool) -> Void = { _ in }, + onAutoPlayConsumed: @escaping () -> Void = {}, + onToggleChrome: @escaping () -> Void = {} + ) { + self.metadata = metadata + self.localURL = localURL + self.previewURL = previewURL + self.backgroundStyle = backgroundStyle + self.canGoPrevious = canGoPrevious + self.canGoNext = canGoNext + self.shouldAutoPlay = shouldAutoPlay + self.onPrevious = onPrevious + self.onNext = onNext + self.onAutoPlayConsumed = onAutoPlayConsumed + self.onToggleChrome = onToggleChrome + + _model = StateObject( + wrappedValue: NCAudioViewerPlaybackRegistry.shared.model( + for: metadata.ocId + ) + ) + } + + var body: some View { + 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 + + ZStack { + Color.ncViewerBackground(backgroundStyle) + .contentShape(Rectangle()) + .onTapGesture { + onToggleChrome() + } + + VStack(spacing: mainSpacing) { + artworkView(size: artworkSize) + if !isLandscape { + VStack(spacing: 8) { + Text(displayFileName) + .font(.headline) + .foregroundStyle(primaryForegroundStyle) + .lineLimit(2) + .multilineTextAlignment(.center) + + Text(metadata.contentType.isEmpty ? "Audio" : metadata.contentType) + .font(.footnote) + .foregroundStyle(secondaryForegroundStyle) + .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) + + HStack { + Text(formatTime(model.currentTime)) + + Spacer() + + Text(formatTime(model.duration)) + } + .font(.caption.monospacedDigit()) + .foregroundStyle(secondaryForegroundStyle) + } + .padding(.horizontal, sliderHorizontalPadding) + + HStack(spacing: buttonSpacing) { + Button { + model.toggleLoop() + } label: { + Image(systemName: model.isLoopEnabled ? "repeat.circle.fill" : "repeat.circle") + .font(.system(size: sideButtonSize, weight: .regular)) + .foregroundStyle(model.isLoopEnabled ? primaryForegroundStyle : mutedForegroundStyle) + } + .buttonStyle(.plain) + + Button { + model.togglePlayback() + } label: { + Image(systemName: model.isPlaying ? "pause.circle.fill" : "play.circle.fill") + .font(.system(size: playButtonSize, weight: .regular)) + .foregroundStyle(primaryForegroundStyle) + } + .buttonStyle(.plain) + + Button { + model.restart() + } label: { + Image(systemName: "gobackward") + .font(.system(size: sideButtonSize, weight: .regular)) + .foregroundStyle(mutedForegroundStyle) + } + .buttonStyle(.plain) + .disabled(model.duration <= 0) + } + } + .padding(.top, topPadding) + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + .task(id: localURL) { + await model.load(url: localURL) + consumeAutoPlayIfNeeded() + } + .onChange(of: shouldAutoPlay) { _, newValue in + guard newValue else { + return + } + + 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() + } + } + + // MARK: - Views + + private func artworkView(size: CGFloat) -> some View { + ZStack { + if let previewImage { + Image(uiImage: previewImage) + .resizable() + .scaledToFill() + .frame(width: size, height: size) + .clipShape(RoundedRectangle(cornerRadius: 24)) + } else { + Circle() + .fill(artworkPlaceholderBackground) + .frame(width: size, height: size) + + Image(systemName: "waveform") + .font(.system(size: 76, weight: .regular)) + .foregroundStyle(primaryForegroundStyle.opacity(0.9)) + } + } + } + + private var previewImage: UIImage? { + guard let previewURL, + previewURL.isFileURL else { + return nil + } + + return UIImage(contentsOfFile: previewURL.path) + } + + private var primaryForegroundStyle: Color { + switch backgroundStyle { + case .black: + return .white + + case .white: + return .black + + case .system: + return .primary + + case .custom: + return .white + } + } + + private var secondaryForegroundStyle: Color { + switch backgroundStyle { + case .black: + return .white.opacity(0.55) + + case .white: + return .black.opacity(0.55) + + case .system: + return .secondary + + case .custom: + return .white.opacity(0.65) + } + } + + private var mutedForegroundStyle: Color { + switch backgroundStyle { + case .black: + return .white.opacity(0.45) + + case .white: + return .black.opacity(0.40) + + case .system: + return .secondary.opacity(0.70) + + case .custom: + return .white.opacity(0.45) + } + } + + private var artworkPlaceholderBackground: Color { + switch backgroundStyle { + case .black: + return .white.opacity(0.08) + + case .white: + return .black.opacity(0.06) + + case .system: + return .secondary.opacity(0.10) + + case .custom: + return .white.opacity(0.10) + } + } + + // MARK: - Private + + private var displayFileName: String { + if !metadata.fileNameView.isEmpty { + return metadata.fileNameView + } + + return metadata.fileName + } + + @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 models alive across SwiftUI rebuilds. +@MainActor +final class NCAudioViewerPlaybackRegistry { + static let shared = NCAudioViewerPlaybackRegistry() + + private var modelsByOcId: [String: NCAudioViewerModel] = [:] + + private init() { } + + func model(for ocId: String) -> NCAudioViewerModel { + if let model = modelsByOcId[ocId] { + return model + } + + let model = NCAudioViewerModel() + modelsByOcId[ocId] = model + return model + } + + // Do not remove models while SwiftUI pages may still hold them. + func stopAll() { + modelsByOcId.values.forEach { $0.stop() } + } +} + +// MARK: - Audio Viewer Model + +@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 + + 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 + + addTimeObserver(to: player) + addEndObserver(for: item, player: player) + + Task { [weak self] in + let loadedDuration: Double + + if let duration = try? await asset.load(.duration), + duration.seconds.isFinite { + loadedDuration = duration.seconds + } else { + loadedDuration = 0 + } + + await MainActor.run { + guard let self, + self.currentURL == url, + self.player === player else { + return + } + + self.duration = loadedDuration + } + } + } + + 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 + } + + func togglePlayback() { + if isPlaying { + pause() + } else { + play() + } + } + + func toggleLoop() { + isLoopEnabled.toggle() + } + + func restart() { + seek(to: 0) + + if isPlaying { + player?.play() + } + } + + 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 + ) + } + + func pause() { + player?.pause() + isPlaying = false + } + + 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 + + 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 + ) + } + } + + 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 + } + } + } + + 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..5e76e3e32b --- /dev/null +++ b/iOSClient/Viewer/NCViewerMedia/Content/Image/NCImageViewerContentView.swift @@ -0,0 +1,304 @@ +// 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 + +struct NCImageViewerContentView: View { + let identifier: String + let previewURL: URL? + let fullURL: URL? + let backgroundStyle: NCViewerBackgroundStyle + let allowsImageAnalysis: Bool + + @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? + @State private var isShowingFullImage = false + + private var taskIdentifier: String { + "\(identifier)|\(previewURL?.absoluteString ?? "")|\(fullURL?.absoluteString ?? "")" + } + + init( + identifier: String, + previewURL: URL?, + fullURL: URL?, + backgroundStyle: NCViewerBackgroundStyle = .system, + allowsImageAnalysis: Bool = true + ) { + self.identifier = identifier + self.previewURL = previewURL + self.fullURL = fullURL + self.backgroundStyle = backgroundStyle + self.allowsImageAnalysis = allowsImageAnalysis + } + + var body: some View { + ZStack { + Color.ncViewerBackground(backgroundStyle) + .ignoresSafeArea() + + if let currentImage { + NCImageZoomView( + image: currentImage, + backgroundStyle: backgroundStyle, + allowsImageAnalysis: shouldAllowImageAnalysis + ) + .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(NSLocalizedString("_image_load_failed_", comment: "")) + .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 + + // Decode preview first, then replace it with the full image when ready. + @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 + isShowingFullImage = false + 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 + isShowingFullImage = false + currentImage = previewImage + + await Task.yield() + } + } + + guard let expectedFullURL else { + return + } + + guard loadedFullURL != expectedFullURL else { + return + } + + if loadedPreviewURL == expectedFullURL, + currentImage != nil { + loadedFullURL = expectedFullURL + isShowingFullImage = true + 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 + isShowingFullImage = true + currentImage = fullImage + return + } + + if currentImage == nil { + if isGIF(expectedFullURL) { + failedMessage = NSLocalizedString("_gif_file_could_not_be_decoded_", comment: "") + } else if isSVG(expectedFullURL) { + failedMessage = NSLocalizedString("_svg_file_could_not_be_rendered_", comment: "") + } else { + failedMessage = NSLocalizedString("_image_file_could_not_be_decoded_", comment: "") + } + } + } + + // Prepare the full image before replacing the preview. + 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 + } + + 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 + } + + 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 + } + + // SVG rendering uses WKWebView and must stay on the main actor. + @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) + ) + } + + private func isGIF(_ url: URL?) -> Bool { + url?.pathExtension.lowercased() == "gif" + } + + private func isSVG(_ url: URL?) -> Bool { + url?.pathExtension.lowercased() == "svg" + } + + 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 + } + + private var shouldAllowImageAnalysis: Bool { + guard allowsImageAnalysis, + isShowingFullImage, + let fullURL else { + return false + } + + if isGIF(fullURL) { + return false + } + + if isSVG(fullURL) { + return false + } + + return true + } +} diff --git a/iOSClient/Viewer/NCViewerMedia/Content/Image/NCImageZoomView.swift b/iOSClient/Viewer/NCViewerMedia/Content/Image/NCImageZoomView.swift new file mode 100644 index 0000000000..a880bad5fe --- /dev/null +++ b/iOSClient/Viewer/NCViewerMedia/Content/Image/NCImageZoomView.swift @@ -0,0 +1,383 @@ +// 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 +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 + + 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 + func resetBoundsTracking() { + lastBoundsSize = .zero + } + + 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() + } + + // Reset zoom on size changes to avoid stale offsets. + 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() + } + + 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 + } + } + + private func isValidLayout( + imageSize: CGSize, + boundsSize: CGSize + ) -> Bool { + imageSize.width > 0 && + imageSize.height > 0 && + boundsSize.width > 0 && + boundsSize.height > 0 + } + + 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 + @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 zoomSize = CGSize( + width: scrollView.bounds.width / targetScale, + height: scrollView.bounds.height / targetScale + ) + + let zoomRect = CGRect( + x: point.x - zoomSize.width * 0.5, + y: point.y - zoomSize.height * 0.5, + width: zoomSize.width, + height: zoomSize.height + ) + + scrollView.zoom(to: zoomRect, animated: true) + } + } + + // MARK: - Image Analysis + // Rebuild analysis to avoid stale VisionKit results after image changes. + @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 + } + } + + @MainActor + private func removeImageAnalysisInteractions(from imageView: UIImageView) { + imageView.interactions + .compactMap { $0 as? ImageAnalysisInteraction } + .forEach { imageView.removeInteraction($0) } + } +} diff --git a/iOSClient/Viewer/NCViewerMedia/Content/Image/NCLivePhotoViewerContentView.swift b/iOSClient/Viewer/NCViewerMedia/Content/Image/NCLivePhotoViewerContentView.swift new file mode 100644 index 0000000000..4be5b875da --- /dev/null +++ b/iOSClient/Viewer/NCViewerMedia/Content/Image/NCLivePhotoViewerContentView.swift @@ -0,0 +1,394 @@ +// 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 + +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 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 + } + .background(Color.ncViewerBackground(backgroundStyle)) + .task(id: taskIdentifier) { + await loadLivePhotoIfNeeded() + } + .highPriorityGesture( + LongPressGesture(minimumDuration: 0.25) + .onEnded { _ in + guard livePhoto != nil else { + return + } + + isPlayingLivePhoto = true + } + ) + // Stop Live Photo playback when the media viewer requests a global playback stop. + .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, + allowsImageAnalysis: false + ) + } + + 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) + } + } + + 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) + } + + // MARK: - Identifiers + + private var taskIdentifier: String { + "\(identifier)|\(fullURL?.absoluteString ?? "")|\(videoURL?.absoluteString ?? "")" + } + + private var playbackViewIdentifier: String { + "\(taskIdentifier)|playback" + } + + // MARK: - Loading + + // Keep the still image visible when Live Photo resources are missing. + @MainActor + private func loadLivePhotoIfNeeded() async { + if loadedTaskIdentifier != taskIdentifier { + livePhoto = nil + isPlayingLivePhoto = false + loadedTaskIdentifier = taskIdentifier + } + + guard livePhoto == nil else { + return + } + + 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 { + return + } + + livePhoto = loadedLivePhoto + } + + @MainActor + private func stopLivePhotoPlayback() { + isPlayingLivePhoto = false + } + + // Photos may call the handler more than once; resume only once. + @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 + +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..5b56f828de --- /dev/null +++ b/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoAVPlayerPresenter.swift @@ -0,0 +1,196 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Marino Faggiana +// SPDX-License-Identifier: GPL-3.0-or-later + +import UIKit +import NextcloudKit + +// MARK: - AVPlayer Presenter +@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 or updates the single AVPlayer fullscreen controller. + static func present( + metadata: tableMetadata, + preparedPlayback: NCVideoAVPreparedPlayback, + userAgent: String?, + shouldAutoPlayOnStart: Bool = true, + isChromeHidden: Bool = false, + contextMenuController: NCMainTabBarController?, + canGoPrevious: Bool = false, + canGoNext: Bool = false, + onPrevious: (() -> Void)? = nil, + onNext: (() -> Void)? = nil, + onClose: ((_ ocId: String?) -> Void)? = nil + ) { + let url = preparedPlayback.url + if currentURL == url, + let currentViewController { + currentViewController.update( + metadata: metadata, + preparedPlayback: preparedPlayback, + userAgent: userAgent, + shouldAutoPlayOnStart: shouldAutoPlayOnStart, + isChromeHidden: isChromeHidden, + contextMenuController: contextMenuController + ) + currentViewController.canGoPrevious = canGoPrevious + currentViewController.canGoNext = canGoNext + currentViewController.onPrevious = onPrevious + currentViewController.onNext = onNext + currentViewController.onClose = onClose + + return + } + + if isPresenting { + return + } + + if let currentViewController { + currentViewController.update( + metadata: metadata, + preparedPlayback: preparedPlayback, + userAgent: userAgent, + shouldAutoPlayOnStart: shouldAutoPlayOnStart, + isChromeHidden: isChromeHidden, + 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, + preparedPlayback: preparedPlayback, + userAgent: userAgent, + shouldAutoPlayOnStart: shouldAutoPlayOnStart, + isChromeHidden: isChromeHidden, + 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.navigationBar.prefersLargeTitles = false + navigationController.navigationBar.barStyle = .black + navigationController.navigationBar.tintColor = .white + navigationController.navigationBar.titleTextAttributes = [ + .foregroundColor: UIColor.white + ] + + presenter.present( + navigationController, + animated: false + ) { + isPresenting = false + } + } + + static func clearCurrent( + _ viewController: NCVideoAVPlayerViewController + ) { + guard currentViewController === viewController else { + return + } + + currentViewController = nil + currentURL = nil + isPresenting = false + } + + static func dismissCurrent() { + guard let currentViewController else { + return + } + + currentViewController.dismiss(animated: false) { + clearCurrent(currentViewController) + } + } + + static func dismiss() { + dismissCurrent() + } + + // MARK: - Private + 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) + } + + 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..61ec9feaa8 --- /dev/null +++ b/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoAVPlayerViewController.swift @@ -0,0 +1,1001 @@ +// 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 + +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 + +final class NCVideoAVPlayerViewController: UIViewController { + + // MARK: - Input + + private var metadata: tableMetadata + private var preparedPlayback: NCVideoAVPreparedPlayback + private var url: URL + private var userAgent: String? + private var shouldAutoPlayOnStart: Bool + private var isChromeHidden: Bool + 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() + internal let controlsView = NCVideoControlsView() + + private let floatingTitleView = NCMediaViewerFloatingTitleView() + + private lazy var floatingTitleDateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.locale = .current + formatter.dateStyle = .medium + formatter.timeStyle = .short + return formatter + }() + + // MARK: - AVPlayer + + internal var player: AVPlayer + + 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? + private var timeControlStatusObservation: NSKeyValueObservation? + 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 && !isPlaybackRequested + } + + internal func setNavigationBarVisible( + _ isVisible: Bool, + animated: Bool + ) { + navigationController?.setNavigationBarHidden( + !isVisible, + animated: animated + ) + } + + // MARK: - Navigation Items + + 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( + named: "info.circle", + colors: [NCBrandColor.shared.iconImageColor] + ), + style: .plain, + target: self, + action: #selector(mediaDetailButtonTapped) + ) + + // MARK: - Init + + init( + metadata: tableMetadata, + preparedPlayback: NCVideoAVPreparedPlayback, + userAgent: String?, + shouldAutoPlayOnStart: Bool = true, + isChromeHidden: Bool = false, + contextMenuController: NCMainTabBarController? + ) { + self.metadata = metadata + self.preparedPlayback = preparedPlayback + self.url = preparedPlayback.url + self.player = preparedPlayback.player + self.userAgent = userAgent + self.shouldAutoPlayOnStart = shouldAutoPlayOnStart + self.isChromeHidden = isChromeHidden + self.contextMenuController = contextMenuController + + super.init( + nibName: nil, + bundle: nil + ) + + modalPresentationStyle = .fullScreen + } + + 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 initialBackgroundColor = viewerBackgroundColor + + let rootView = UIView() + rootView.backgroundColor = initialBackgroundColor + rootView.isOpaque = true + rootView.clipsToBounds = true + + playerContainerView.backgroundColor = initialBackgroundColor + playerContainerView.isOpaque = true + playerContainerView.clipsToBounds = true + playerContainerView.translatesAutoresizingMaskIntoConstraints = false + playerContainerView.playerLayer.videoGravity = .resizeAspect + + controlsView.delegate = self + controlsView.alpha = 0 + controlsView.isHidden = true + controlsView.translatesAutoresizingMaskIntoConstraints = false + + rootView.addSubview(playerContainerView) + 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), + + 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 = viewerBackgroundColor + + configureNavigationItem() + updateTitleLabel(metadata: metadata) + configureAudioSession() + configurePlayerLayer() + configureSwipeGestures() + configureTapGesture() + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + let shouldPreserveHiddenChromeBackground = isChromeHidden + + start() + showControls(animated: false) + stopControlsHideTimer() + + if shouldPreserveHiddenChromeBackground { + updateViewerBackground(isChromeHidden: true) + } + } + + 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 + + func update( + metadata: tableMetadata, + preparedPlayback: NCVideoAVPreparedPlayback, + userAgent: String?, + shouldAutoPlayOnStart: Bool = true, + isChromeHidden: Bool = false, + contextMenuController: NCMainTabBarController? + ) { + let urlChanged = self.url != preparedPlayback.url + + if urlChanged { + stop() + self.preparedPlayback = preparedPlayback + self.url = preparedPlayback.url + self.player = preparedPlayback.player + } + + self.metadata = metadata + self.userAgent = userAgent + self.shouldAutoPlayOnStart = shouldAutoPlayOnStart + self.contextMenuController = contextMenuController + updateViewerBackground(isChromeHidden: isChromeHidden) + updateTitleLabel(metadata: metadata) + + refreshMoreMenu() + + if urlChanged { + start() + } + + updatePlayPauseButton() + 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() { + 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 + ] + } + + private func configureFloatingTitleViewIfNeeded() { + guard let navigationBar = navigationController?.navigationBar else { + return + } + + floatingTitleView.attach(to: navigationBar) + } + + private func updateTitleLabel(metadata: tableMetadata) { + let primaryTitle = metadata.fileNameView.isEmpty + ? metadata.fileName + : metadata.fileNameView + + floatingTitleView.update( + primaryText: primaryTitle, + secondaryText: floatingTitleDateFormatter.string(from: metadata.date as Date), + textColor: .white + ) + } + + private func refreshMoreMenu() { + moreNavigationItem.menu = makeMoreMenu(sender: moreNavigationItem) + } + + // 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 { + completion([]) + return + } + + if let menu = NCContextMenuViewer( + metadata: self.metadata, + controller: self.contextMenuController, + viewController: self, + webView: false, + sender: sender + ).viewMenu() { + completion(menu.children) + } else { + completion([]) + } + } + ]) + } + + @objc + private func closeTapped() { + close() + } + + @objc + private func mediaDetailButtonTapped() { + presentDetailView(animated: true) + } + + 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() { + let closeCallback = onClose + let closingOcId = metadata.ocId + let controllerToDismiss = navigationController ?? self + + NCVideoAVPlayerPresenter.clearCurrent(self) + + controllerToDismiss.dismiss(animated: false) { [weak self] in + self?.stopControlsHideTimer() + self?.stop() + + DispatchQueue.main.async { + closeCallback?(closingOcId) + } + } + } + + func closeImmediately() { + let closeCallback = onClose + let controllerToDismiss = navigationController ?? self + + NCVideoAVPlayerPresenter.clearCurrent(self) + + controllerToDismiss.dismiss(animated: false) { [weak self] in + self?.stopControlsHideTimer() + self?.stop() + + DispatchQueue.main.async { + closeCallback?(nil) + } + } + } + + // MARK: - Swipe Navigation + + 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 + self.closePanGesture = closePanGesture + view.addGestureRecognizer(closePanGesture) + } + + @objc + private func handleSwipe(_ gesture: UISwipeGestureRecognizer) { + guard gesture.state == .ended else { + return + } + + guard !isPictureInPictureActive else { + return + } + + switch gesture.direction { + case .left: + guard canGoNext else { + return + } + onNext?() + + case .right: + guard canGoPrevious else { + return + } + onPrevious?() + + default: + break + } + } + + // Close only when downward movement wins over horizontal paging. + @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 + + private func configureTapGesture() { + let tapGesture = UITapGestureRecognizer( + target: self, + action: #selector(handleSingleTap(_:)) + ) + tapGesture.numberOfTapsRequired = 1 + tapGesture.cancelsTouchesInView = false + tapGesture.delegate = self + view.addGestureRecognizer(tapGesture) + } + + // Keep controls visible when playback is not running. + @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 + + private func start() { + isPlaybackRequested = shouldAutoPlayOnStart + + guard preparedURL != url else { + updatePlayPauseButton() + updateProgressControls() + updateSeekingState() + return + } + + preparedURL = url + playerContainerView.player = player + updatePlayPauseButton() + + configureExternalPlayback() + configureObservers() + configurePictureInPicture() + + if shouldAutoPlayOnStart, + player.timeControlStatus != .playing { + player.play() + } + + updatePlayPauseButton() + updateProgressControls() + updateSeekingState() + } + + private func stop() { + preparedURL = nil + isPlaybackRequested = false + + player.pause() + cleanupObservers() + + playerContainerView.player = nil + + pictureInPictureController?.delegate = nil + pictureInPictureController = nil + + updatePlayPauseButton() + updateProgressControls() + } + + private func configurePlayerLayer() { + playerContainerView.playerLayer.videoGravity = .resizeAspect + playerContainerView.player = player + } + + private func configureExternalPlayback() { + player.allowsExternalPlayback = true + player.usesExternalPlaybackWhileExternalScreenIsActive = true + } + + 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) + } + + private func updatePictureInPictureLayout() { + playerContainerView.playerLayer.frame = playerContainerView.bounds + } + + func togglePictureInPicture() { + guard let pictureInPictureController else { + return + } + + if pictureInPictureController.isPictureInPictureActive { + pictureInPictureController.stopPictureInPicture() + } else { + pictureInPictureController.startPictureInPicture() + } + } + + 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() + } + } + } + + 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 + } + } + + private func handleCurrentItemStatusChange() { + updateProgressControls() + updateSeekingState() + + guard player.currentItem?.status == .readyToPlay else { + updatePlayPauseButton() + return + } + + if shouldAutoPlayOnStart, + player.timeControlStatus != .playing { + isPlaybackRequested = true + updatePlayPauseButton() + player.play() + } else { + updatePlayPauseButton() + } + + if !controlsVisible, + !isPictureInPictureActive { + showControls(animated: false) + scheduleControlsHide() + } + } + + 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 { + if !isPlaybackRequested { + showControls(animated: false) + stopControlsHideTimer() + } + return + } + + if controlsVisible { + scheduleControlsHide() + } + } + + private func handlePlaybackEnded() { + isPlaybackRequested = false + + updatePlayPauseButton() + updateProgressControls() + showControls(animated: true) + } + + private func updateControlsNavigationBar() { + controlsView.setTopActionsNavigationBar(navigationController?.navigationBar) + } + + 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) + } + + 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 + ) + } + } + + internal func updatePlayPauseButton() { + let isPlaying = player.timeControlStatus == .playing || + player.timeControlStatus == .waitingToPlayAtSpecifiedRate || + isPlaybackRequested + + controlsView.updatePlayPauseButton(isPlaying: isPlaying) + } + + 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))" + ) + } + + 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 + ) { + + stopControlsHideTimer() + hideControls(animated: false) + } + + func pictureInPictureControllerDidStartPictureInPicture( + _ pictureInPictureController: AVPictureInPictureController + ) { + + stopControlsHideTimer() + hideControls(animated: false) + } + + func pictureInPictureControllerWillStopPictureInPicture( + _ pictureInPictureController: AVPictureInPictureController + ) { + } + + func pictureInPictureControllerDidStopPictureInPicture( + _ pictureInPictureController: AVPictureInPictureController + ) { + updatePlayPauseButton() + updateProgressControls() + updateSeekingState() + showControls(animated: false) + + if shouldKeepControlsVisible { + stopControlsHideTimer() + } else { + scheduleControlsHide() + } + } + + 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 { + // Keep AVPlayer touches compatible with viewer gestures, but isolate visible controls from global gestures. + func gestureRecognizer( + _ gestureRecognizer: UIGestureRecognizer, + shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer + ) -> Bool { + 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 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 { + guard !isPictureInPictureActive else { + return false + } + + guard controlsVisible else { + return true + } + + let location = touch.location(in: view) + + return !controlsHitFramesContain(location) + } + + func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { + guard gestureRecognizer === closePanGesture else { + return true + } + + guard !isPictureInPictureActive else { + return false + } + + let velocity = closePanGesture?.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..f421911db5 --- /dev/null +++ b/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoAVPlayerViewControls.swift @@ -0,0 +1,255 @@ +// 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 { + + 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 { + updateViewerBackground(isChromeHidden: true) + setControlsVisible( + false, + animated: false + ) + setNavigationBarVisible( + false, + animated: false + ) + return + } + + updateViewerBackground(isChromeHidden: false) + + setNavigationBarVisible( + true, + animated: animated + ) + setControlsVisible( + true, + animated: animated + ) + } + + func hideControls(animated: Bool) { + guard !shouldKeepControlsVisible else { + showControls(animated: false) + stopControlsHideTimer() + return + } + + updateViewerBackground(isChromeHidden: true) + + 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 videoControlsDidTapSeekBackward(_ controlsView: NCVideoControlsView) { + seek(bySeconds: -10) + } + + func videoControlsDidTapPlayPause(_ controlsView: NCVideoControlsView) { + 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) { + seek(bySeconds: 10) + } + + 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/AVPlayer/NCVideoViewerContentView+AVPlayer.swift b/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoViewerContentView+AVPlayer.swift new file mode 100644 index 0000000000..fb6528ff10 --- /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(preparedPlayback: NCVideoAVPreparedPlayback) { + hasRequestedPlayback = true + presentAVPlayerIfSelected(preparedPlayback: preparedPlayback) + } + + @MainActor + func presentAVPlayerIfSelected(preparedPlayback: NCVideoAVPreparedPlayback) { + guard isSelected else { + return + } + + guard presentedAVPlayerURL != preparedPlayback.url else { + return + } + + presentedAVPlayerURL = preparedPlayback.url + + NCVideoAVPlayerPresenter.present( + metadata: metadata, + preparedPlayback: preparedPlayback, + userAgent: userAgent, + shouldAutoPlayOnStart: 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/NCVideoControlsView.swift b/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoControlsView.swift new file mode 100644 index 0000000000..e6e7dc9452 --- /dev/null +++ b/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoControlsView.swift @@ -0,0 +1,785 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Marino Faggiana +// SPDX-License-Identifier: GPL-3.0-or-later + +import AVKit +import SwiftUI +import UIKit + +// MARK: - Video Controls View Delegate + +protocol NCVideoControlsViewDelegate: AnyObject { + func videoControlsDidTapSeekBackward(_ controlsView: NCVideoControlsView) + func videoControlsDidTapPlayPause(_ controlsView: NCVideoControlsView) + func videoControlsDidTapSeekForward(_ controlsView: NCVideoControlsView) + func videoControlsDidTapPictureInPicture(_ 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 { + func videoControlsDidTapPictureInPicture(_ controlsView: NCVideoControlsView) { } + + func videoControlsDidTapAddExternalSubtitle(_ controlsView: NCVideoControlsView) { } + + func videoControls(_ controlsView: NCVideoControlsView, didSelectSubtitleTrackIndex index: Int32) { } + + func videoControls(_ controlsView: NCVideoControlsView, didSelectAudioTrackIndex index: Int32) { } +} + +// MARK: - Video Controls Top Actions Mode + +enum NCVideoControlsTopActionsMode: Equatable { + case none + case pictureInPicture + case vlcTracks +} + +// MARK: - Video Track Menu Item + +struct NCVideoTrackMenuItem: Identifiable, Equatable { + let index: Int32 + let title: String + let isSelected: Bool + + var id: Int32 { + index + } +} + +// MARK: - Video Controls View + +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 = 45 + fileprivate static let bottomControlsHorizontalInset: 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 + 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 + + func updatePlayPauseButton(isPlaying: Bool) { + state.isPlaying = isPlaying + updateHostedView() + } + + func updateProgress( + progress: Float, + elapsedText: String, + remainingText: String + ) { + state.progress = max(0, min(1, progress)) + state.elapsedText = elapsedText + state.remainingText = remainingText + updateHostedView() + } + + func setSeekingEnabled(_ isEnabled: Bool) { + state.isSeekingEnabled = isEnabled + updateHostedView() + } + + func setPictureInPictureVisible(_ isVisible: Bool) { + setTopActionsMode(isVisible ? .pictureInPicture : .none) + } + + func setVLCTrackControlsVisible(_ isVisible: Bool) { + setTopActionsMode(isVisible ? .vlcTracks : .none) + } + + 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, hasTrackItems { + state.subtitleTrackItems = [] + state.audioTrackItems = [] + didResetTrackItems = true + } + + guard didChangeMode || didResetTrackItems else { + return + } + + updateHostedView() + } + + func setSubtitleTrackMenuItems(_ items: [NCVideoTrackMenuItem]) { + guard state.subtitleTrackItems != items else { + return + } + + state.subtitleTrackItems = items + updateHostedView() + } + + func setAudioTrackMenuItems(_ items: [NCVideoTrackMenuItem]) { + guard state.audioTrackItems != items else { + return + } + + state.audioTrackItems = items + updateHostedView() + } + + // Keeps top actions aligned below the real navigation bar. + 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 + 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) + }, + 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 onSubtitleTrackSelected: (_ index: Int32) -> Void + let onAddExternalSubtitle: () -> Void + let onAudioTrackSelected: (_ index: Int32) -> Void + + @State private var currentScrubProgress: Double? + + 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 = 2 + 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: { + 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 { + let progress = Float(currentScrubProgress ?? Double(state.progress)) + currentScrubProgress = nil + onScrubEnded(progress) + } + } + ) + .disabled(!state.isSeekingEnabled) + .tint(.gray) + .opacity(state.isSeekingEnabled ? 1 : 0.45) + + timeLabel(state.remainingText) + .frame(width: 58) + } + .padding(.horizontal, 18) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .controlGlassBackground(shape: Capsule()) + .contentShape(Capsule()) + } + + private var topActions: some View { + HStack(spacing: NCVideoControlsView.topActionsSpacing) { + switch state.topActionsMode { + case .none: + EmptyView() + + case .pictureInPicture: + Button(action: onPictureInPicture) { + topActionIcon( + systemName: "pip.enter", + pointSize: 18 + ) + } + .buttonStyle(.plain) + + NCVideoAirPlayRoutePickerView() + .frame( + width: NCVideoControlsView.topActionsButtonSize, + height: NCVideoControlsView.topActionsButtonSize + ) + .videoControlIconShadow() + .controlGlassBackground(shape: Circle()) + + case .vlcTracks: + subtitleActionMenu( + systemName: "captions.bubble", + pointSize: 17, + items: state.subtitleTrackItems, + emptyTitle: "_no_subtitles_available_", + onSelect: onSubtitleTrackSelected, + onAddExternalSubtitle: onAddExternalSubtitle + ) + + topActionMenu( + systemName: "speaker.wave.2", + pointSize: 17, + items: state.audioTrackItems, + emptyTitle: "_no_audio_tracks_available_", + onSelect: onAudioTrackSelected + ) + } + } + } + + private func subtitleActionMenu( + systemName: String, + pointSize: CGFloat, + items: [NCVideoTrackMenuItem], + emptyTitle: String, + 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 topActionMenu( + systemName: String, + pointSize: CGFloat, + items: [NCVideoTrackMenuItem], + emptyTitle: String, + 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(.white) + .videoControlIconShadow() + .frame( + width: NCVideoControlsView.topActionsButtonSize, + height: NCVideoControlsView.topActionsButtonSize + ) + .controlGlassBackground(shape: Circle()) + } + + 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(.white) + .videoControlIconShadow() + .frame(width: size, height: size) + .controlGlassBackground(shape: Circle()) + } + .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(.gray) + .videoControlIconShadow() + .lineLimit(1) + .minimumScaleFactor(0.85) + } +} + +// MARK: - AirPlay Route Picker + +private struct NCVideoAirPlayRoutePickerView: UIViewRepresentable { + func makeUIView(context: Context) -> AVRoutePickerView { + let routePickerView = AVRoutePickerView() + routePickerView.backgroundColor = .clear + routePickerView.tintColor = .white + routePickerView.activeTintColor = .white + routePickerView.prioritizesVideoDevices = true + return routePickerView + } + + func updateUIView( + _ uiView: AVRoutePickerView, + context: Context + ) { } +} + +private extension View { + @ViewBuilder + func controlGlassBackground( + shape: BackgroundShape + ) -> some View { + if #available(iOS 26.0, *) { + self + .glassEffect(.regular, in: shape) + .overlay { + shape + .stroke(.white.opacity(0.58), lineWidth: 1.2) + } + .overlay { + shape + .stroke(.white.opacity(0.20), lineWidth: 4) + .blur(radius: 2) + .mask(shape) + } + .shadow( + color: .black.opacity(0.18), + radius: 14, + x: 0, + y: 4 + ) + } else { + self + .background(.white.opacity(0.92)) + .clipShape(shape) + } + } +} + +private extension View { + func videoControlIconShadow() -> some View { + shadow( + color: .black.opacity(0.5), + radius: 2.5, + x: 0, + y: 1 + ) + } +} + +// 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 = .white + 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 + controlsView.setTopActionsMode(.pictureInPicture) + controlsView.updatePlayPauseButton(isPlaying: true) + controlsView.updateProgress( + progress: 0.42, + elapsedText: "1:24", + remainingText: "−2:31" + ) + controlsView.setSubtitleTrackMenuItems([ + NCVideoTrackMenuItem(index: -1, title: NSLocalizedString("_disable_", comment: ""), 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(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), + 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..50cda8d7a9 --- /dev/null +++ b/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoPlaybackController.swift @@ -0,0 +1,382 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Marino Faggiana +// SPDX-License-Identifier: GPL-3.0-or-later + +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(preparedPlayback: NCVideoAVPreparedPlayback) + case vlc(preparedPlayback: NCVideoVLCPreparedPlayback) + case failed(message: String) +} + +// MARK: - Video Playback Controller + +// Resolves AVFoundation playback or VLC fallback for video pages. +@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 currentOcId: String? + private var currentEtag: String? + private var currentURL: URL? + private var currentFileName: String? + private var loadToken = UUID() + + private init() { } + + // MARK: - Public API + + func isCurrentVideo( + ocId: String, + etag: String, + url: URL + ) -> Bool { + currentOcId == ocId && + currentEtag == etag && + currentURL == url + } + // Used for remote videos before the final playback URL is known. + func isCurrentVideo( + ocId: String, + etag: String + ) -> Bool { + currentOcId == ocId && + currentEtag == etag && + currentURL != nil + } + // Reuses the current player when the requested video is already loaded. + func loadVideo( + metadata: tableMetadata, + url: URL, + fileName: String, + userAgent: String?, + httpHeaders: [String: String] + ) { + if isSameLoadedVideo( + metadata: metadata, + url: url + ) { + 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: "") + return + } + + configureAudioSession() + + if shouldUseVLCWithoutAVFoundation( + url: url, + fileName: fileName + ) { + resolveWithVLC( + url: url, + userAgent: userAgent, + token: token + ) + return + } + + prepareAVFoundation( + metadata: metadata, + url: url, + userAgent: userAgent, + httpHeaders: url.isFileURL ? [:] : httpHeaders, + token: token + ) + } + + func stopIfCurrent(ocId: String) { + guard currentOcId == ocId else { + return + } + + stop() + } + // Releases the current prepared playback state and pending AVFoundation probes. + func stop() { + loadToken = UUID() + + statusObservation?.invalidate() + statusObservation = nil + + avProbePlayer?.pause() + avProbePlayer = nil + avProbeItem = nil + + currentOcId = nil + currentEtag = nil + currentURL = nil + currentFileName = nil + + engine = .loading + } + + // MARK: - AVFoundation + + private func prepareAVFoundation( + metadata: tableMetadata, + url: URL, + userAgent: String?, + httpHeaders: [String: String], + 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, + item: item, + token: token + ) + + case .failed: + self.resolveWithVLC( + url: url, + userAgent: userAgent, + token: token + ) + + case .unknown: + break + + @unknown default: + self.resolveWithVLC( + url: url, + userAgent: userAgent, + token: token + ) + } + } + } + } + + private func resolveWithAVFoundation( + url: URL, + player: AVPlayer, + item: AVPlayerItem, + token: UUID + ) { + guard loadToken == token, + avProbePlayer === player, + avProbeItem === item else { + return + } + + 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( + url: url, + token: token + ) else { + return + } + + statusObservation?.invalidate() + statusObservation = nil + + avProbePlayer?.pause() + avProbePlayer = nil + avProbeItem = nil + + 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 + + private func isSameLoadedVideo( + metadata: tableMetadata, + url: URL + ) -> Bool { + currentOcId == metadata.ocId && + currentEtag == metadata.etag && + currentURL == url + } + + private func isCurrentLoad( + url: URL, + token: UUID + ) -> Bool { + loadToken == token && currentURL == url + } + + // MARK: - Private Helpers + + 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 + ) + } + } + + // Legacy formats go directly to VLC. + 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) + } + + private func resolvedVideoExtension( + url: URL, + fileName: String + ) -> String { + let metadataExtension = URL(fileURLWithPath: fileName) + .pathExtension + .lowercased() + + if !metadataExtension.isEmpty { + return metadataExtension + } + + return url.pathExtension.lowercased() + } + + 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/NCVideoPlaybackCoverView.swift b/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoPlaybackCoverView.swift new file mode 100644 index 0000000000..2742e27866 --- /dev/null +++ b/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoPlaybackCoverView.swift @@ -0,0 +1,147 @@ +// 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 ? .white : .black.opacity(0.35)) + .videoControlIconShadow() + .frame(width: 62, height: 62) + .coverPlayButtonBackground(isEnabled: isPlayEnabled) + } + .disabled(!isPlayEnabled || isLaunchingPlayback) + .opacity(isLaunchingPlayback ? 0 : 1) + .scaleEffect(isLaunchingPlayback ? 1.12 : 1) + .animation(.easeInOut(duration: 0.14), value: isLaunchingPlayback) + .accessibilityLabel(Text(NSLocalizedString("_play_", comment: ""))) + } + } +} + +private extension View { + @ViewBuilder + func coverPlayButtonBackground(isEnabled: Bool) -> some View { + if #available(iOS 26.0, *) { + self + .glassEffect(.regular, in: .circle) + .overlay { + Circle() + .stroke(.white.opacity(0.58), lineWidth: 1.2) + } + .overlay { + Circle() + .stroke(.white.opacity(0.20), lineWidth: 4) + .blur(radius: 2) + .mask(Circle()) + } + .shadow( + color: .black.opacity(0.18), + radius: 14, + x: 0, + y: 4 + ) + } else { + self + .background(.white.opacity(isEnabled ? 0.92 : 0.45)) + .clipShape(Circle()) + } + } +} + +private extension View { + func videoControlIconShadow() -> some View { + shadow( + color: .black.opacity(0.5), + radius: 2.5, + x: 0, + y: 1 + ) + } +} + +#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 + } + } +} 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 new file mode 100644 index 0000000000..0bb3b9514c --- /dev/null +++ b/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoViewerContentView.swift @@ -0,0 +1,531 @@ +// 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 + +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 var presentedAVPlayerURL: URL? + @State var presentedVLCURL: URL? + @State var hasRequestedPlayback = false + @State var isLaunchingPlayback = false + @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, + 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 { + videoBackgroundColor + .ignoresSafeArea() + + contentView + } + .background(videoBackgroundColor) + .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 { + // 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 + } + } + + @ViewBuilder + var requestedPlaybackView: some View { + switch playback.engine { + case .loading: + videoBackgroundColor + .ignoresSafeArea() + .allowsHitTesting(false) + + case .avFoundation(let preparedPlayback): + if isSelected, + isCurrentPlaybackVideo() { + playbackPresentationPlaceholder( + url: preparedPlayback.url, + onURLChanged: { _ in + presentedAVPlayerURL = nil + presentAVPlayerIfSelected(preparedPlayback: preparedPlayback) + }, + onSelectionRestored: { + presentAVPlayerIfSelected(preparedPlayback: preparedPlayback) + } + ) + } else { + EmptyView() + } + + case .vlc(let preparedPlayback): + if isSelected, + isCurrentPlaybackVideo() { + playbackPresentationPlaceholder( + url: preparedPlayback.url, + onURLChanged: { _ in + presentedVLCURL = nil + presentVLCIfSelected(preparedPlayback: preparedPlayback) + }, + onSelectionRestored: { + presentVLCIfSelected(preparedPlayback: preparedPlayback) + } + ) + } 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)) + + Text(NSLocalizedString("_video_not_available_", comment: "")) + .font(.headline) + } + .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 + + case .loading, + .failed: + return false + } + } + + @MainActor + func playFromCover() { + guard isPlaybackCoverPlayEnabled, + !isLaunchingPlayback else { + return + } + + isLaunchingPlayback = true + + switch playback.engine { + case .avFoundation(let preparedPlayback): + requestAVPlayerPresentation(preparedPlayback: preparedPlayback) + + case .vlc(let preparedPlayback): + requestVLCPresentation(preparedPlayback: preparedPlayback) + + case .loading, + .failed: + isLaunchingPlayback = false + } + } + + 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)" + } + + @MainActor + func stopPlaybackForDeselection() { + resetPlaybackPresentationState() + + NCVideoAVPlayerPresenter.dismiss() + NCVideoVLCPresenter.dismiss() + playback.stop() + } + + @MainActor + func loadVideoIfSelected() async { + let expectedTaskIdentifier = taskIdentifier + let expectedLoadGeneration = loadGeneration + + guard isStableSelection( + expectedTaskIdentifier: expectedTaskIdentifier, + expectedLoadGeneration: expectedLoadGeneration + ) else { + return + } + + errorMessage = nil + + if isCurrentPlaybackVideo() { + revealCurrentPlaybackIfNeeded() + return + } + + await resolveAndLoadVideo( + expectedTaskIdentifier: expectedTaskIdentifier, + expectedLoadGeneration: expectedLoadGeneration + ) + } + + @MainActor + func isStableSelection( + expectedTaskIdentifier: String, + expectedLoadGeneration: UUID + ) -> Bool { + guard !Task.isCancelled else { + return false + } + + guard isSelected else { + return false + } + + guard expectedTaskIdentifier == taskIdentifier else { + return false + } + + guard expectedLoadGeneration == loadGeneration else { + return false + } + + return true + } + + @MainActor + func resolveAndLoadVideo( + expectedTaskIdentifier: String, + expectedLoadGeneration: UUID + ) async { + errorMessage = nil + + if let localURL { + loadResolvedVideo( + url: localURL, + expectedTaskIdentifier: expectedTaskIdentifier, + expectedLoadGeneration: expectedLoadGeneration + ) + return + } + + let result = await resolvedVideoURL( + taskIdentifier: expectedTaskIdentifier + ) + + guard !Task.isCancelled else { + return + } + + guard expectedTaskIdentifier == taskIdentifier else { + return + } + + guard expectedLoadGeneration == loadGeneration else { + return + } + + guard isSelected else { + return + } + + guard result.error == .success, + let url = result.url else { + errorMessage = "" + return + } + + loadResolvedVideo( + url: url, + expectedTaskIdentifier: expectedTaskIdentifier, + expectedLoadGeneration: expectedLoadGeneration + ) + } + + @MainActor + func loadResolvedVideo( + url: URL, + expectedTaskIdentifier: String, + expectedLoadGeneration: UUID + ) { + guard expectedTaskIdentifier == taskIdentifier else { + return + } + + guard expectedLoadGeneration == loadGeneration else { + return + } + + guard isSelected else { + return + } + + hasRequestedPlayback = false + + playback.loadVideo( + metadata: metadata, + url: url, + fileName: resolvedFileName, + userAgent: userAgent, + httpHeaders: httpHeaders(for: url) + ) + } + + 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 + +private extension NCVideoViewerContentView { + 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 + ) + } + + @MainActor + func revealCurrentPlaybackIfNeeded() { + guard hasRequestedPlayback else { + return + } + + switch playback.engine { + case .avFoundation(let preparedPlayback): + presentAVPlayerIfSelected(preparedPlayback: preparedPlayback) + + case .vlc(let preparedPlayback): + presentVLCIfSelected(preparedPlayback: preparedPlayback) + + case .loading, + .failed: + break + } + } +} + +// MARK: - Fullscreen Playback State + +extension NCVideoViewerContentView { + @MainActor + func closeFromFullscreenVideo(ocId: String?) { + onClose?(ocId) + } + + @MainActor + func resetPlaybackPresentationState() { + presentedAVPlayerURL = nil + presentedVLCURL = nil + hasRequestedPlayback = false + isLaunchingPlayback = false + } + + @MainActor + func performFullscreenPageTransition( + dismissPlayer: @escaping () -> Void, + changePage: @escaping () -> Void + ) { + resetPlaybackPresentationState() + dismissPlayer() + changePage() + } +} + +// MARK: - URL Resolution + +private extension NCVideoViewerContentView { + @MainActor + 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 + +private extension NCVideoViewerContentView { + var resolvedFileName: String { + if !metadata.fileNameView.isEmpty { + return metadata.fileNameView + } + + return metadata.fileName + } +} 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..d03666ac88 --- /dev/null +++ b/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCPresenter.swift @@ -0,0 +1,195 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Marino Faggiana +// SPDX-License-Identifier: GPL-3.0-or-later + +import UIKit +import NextcloudKit + +// MARK: - VLC Presenter +@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 or updates the single VLC fullscreen controller. + static func present( + metadata: tableMetadata, + preparedPlayback: NCVideoVLCPreparedPlayback, + userAgent: String?, + shouldAutoPlayOnStart: Bool = true, + isChromeHidden: Bool = false, + contextMenuController: NCMainTabBarController?, + canGoPrevious: Bool = false, + canGoNext: Bool = false, + onPrevious: (() -> Void)? = nil, + onNext: (() -> Void)? = nil, + onClose: ((_ ocId: String?) -> Void)? = nil + ) { + let url = preparedPlayback.url + if currentURL == url, + let currentViewController { + currentViewController.update( + metadata: metadata, + preparedPlayback: preparedPlayback, + userAgent: userAgent, + shouldAutoPlayOnStart: shouldAutoPlayOnStart, + isChromeHidden: isChromeHidden, + contextMenuController: contextMenuController + ) + currentViewController.onPrevious = onPrevious + currentViewController.onNext = onNext + currentViewController.onClose = onClose + currentViewController.canGoPrevious = canGoPrevious + currentViewController.canGoNext = canGoNext + return + } + + if isPresenting { + return + } + + if let currentViewController { + currentViewController.update( + metadata: metadata, + preparedPlayback: preparedPlayback, + userAgent: userAgent, + shouldAutoPlayOnStart: shouldAutoPlayOnStart, + isChromeHidden: isChromeHidden, + 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, + preparedPlayback: preparedPlayback, + userAgent: userAgent, + shouldAutoPlayOnStart: shouldAutoPlayOnStart, + isChromeHidden: isChromeHidden, + 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.navigationBar.prefersLargeTitles = false + navigationController.navigationBar.barStyle = .black + navigationController.navigationBar.tintColor = .white + navigationController.navigationBar.titleTextAttributes = [ + .foregroundColor: UIColor.white + ] + + presenter.present( + navigationController, + animated: false + ) { + isPresenting = false + } + } + + static func clearCurrent( + _ viewController: NCVideoVLCViewController + ) { + guard currentViewController === viewController else { + return + } + + currentViewController = nil + currentURL = nil + isPresenting = false + } + + static func dismissCurrent() { + guard let currentViewController else { + return + } + + currentViewController.dismiss(animated: false) { + clearCurrent(currentViewController) + } + } + + static func dismiss() { + dismissCurrent() + } + + // MARK: - Private + 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) + } + + 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..6a44654276 --- /dev/null +++ b/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCViewController.swift @@ -0,0 +1,978 @@ +// 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 + +final class NCVideoVLCViewController: UIViewController { + + // MARK: - Input + + private var metadata: tableMetadata + private var preparedPlayback: NCVideoVLCPreparedPlayback + private var url: URL + private var userAgent: String? + private var shouldAutoPlayOnStart: Bool + private var isChromeHidden: Bool + 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() + internal let controlsView = NCVideoControlsView() + + private let floatingTitleView = NCMediaViewerFloatingTitleView() + + 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 isPlaybackRequested = false + private weak var closePanGesture: UIPanGestureRecognizer? + + internal var shouldKeepControlsVisible: Bool { + mediaPlayer.state != .playing && !mediaPlayer.isPlaying && !isPlaybackRequested + } + + internal func setNavigationBarVisible( + _ isVisible: Bool, + animated: Bool + ) { + navigationController?.setNavigationBarHidden( + !isVisible, + animated: animated + ) + } + + // MARK: - Navigation Items + + 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( + named: "info.circle", + colors: [NCBrandColor.shared.iconImageColor] + ), + style: .plain, + target: self, + action: #selector(mediaDetailButtonTapped) + ) + + // MARK: - Init + + init( + metadata: tableMetadata, + preparedPlayback: NCVideoVLCPreparedPlayback, + userAgent: String?, + shouldAutoPlayOnStart: Bool = true, + isChromeHidden: Bool = false, + contextMenuController: NCMainTabBarController? + ) { + self.metadata = metadata + self.preparedPlayback = preparedPlayback + self.url = preparedPlayback.url + self.userAgent = userAgent + self.shouldAutoPlayOnStart = shouldAutoPlayOnStart + self.isChromeHidden = isChromeHidden + self.contextMenuController = contextMenuController + + super.init( + nibName: nil, + bundle: nil + ) + + modalPresentationStyle = .fullScreen + } + + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + deinit { + stopControlsHideTimer() + mediaPlayer.delegate = nil + stop() + } + + // MARK: - Lifecycle + + override func loadView() { + let backgroundColor = viewerBackgroundColor + + let rootView = UIView() + rootView.backgroundColor = backgroundColor + rootView.isOpaque = true + rootView.clipsToBounds = true + + drawableView.backgroundColor = backgroundColor + drawableView.isOpaque = true + drawableView.clipsToBounds = true + drawableView.translatesAutoresizingMaskIntoConstraints = false + + controlsView.delegate = self + controlsView.setTopActionsMode(.vlcTracks) + controlsView.alpha = 0 + controlsView.isHidden = true + controlsView.translatesAutoresizingMaskIntoConstraints = false + + rootView.addSubview(drawableView) + 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), + + 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 = viewerBackgroundColor + + 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 + + func update( + metadata: tableMetadata, + preparedPlayback: NCVideoVLCPreparedPlayback, + userAgent: String?, + shouldAutoPlayOnStart: Bool = true, + isChromeHidden: Bool = false, + contextMenuController: NCMainTabBarController? + ) { + let urlChanged = self.url != preparedPlayback.url + + if urlChanged { + stop() + self.preparedPlayback = preparedPlayback + self.url = preparedPlayback.url + } + + self.metadata = metadata + self.userAgent = userAgent + self.shouldAutoPlayOnStart = shouldAutoPlayOnStart + self.isChromeHidden = isChromeHidden + self.contextMenuController = contextMenuController + updateViewerBackgroundIfNeeded() + updateTitleLabel(metadata: metadata) + refreshVLCTrackMenuItemsWhenPlayerIsActive() + + refreshMoreMenu() + + if urlChanged { + start() + } + + 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() { + 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 + ] + } + + private func configureFloatingTitleViewIfNeeded() { + guard let navigationBar = navigationController?.navigationBar else { + return + } + + floatingTitleView.attach(to: navigationBar) + } + + private func updateTitleLabel(metadata: tableMetadata) { + let primaryTitle = metadata.fileNameView.isEmpty + ? metadata.fileName + : metadata.fileNameView + + floatingTitleView.update( + primaryText: primaryTitle, + secondaryText: floatingTitleDateFormatter.string(from: metadata.date as Date), + textColor: .white + ) + } + + private func refreshMoreMenu() { + moreNavigationItem.menu = makeMoreMenu(sender: moreNavigationItem) + } + + // 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 { + completion([]) + return + } + + if let menu = NCContextMenuViewer( + metadata: self.metadata, + controller: self.contextMenuController, + viewController: self, + webView: false, + sender: sender + ).viewMenu() { + completion(menu.children) + } else { + completion([]) + } + } + ]) + } + + @objc + private func closeTapped() { + close() + } + + @objc + private func mediaDetailButtonTapped() { + presentDetailView(animated: true) + } + + 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() { + let closeCallback = onClose + let closingOcId = metadata.ocId + let controllerToDismiss = navigationController ?? self + + NCVideoVLCPresenter.clearCurrent(self) + + controllerToDismiss.dismiss(animated: false) { [weak self] in + self?.stopControlsHideTimer() + self?.stopProgressTimer() + self?.stop() + + DispatchQueue.main.async { + closeCallback?(closingOcId) + } + } + } + + func closeImmediately() { + let closeCallback = onClose + let controllerToDismiss = navigationController ?? self + + NCVideoVLCPresenter.clearCurrent(self) + + controllerToDismiss.dismiss(animated: false) { [weak self] in + self?.stopControlsHideTimer() + self?.stopProgressTimer() + self?.stop() + + DispatchQueue.main.async { + closeCallback?(nil) + } + } + } + + // MARK: - Swipe Navigation + + 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 + self.closePanGesture = closePanGesture + + view.addGestureRecognizer(swipeLeft) + view.addGestureRecognizer(swipeRight) + view.addGestureRecognizer(closePanGesture) + } + + private func configureTapGesture() { + let tapGesture = UITapGestureRecognizer( + target: self, + action: #selector(handleSingleTap(_:)) + ) + tapGesture.numberOfTapsRequired = 1 + tapGesture.cancelsTouchesInView = false + tapGesture.delegate = self + view.addGestureRecognizer(tapGesture) + } + + // Keep controls visible when playback is not running. + @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() + } + } + + @objc + private func handleSwipe(_ gesture: UISwipeGestureRecognizer) { + switch gesture.direction { + case .left: + guard canGoNext else { + return + } + onNext?() + + case .right: + guard canGoPrevious else { + return + } + onPrevious?() + + default: + break + } + } + + // Close only when downward movement wins over horizontal paging. + @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 + + private func start() { + isPlaybackRequested = shouldAutoPlayOnStart + attachDrawable() + + mediaPlayer.media = preparedPlayback.media + updatePlayPauseButton() + + if shouldAutoPlayOnStart { + mediaPlayer.play() + } + + updatePlayPauseButton() + updateProgressControls() + clearVLCTrackMenuItems() + startProgressTimer() + showControls(animated: false) + stopControlsHideTimer() + } + + private func stop() { + isPlaybackRequested = false + + mediaPlayer.stop() + mediaPlayer.media = nil + mediaPlayer.drawable = nil + externalSubtitleURL = nil + stopProgressTimer() + updatePlayPauseButton() + updateProgressControls() + clearVLCTrackMenuItems() + } + + private func attachDrawable() { + guard drawableView.bounds.width > 0, + drawableView.bounds.height > 0 else { + return + } + + if let currentDrawable = mediaPlayer.drawable as? UIView, + currentDrawable === drawableView { + return + } + + mediaPlayer.drawable = drawableView + } + + 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 { + if !isPlaybackRequested { + showControls(animated: false) + stopControlsHideTimer() + } + return + } + + scheduleControlsHideIfNeededAfterPlaybackStart() + } + + // Safe to call from both state and time callbacks. + private func scheduleControlsHideIfNeededAfterPlaybackStart() { + guard !shouldKeepControlsVisible else { + return + } + + guard controlsVisible else { + return + } + + guard controlsHideTimer == nil else { + return + } + + scheduleControlsHide() + } + + // MARK: - VLC Track Menus + + func refreshVLCTrackMenuItems() { + controlsView.setSubtitleTrackMenuItems(makeSubtitleTrackMenuItems()) + controlsView.setAudioTrackMenuItems(makeAudioTrackMenuItems()) + } + + func clearVLCTrackMenuItems() { + controlsView.setSubtitleTrackMenuItems([]) + controlsView.setAudioTrackMenuItems([]) + } + + func refreshVLCTrackMenuItemsWhenPlayerIsActive() { + switch mediaPlayer.state { + case .opening, .buffering, .playing, .paused: + refreshVLCTrackMenuItems() + default: + clearVLCTrackMenuItems() + } + } + + func selectSubtitleTrack(index: Int32) { + mediaPlayer.currentVideoSubTitleIndex = index + + NCManageDatabase.shared.addVideo( + metadata: metadata, + currentVideoSubTitleIndex: Int(index) + ) + + 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) + ) + + Task { @MainActor [weak self] in + try? await Task.sleep(for: .milliseconds(200)) + self?.refreshVLCTrackMenuItemsWhenPlayerIsActive() + } + } + + func presentExternalSubtitlePicker() { + let picker = UIDocumentPickerViewController( + forOpeningContentTypes: [.item], + asCopy: true + ) + picker.delegate = self + picker.allowsMultipleSelection = false + present(picker, animated: true) + } + + private func isSupportedExternalSubtitleURL(_ url: URL) -> Bool { + let supportedExtensions: Set = [ + "srt", + "vtt", + "ass", + "ssa", + "sub" + ] + + return supportedExtensions.contains(url.pathExtension.lowercased()) + } + + 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 + ) + } + } + + // Copy to a stable temporary file readable 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 + } + + private func refreshExternalSubtitleTracksAfterLoad() { + refreshVLCTrackMenuItems() + + Task { @MainActor [weak self] in + try? await Task.sleep(for: .milliseconds(250)) + self?.refreshVLCTrackMenuItems() + } + } + + private func makeSubtitleTrackMenuItems() -> [NCVideoTrackMenuItem] { + makeTrackMenuItems( + titles: mediaPlayer.videoSubTitlesNames, + indexes: mediaPlayer.videoSubTitlesIndexes, + currentIndex: currentSubtitleTrackIndex() + ) + } + + private func makeAudioTrackMenuItems() -> [NCVideoTrackMenuItem] { + makeTrackMenuItems( + titles: mediaPlayer.audioTrackNames, + indexes: mediaPlayer.audioTrackIndexes, + currentIndex: currentAudioTrackIndex() + ) + } + + private func currentSubtitleTrackIndex() -> Int? { + let playerIndex = Int(mediaPlayer.currentVideoSubTitleIndex) + + if playerIndex >= 0 { + return playerIndex + } + + return NCManageDatabase.shared.getVideo(metadata: metadata)?.currentVideoSubTitleIndex + } + + private func currentAudioTrackIndex() -> Int? { + let playerIndex = Int(mediaPlayer.currentAudioTrackIndex) + + if playerIndex >= 0 { + return playerIndex + } + + return NCManageDatabase.shared.getVideo(metadata: metadata)?.currentAudioTrackIndex + } + + 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) + ) + } + } + + 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 + + private func updateControlsNavigationBar() { + controlsView.setTopActionsNavigationBar(navigationController?.navigationBar) + } + + 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) + } + + 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 { + // Keep VLC drawable touches compatible with viewer gestures, but isolate visible controls from global gestures. + func gestureRecognizer( + _ gestureRecognizer: UIGestureRecognizer, + shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer + ) -> Bool { + 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 global viewer gestures disabled when visible controls receive the touch. + func gestureRecognizer( + _ gestureRecognizer: UIGestureRecognizer, + shouldReceive touch: UITouch + ) -> Bool { + guard controlsVisible else { + return true + } + + let location = touch.location(in: view) + + return !controlsHitFramesContain(location) + } + + func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { + guard gestureRecognizer === closePanGesture else { + return true + } + + let velocity = closePanGesture?.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 { + func documentPicker(_ controller: UIDocumentPickerViewController, didPickDocumentsAt urls: [URL]) { + guard let url = urls.first else { + return + } + + loadExternalSubtitle(url: url) + showControls(animated: true) + } + + 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..5ae0690174 --- /dev/null +++ b/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCViewControls.swift @@ -0,0 +1,254 @@ +import UIKit +import MobileVLCKit + +// MARK: - Playback Controls + +extension NCVideoVLCViewController { + 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() + } + + func updatePlayPauseButton() { + let isPlaying = mediaPlayer.isPlaying || + mediaPlayer.state == .opening || + mediaPlayer.state == .buffering || + mediaPlayer.state == .playing || + isPlaybackRequested + + controlsView.updatePlayPauseButton(isPlaying: isPlaying) + } + + func startProgressTimer() { + stopProgressTimer() + + progressTimer = Timer.scheduledTimer( + withTimeInterval: 0.35, + repeats: true + ) { [weak self] _ in + self?.updateProgressControls() + } + } + + func stopProgressTimer() { + progressTimer?.invalidate() + progressTimer = nil + } + + func updateProgressControls() { + guard !isScrubbing else { + return + } + + let position = max(0, min(1, mediaPlayer.position)) + updateProgressLabels(position: position) + updatePlayPauseButton() + } + + 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) + ) + } + + 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 { + internal func showControls(animated: Bool) { + setNavigationBarVisible( + true, + animated: animated + ) + controlsVisible = true + setControlsVisible(true, animated: 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) + } + + 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 + ) + } + + 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) + } + } + } + + internal func stopControlsHideTimer() { + controlsHideTimer?.invalidate() + controlsHideTimer = nil + } +} + +// MARK: - Shared Controls Delegate +extension NCVideoVLCViewController: NCVideoControlsViewDelegate { + func videoControlsDidTapSeekBackward(_ controlsView: NCVideoControlsView) { + showControls(animated: true) + scheduleControlsHide() + seek(byMilliseconds: -10_000) + } + + func videoControlsDidTapPlayPause(_ controlsView: NCVideoControlsView) { + showControls(animated: true) + + if mediaPlayer.isPlaying || mediaPlayer.state == .playing { + isPlaybackRequested = false + mediaPlayer.pause() + updatePlayPauseButton() + showControls(animated: false) + stopControlsHideTimer() + } else { + isPlaybackRequested = true + updatePlayPauseButton() + mediaPlayer.play() + scheduleControlsHide() + } + + updateProgressControls() + } + + func videoControlsDidTapSeekForward(_ controlsView: NCVideoControlsView) { + showControls(animated: true) + scheduleControlsHide() + seek(byMilliseconds: 10_000) + } + + func videoControlsDidBeginScrubbing(_ controlsView: NCVideoControlsView) { + showControls(animated: true) + stopControlsHideTimer() + isScrubbing = true + } + + func videoControlsDidTapSubtitle(_ controlsView: NCVideoControlsView) { + showControls(animated: true) + stopControlsHideTimer() + refreshVLCTrackMenuItemsWhenPlayerIsActive() + } + + func videoControlsDidTapAudio(_ controlsView: NCVideoControlsView) { + showControls(animated: true) + stopControlsHideTimer() + refreshVLCTrackMenuItemsWhenPlayerIsActive() + } + + func videoControlsDidTapAddExternalSubtitle(_ controlsView: NCVideoControlsView) { + showControls(animated: true) + stopControlsHideTimer() + presentExternalSubtitlePicker() + } + + func videoControls(_ controlsView: NCVideoControlsView, didSelectSubtitleTrackIndex index: Int32) { + showControls(animated: true) + stopControlsHideTimer() + selectSubtitleTrack(index: index) + } + + func videoControls(_ controlsView: NCVideoControlsView, didSelectAudioTrackIndex index: Int32) { + showControls(animated: true) + stopControlsHideTimer() + selectAudioTrack(index: index) + } + + func videoControls(_ controlsView: NCVideoControlsView, didScrubTo progress: Float) { + updateProgressLabels(position: progress) + } + + func videoControlsDidEndScrubbing(_ controlsView: NCVideoControlsView, progress: Float) { + mediaPlayer.position = progress + isScrubbing = false + updateProgressControls() + scheduleControlsHide() + } +} 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..dd4e7aa5e8 --- /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(preparedPlayback: NCVideoVLCPreparedPlayback) { + hasRequestedPlayback = true + presentVLCIfSelected(preparedPlayback: preparedPlayback) + } + + @MainActor + func presentVLCIfSelected(preparedPlayback: NCVideoVLCPreparedPlayback) { + guard isSelected else { + return + } + + guard presentedVLCURL != preparedPlayback.url else { + return + } + + presentedVLCURL = preparedPlayback.url + + NCVideoVLCPresenter.present( + metadata: metadata, + preparedPlayback: preparedPlayback, + userAgent: userAgent, + shouldAutoPlayOnStart: 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 new file mode 100644 index 0000000000..c7e48076ee --- /dev/null +++ b/iOSClient/Viewer/NCViewerMedia/Core/NCMediaViewerModel.swift @@ -0,0 +1,1158 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Marino Faggiana +// SPDX-License-Identifier: GPL-3.0-or-later + +import Foundation +import NextcloudKit + +// MARK: - Page State + +enum NCMediaViewerPageState { + case idle + case loadingMetadata + case metadataMissing + case checkingLocalFile + case image(previewURL: URL?, localURL: URL?, livePhotoURL: URL?, progress: Double?) + case audio(localURL: URL, previewURL: URL?) + case video(localURL: URL?, previewURL: URL?) + case downloading(previewURL: URL?, progress: Double?) + case ready(localURL: URL, previewURL: URL?) + case deleted + case failed(previewURL: URL?, message: String) +} + +// MARK: - Page Model + +struct NCMediaViewerPageModel: Identifiable { + let id: String + let index: Int + let ocId: String + var metadata: tableMetadata? + var state: NCMediaViewerPageState + + 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 + +struct NCMediaViewerInitialModel { + let currentMetadata: tableMetadata + let ocIds: [String] + + init( + currentMetadata: tableMetadata, + ocIds: [String] + ) { + self.currentMetadata = currentMetadata + self.ocIds = ocIds + } + + var normalizedOcIds: [String] { + if ocIds.contains(currentMetadata.ocId) { + return ocIds + } else { + return [currentMetadata.ocId] + ocIds + } + } + + var currentSelectedIndex: Int { + normalizedOcIds.firstIndex(of: currentMetadata.ocId) ?? 0 + } +} + +// MARK: - Loading Task Kind + +private enum NCMediaViewerLoadingTaskKind { + case selected + case prefetch +} + +// MARK: - Loading Task + +private struct NCMediaViewerLoadingTask { + let identifier: UUID + let kind: NCMediaViewerLoadingTaskKind + let task: Task +} + +// MARK: - Media Viewer Model + +// Coordinates media paging, loading, and prefetching. +@MainActor +final class NCMediaViewerModel: ObservableObject { + + // MARK: - Published State + + @Published private(set) var selectedIndex: Int + @Published private(set) var revision: Int = 0 + @Published private(set) var thumbnailReloadRevision: Int = 0 + @Published private(set) var isChromeHidden = false + @Published private(set) var autoPlayTargetIndex: Int? + + // MARK: - Dependencies + + private let loader: NCMediaViewerLoading + private let utilityFileSystem = NCUtilityFileSystem() + + // MARK: - Source Context + + private let session: NCSession.Session + + // MARK: - Source Data + + private let ocIds: [String] + + // MARK: - Page Cache + + // Lazy page cache keyed by ocId. + private var cachedPagesByOcId: [String: NCMediaViewerPageModel] = [:] + + // MARK: - Running Tasks + + private var loadingTasksByOcId: [String: NCMediaViewerLoadingTask] = [:] + + // MARK: - Public Read-Only Access + + var numberOfPages: Int { + ocIds.count + } + + var currentSelectedIndex: Int { + selectedIndex + } + + var selectedOcId: String? { + guard ocIds.indices.contains(selectedIndex) else { + return nil + } + + return ocIds[selectedIndex] + } + + var selectedMetadata: tableMetadata? { + guard ocIds.indices.contains(selectedIndex) else { + return nil + } + + let ocId = ocIds[selectedIndex] + return cachedPagesByOcId[ocId]?.metadata + } + + func ocId(at index: Int) -> String? { + guard ocIds.indices.contains(index) else { + return nil + } + + return ocIds[index] + } + + func metadataForThumbnail(at index: Int) -> tableMetadata? { + guard let ocId = ocId(at: index) else { + return nil + } + + return cachedPagesByOcId[ocId]?.metadata + } + + func requestAutoPlay(at index: Int) { + guard ocIds.indices.contains(index) else { + return + } + + autoPlayTargetIndex = index + revision &+= 1 + } + + func clearAutoPlayIfNeeded(for index: Int) { + guard autoPlayTargetIndex == index else { + return + } + + autoPlayTargetIndex = nil + revision &+= 1 + } + + @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 + ) + + updatePage(ocId: ocId) { page in + page.state = .deleted + } + } + + // MARK: - Init + + init( + initialModel: NCMediaViewerInitialModel, + session: NCSession.Session, + loader: NCMediaViewerLoading + ) { + self.loader = loader + self.session = session + self.ocIds = initialModel.normalizedOcIds + self.selectedIndex = initialModel.currentSelectedIndex + + let currentPage = NCMediaViewerPageModel( + index: initialModel.currentSelectedIndex, + ocId: initialModel.currentMetadata.ocId, + metadata: initialModel.currentMetadata, + state: .idle + ) + + cachedPagesByOcId[initialModel.currentMetadata.ocId] = currentPage + } + + convenience init( + currentMetadata: tableMetadata, + ocIds: [String], + session: NCSession.Session, + loader: NCMediaViewerLoading + ) { + let initialModel = NCMediaViewerInitialModel( + currentMetadata: currentMetadata, + ocIds: ocIds + ) + + self.init( + initialModel: initialModel, + session: session, + loader: loader + ) + } + + deinit { + loadingTasksByOcId.values.forEach { $0.task.cancel() } + loadingTasksByOcId.removeAll() + } + + // MARK: - Public API + + 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 + } + + func displayPage(at index: Int) async { + guard ocIds.indices.contains(index) else { + return + } + + if selectedIndex == index, + let ocId = ocId(at: index), + !pageState(for: ocId).needsSelectedPageLoading { + return + } + + selectedIndex = index + + prefetchNeighborPages(around: index) + await loadPageIfNeeded(index: index) + } + + func displayPreviewPage(at index: Int) async { + guard ocIds.indices.contains(index) else { + return + } + + guard selectedIndex != index else { + return + } + + selectedIndex = index + + let ocId = ocIds[index] + + guard let metadata = await resolvedMetadata(for: ocId) else { + return + } + + setThumbnailMetadata(metadata, for: ocId) + + let previewURL: URL? + + if let existingPreviewURL = currentPreviewURL(for: ocId) { + previewURL = existingPreviewURL + } else { + previewURL = await loader.previewURL( + for: metadata, + ext: NCGlobal.shared.previewExt1024 + ) + } + + guard let previewURL else { + return + } + + switch metadata.classFile { + case NKTypeClassFile.image.rawValue: + setState( + .image( + previewURL: previewURL, + localURL: nil, + livePhotoURL: nil, + progress: nil + ), + for: ocId + ) + + case NKTypeClassFile.video.rawValue: + setState( + .video( + localURL: nil, + previewURL: previewURL + ), + for: ocId + ) + + case NKTypeClassFile.audio.rawValue: + setState( + .downloading( + previewURL: previewURL, + progress: nil + ), + for: ocId + ) + + default: + break + } + } + + func selectedPageModel() -> NCMediaViewerPageModel? { + pageModel(at: selectedIndex) + } + + func loadSelectedPageIfNeeded() async { + prefetchNeighborPages(around: selectedIndex) + await loadPageIfNeeded(index: selectedIndex) + } + + 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 the page from the beginning, forcing a fresh metadata resolution before rebuilding the preview and media state. + /// - Parameter index: The index of the page that must be reloaded. + 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.metadata = nil + page.state = .idle + } + + thumbnailReloadRevision &+= 1 + + await loadPage( + index: index, + forceMetadataReload: true + ) + } + + func cancelLoading(index: Int) { + guard ocIds.indices.contains(index) else { + return + } + + let ocId = ocIds[index] + + loadingTasksByOcId[ocId]?.task.cancel() + loadingTasksByOcId[ocId] = nil + } + + func setSelectedIndex(_ index: Int) { + guard ocIds.indices.contains(index) else { + return + } + + guard selectedIndex != index else { + return + } + + selectedIndex = index + } + + func prefetchVisiblePageIfNeeded(index: Int) async { + guard ocIds.indices.contains(index) else { + return + } + + await prefetchPageIfNeeded(index: index) + prefetchNeighborPages(around: index) + } + + func toggleChromeVisibility() { + isChromeHidden.toggle() + } + + func previewURL(for metadata: tableMetadata, ext: String) async -> URL? { + await loader.previewURL(for: metadata, ext: ext) + } + + func localPreviewURL(for metadata: tableMetadata, ext: String) -> URL? { + let localPath = utilityFileSystem.getDirectoryProviderStorageImageOcId( + metadata.ocId, + etag: metadata.etag, + ext: ext, + userId: metadata.userId, + urlBase: metadata.urlBase + ) + + guard FileManager.default.fileExists(atPath: localPath) else { + return nil + } + + return URL(fileURLWithPath: localPath) + } + + func resolveMetadataForThumbnail(at index: Int) async -> tableMetadata? { + guard let ocId = ocId(at: index) else { + return nil + } + + if let existingMetadata = cachedPagesByOcId[ocId]?.metadata { + return existingMetadata + } + + guard let metadata = await resolvedMetadata(for: ocId) else { + return nil + } + + setThumbnailMetadata(metadata, for: ocId) + + return metadata + } + + func isThumbnailDeleted(at index: Int) -> Bool { + guard let ocId = ocId(at: index), + let page = cachedPagesByOcId[ocId] else { + return false + } + + if case .deleted = page.state { + return true + } + + return false + } + + // MARK: - Selected Page Loading + + private func loadPage( + index: Int, + forceMetadataReload: Bool = false + ) async { + guard ocIds.indices.contains(index) else { + return + } + + let ocId = ocIds[index] + let metadata = await resolvedMetadata( + for: ocId, + allowCached: !forceMetadataReload + ) + + guard !Task.isCancelled else { + return + } + + guard let metadata else { + setState(.metadataMissing, for: ocId) + return + } + + setMetadata(metadata, for: ocId) + + let previewURL = currentPreviewURL(for: ocId) + + if let localURL = await loader.localMediaURL(for: metadata) { + guard !Task.isCancelled else { + return + } + + await loadLocalPage( + metadata: metadata, + previewURL: previewURL, + localURL: localURL, + for: ocId, + index: index + ) + return + } + + guard !Task.isCancelled else { + return + } + + await loadRemotePage( + metadata: metadata, + previewURL: previewURL, + for: ocId, + index: index + ) + } + + private func loadLocalPage( + metadata: tableMetadata, + previewURL: URL?, + localURL: URL, + for ocId: String, + index: Int + ) async { + switch metadata.classFile { + case NKTypeClassFile.video.rawValue: + var videoPreviewURL = previewURL + + if videoPreviewURL == nil { + videoPreviewURL = await loader.previewURL( + for: metadata, + ext: NCGlobal.shared.previewExt1024 + ) + + guard !Task.isCancelled else { + return + } + } + + setState( + .video( + localURL: localURL, + previewURL: videoPreviewURL + ), + 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, + ext: NCGlobal.shared.previewExt1024 + ) + + guard !Task.isCancelled else { + return + } + } + + await setReadyState( + metadata: metadata, + previewURL: imagePreviewURL, + localURL: localURL, + for: ocId, + index: index + ) + + default: + await setReadyState( + metadata: metadata, + previewURL: previewURL, + localURL: localURL, + for: ocId, + index: index + ) + } + } + + private func loadRemotePage( + metadata: tableMetadata, + previewURL: URL?, + for ocId: String, + index: Int + ) async { + var previewURL = previewURL + + if previewURL == nil, + shouldLoadPreview(for: metadata) { + previewURL = await loader.previewURL( + for: metadata, + ext: NCGlobal.shared.previewExt1024 + ) + } + + guard !Task.isCancelled else { + return + } + + switch metadata.classFile { + case NKTypeClassFile.video.rawValue: + setState( + .video( + localURL: nil, + previewURL: previewURL + ), + for: ocId + ) + return + + case NKTypeClassFile.image.rawValue: + if let previewURL { + setState( + .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 + ) + + guard !Task.isCancelled else { + return + } + + await setReadyState( + metadata: metadata, + previewURL: previewURL, + localURL: downloadedURL, + 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 { + setState( + .failed( + previewURL: previewURL, + message: "" + ), + for: ocId + ) + } + } + + // MARK: - Prefetch + + 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) + } + } + } + + 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 + ) + } + + private func loadPageForPrefetch(index: Int) async { + guard ocIds.indices.contains(index) else { + return + } + + 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: URL? + + if shouldLoadPreview(for: metadata) { + previewURL = await loader.previewURL( + for: metadata, + ext: NCGlobal.shared.previewExt1024 + ) + } else { + previewURL = nil + } + + guard !Task.isCancelled else { + return + } + + if metadata.classFile == NKTypeClassFile.image.rawValue, let previewURL { + setState( + .image( + previewURL: previewURL, + localURL: nil, + livePhotoURL: nil, + progress: nil + ), + for: ocId + ) + return + } + + if metadata.classFile == NKTypeClassFile.video.rawValue { + let localURL = await loader.localMediaURL( + for: metadata + ) + + guard !Task.isCancelled else { + return + } + + setState( + .video( + localURL: localURL, + previewURL: previewURL + ), + for: ocId + ) + return + } + + if metadata.classFile == NKTypeClassFile.audio.rawValue { + let localURL = await loader.localMediaURL( + for: metadata + ) + + guard !Task.isCancelled else { + return + } + + guard let localURL else { + setState( + .downloading( + previewURL: previewURL, + progress: nil + ), + for: ocId + ) + return + } + + setState( + .audio( + localURL: localURL, + previewURL: previewURL + ), + for: ocId + ) + + await loadAudioPreviewIfNeeded( + metadata: metadata, + localURL: localURL, + currentPreviewURL: previewURL, + for: ocId, + index: index + ) + return + } + } + + // MARK: - Page Updates + + private func resolvedMetadata( + for ocId: String, + allowCached: Bool = true + ) async -> tableMetadata? { + if allowCached, + let existingMetadata = cachedPagesByOcId[ocId]?.metadata { + return existingMetadata + } + + return await loader.metadata( + for: ocId, + account: session.account + ) + } + + private func pageState(for ocId: String) -> NCMediaViewerPageState { + cachedPagesByOcId[ocId]?.state ?? .idle + } + + 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 .downloading(let previewURL, _): + return previewURL + + case .audio(_, let previewURL), + .video(_, let previewURL), + .ready(_, let previewURL), + .failed(let previewURL, _): + return previewURL + + case .idle, + .loadingMetadata, + .metadataMissing, + .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 + } + } + + private func setState(_ state: NCMediaViewerPageState, for ocId: String) { + updatePage(ocId: ocId) { page in + page.state = state + } + } + + private func setReadyState( + metadata: tableMetadata, + previewURL: URL?, + localURL: URL, + for ocId: String, + index: Int + ) async { + if metadata.classFile == NKTypeClassFile.image.rawValue { + let livePhotoURL: URL? + + if metadata.isLivePhoto { + livePhotoURL = await loader.downloadLivePhotoMedia( + for: metadata + ) + } else { + livePhotoURL = nil + } + + setState( + .image( + previewURL: previewURL, + localURL: localURL, + livePhotoURL: livePhotoURL, + progress: nil + ), + for: ocId + ) + } else if metadata.classFile == NKTypeClassFile.video.rawValue { + setState( + .video( + localURL: localURL, + previewURL: previewURL + ), + for: ocId + ) + } else if metadata.classFile == NKTypeClassFile.audio.rawValue { + setState( + .audio( + localURL: localURL, + previewURL: previewURL + ), + for: ocId + ) + } else { + setState( + .ready( + localURL: localURL, + previewURL: previewURL + ), + for: ocId + ) + } + } + + 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, + ext: NCGlobal.shared.previewExt1024 + ) + + guard !Task.isCancelled, + let previewURL else { + return + } + + guard case .audio(let readyLocalURL, _) = pageState(for: ocId), + readyLocalURL == localURL else { + return + } + + setState( + .audio( + localURL: localURL, + previewURL: previewURL + ), + for: ocId + ) + } + + private func updatePage( + ocId: String, + publishRevision: Bool = true, + 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 + + if publishRevision { + revision &+= 1 + } + } + + private func setThumbnailMetadata(_ metadata: tableMetadata, for ocId: String) { + updatePage( + ocId: ocId, + publishRevision: false + ) { page in + page.metadata = metadata + } + } + + private func clearLoadingTaskIfCurrent( + ocId: String, + identifier: UUID + ) { + guard loadingTasksByOcId[ocId]?.identifier == identifier else { + return + } + + loadingTasksByOcId[ocId] = nil + } + +} + +// MARK: - NCMediaViewerPageState Helpers + +private extension NCMediaViewerPageState { + var isIdle: Bool { + switch self { + case .idle: + return true + + case .loadingMetadata, + .metadataMissing, + .checkingLocalFile, + .image, + .audio, + .video, + .downloading, + .ready, + .deleted, + .failed: + return false + } + } + + var needsSelectedPageLoading: Bool { + switch self { + case .idle: + return true + + case .downloading: + return true + + case .image(_, nil, _, _): + return true + + case .video(nil, nil): + return true + + case .audio(_, nil): + return true + + case .image(_, .some, _, _), + .audio(_, .some), + .video, + .loadingMetadata, + .metadataMissing, + .checkingLocalFile, + .ready, + .deleted, + .failed: + return false + } + } +} diff --git a/iOSClient/Viewer/NCViewerMedia/Core/NCMediaViewerView.swift b/iOSClient/Viewer/NCViewerMedia/Core/NCMediaViewerView.swift new file mode 100644 index 0000000000..030cfd7e61 --- /dev/null +++ b/iOSClient/Viewer/NCViewerMedia/Core/NCMediaViewerView.swift @@ -0,0 +1,124 @@ +// 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. +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. + 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() + + if !model.isChromeHidden, model.numberOfPages > 1 { + NCMediaViewerThumbnail( + selectedIndex: model.selectedIndex, + numberOfPages: model.numberOfPages, + reloadRevision: model.thumbnailReloadRevision, + metadataProvider: { index in + model.metadataForThumbnail(at: index) + }, + metadataResolver: { index in + await model.resolveMetadataForThumbnail(at: index) + }, + previewURLProvider: { metadata in + await model.previewURL( + for: metadata, + ext: NCGlobal.shared.previewExt256 + ) + }, + isDeletedProvider: { index in + model.isThumbnailDeleted(at: index) + }, + onSelect: { index in + Task { + await model.displayPreviewPage(at: index) + } + } + ) + .equatable() + .frame(height: NCMediaViewerThumbnail.preferredHeight) + .padding(.bottom, 40) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .bottom) + .ignoresSafeArea(.container, edges: [.horizontal, .bottom]) + .zIndex(10) + } + } + .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: ""), + loader: NCMediaViewerLoader() + ) + + return NCMediaViewerView(model: model) + } +} +#endif diff --git a/iOSClient/Viewer/NCViewerMedia/Helpers/NCMediaViewerAppearance.swift b/iOSClient/Viewer/NCViewerMedia/Helpers/NCMediaViewerAppearance.swift new file mode 100644 index 0000000000..ddd2ec1fd7 --- /dev/null +++ b/iOSClient/Viewer/NCViewerMedia/Helpers/NCMediaViewerAppearance.swift @@ -0,0 +1,70 @@ +// 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 +enum NCViewerBackgroundStyle { + case system + case black + case white + case custom(UIColor) +} + +// MARK: - UIColor Viewer Background +extension UIColor { + 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 { + static func ncViewerBackground(_ style: NCViewerBackgroundStyle = .system) -> Color { + Color(uiColor: .ncViewerBackground(style)) + } +} + +// MARK: - Color Viewer Progress Tint +extension Color { + static func ncViewerProgressTint(_ style: NCViewerBackgroundStyle = .system) -> Color { + switch style { + case .black: + return .white + + case .system, + .white, + .custom: + return .accentColor + } + } +} + +// MARK: - Viewer Background Resolution +func ncViewerBackgroundStyle(for metadata: tableMetadata?) -> NCViewerBackgroundStyle { + .system +} + +// MARK: - Viewer Chrome-Aware Background Resolution +func ncViewerBackgroundStyle( + for metadata: tableMetadata?, + isChromeHidden: Bool +) -> NCViewerBackgroundStyle { + if isChromeHidden { + return .black + } + + return ncViewerBackgroundStyle(for: metadata) +} diff --git a/iOSClient/Viewer/NCViewerMedia/Helpers/NCMediaViewerTransitionSource.swift b/iOSClient/Viewer/NCViewerMedia/Helpers/NCMediaViewerTransitionSource.swift new file mode 100644 index 0000000000..83a9dd0a82 --- /dev/null +++ b/iOSClient/Viewer/NCViewerMedia/Helpers/NCMediaViewerTransitionSource.swift @@ -0,0 +1,20 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Marino Faggiana +// SPDX-License-Identifier: GPL-3.0-or-later + +import UIKit + +// MARK: - Viewer Transition Source +struct NCMediaViewerTransitionSource { + let image: UIImage + + let sourceFrame: CGRect + + let cornerRadius: CGFloat + + 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..08898babf0 --- /dev/null +++ b/iOSClient/Viewer/NCViewerMedia/Helpers/Notification+Extension.swift @@ -0,0 +1,13 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Marino Faggiana +// SPDX-License-Identifier: GPL-3.0-or-later + +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/Loading/NCNextcloudMediaViewerLoader.swift b/iOSClient/Viewer/NCViewerMedia/Loading/NCNextcloudMediaViewerLoader.swift new file mode 100644 index 0000000000..3a8547dcf3 --- /dev/null +++ b/iOSClient/Viewer/NCViewerMedia/Loading/NCNextcloudMediaViewerLoader.swift @@ -0,0 +1,259 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Marino Faggiana +// SPDX-License-Identifier: GPL-3.0-or-later + +import Foundation +import ImageIO +import NextcloudKit + +// MARK: - Media Download Limiter +private actor NCMediaDownloadLimiter { + private var runningDownloads = 0 + private var waitingContinuations: [CheckedContinuation] = [] + + func acquire() async { + guard runningDownloads >= NCBrandOptions.shared.httpMaximumConnectionsPerHostInDownload else { + runningDownloads += 1 + return + } + + await withCheckedContinuation { continuation in + waitingContinuations.append(continuation) + } + } + + func release() { + guard !waitingContinuations.isEmpty else { + runningDownloads = max(0, runningDownloads - 1) + return + } + + let continuation = waitingContinuations.removeFirst() + continuation.resume() + } +} + +// MARK: - Media Viewer Loader +final class NCMediaViewerLoader: NCMediaViewerLoading, @unchecked Sendable { + private let database = NCManageDatabase.shared + private let utilityFileSystem = NCUtilityFileSystem() + private let fileManager = FileManager.default + private let mediaDownloadLimiter = NCMediaDownloadLimiter() + + // MARK: - NCMediaViewerLoading + func metadata(for ocId: String, account: String) async -> tableMetadata? { + if let metadata = await database.getMetadataFromOcIdAsync(ocId), + !metadata.placeholder { + 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) + await NCManageDatabase.shared.addMetadataAsync(metadata) + + return metadata + } + + func previewURL(for metadata: tableMetadata, ext: String) async -> URL? { + let localPath = utilityFileSystem.getDirectoryProviderStorageImageOcId( + metadata.ocId, + etag: metadata.etag, + ext: ext, + userId: metadata.userId, + urlBase: metadata.urlBase + ) + + if isValidLocalFile(path: localPath, validateAsImage: true) { + return URL(fileURLWithPath: localPath) + } + + removeInvalidLocalFileIfNeeded(path: localPath, validateAsImage: true) + + await mediaDownloadLimiter.acquire() + + let result = await NextcloudKit.shared.downloadPreviewAsync( + fileId: metadata.fileId, + etag: metadata.etag, + account: metadata.account + ) + + await mediaDownloadLimiter.release() + + if result.error == .success, + let data = result.responseData?.data { + NCUtility().createImageFileFrom( + data: data, + metadata: metadata + ) + } + + guard isValidLocalFile(path: localPath, validateAsImage: true) else { + removeInvalidLocalFileIfNeeded(path: localPath, validateAsImage: true) + return nil + } + + return URL(fileURLWithPath: localPath) + } + + func localMediaURL(for metadata: tableMetadata) async -> URL? { + let localPath = fullLocalPath(for: metadata) + let validateAsImage = metadata.classFile == NKTypeClassFile.image.rawValue + + guard isValidLocalFile(path: localPath, validateAsImage: validateAsImage) else { + removeInvalidLocalFileIfNeeded(path: localPath, validateAsImage: validateAsImage) + return nil + } + + return URL(fileURLWithPath: localPath) + } + + func downloadMedia(for metadata: tableMetadata) async throws -> URL { + if let localURL = await localMediaURL(for: metadata) { + return localURL + } + + guard let metadata = await self.database.setMetadataSessionInWaitDownloadAsync( + ocId: metadata.ocId, + session: NCNetworking.shared.sessionDownload, + selector: NCGlobal.shared.selectorDownloadFile) else { + throw NSError(domain: "Download Media", code: 1, userInfo: [NSLocalizedDescriptionKey: "FULL error"]) + } + + await mediaDownloadLimiter.acquire() + + let result = await NCNetworking.shared.downloadFile(metadata: metadata) + + await mediaDownloadLimiter.release() + + if result.nkError != .success { + throw result.nkError + } + + if let localURL = await localMediaURL(for: metadata) { + return localURL + } + + throw NSError(domain: "Download Media", code: 2) + } + + func localLivePhotoURL(for metadata: tableMetadata) async -> URL? { + guard metadata.isLivePhoto else { + return nil + } + + guard let livePhotoMetadata = database.getMetadataLivePhoto(metadata: metadata) else { + return nil + } + + let localPath = fullLocalPath(for: livePhotoMetadata) + + guard isValidLocalFile(path: localPath, validateAsImage: false) else { + removeInvalidLocalFileIfNeeded(path: localPath, validateAsImage: false) + return nil + } + + return URL(fileURLWithPath: localPath) + } + + // Live Photo fallback is optional; the image viewer can continue without it. + func downloadLivePhotoMedia(for metadata: tableMetadata) async -> URL? { + guard metadata.isLivePhoto else { + return nil + } + + if let localURL = await localLivePhotoURL(for: metadata) { + return localURL + } + + guard NCNetworking.shared.isOnline, + let livePhotoMetadata = database.getMetadataLivePhoto(metadata: metadata), + let downloadMetadata = await database.setMetadataSessionInWaitDownloadAsync( + ocId: livePhotoMetadata.ocId, + session: NCNetworking.shared.sessionDownload, + selector: "" + ) else { + return nil + } + + await mediaDownloadLimiter.acquire() + let result = await NCNetworking.shared.downloadFile(metadata: downloadMetadata) + await mediaDownloadLimiter.release() + + guard result.nkError == .success else { + return nil + } + + return await localLivePhotoURL(for: metadata) + } + + // MARK: - Private Helpers + private func fullLocalPath(for metadata: tableMetadata) -> String { + utilityFileSystem.getDirectoryProviderStorageOcId( + metadata.ocId, + fileName: metadata.fileNameView, + userId: metadata.userId, + urlBase: metadata.urlBase + ) + } + + private func isValidLocalFile(path: String, validateAsImage: Bool) -> Bool { + guard !path.isEmpty, + fileManager.fileExists(atPath: path), + let attributes = try? fileManager.attributesOfItem(atPath: path), + let fileSize = attributes[.size] as? NSNumber, + fileSize.int64Value > 0 else { + return false + } + + guard validateAsImage else { + return true + } + + let url = URL(fileURLWithPath: path) + + guard let source = CGImageSourceCreateWithURL(url as CFURL, nil), + CGImageSourceGetCount(source) > 0, + CGImageSourceGetType(source) != nil else { + return false + } + + return true + } + + private func removeInvalidLocalFileIfNeeded(path: String, validateAsImage: Bool) { + guard !path.isEmpty, + fileManager.fileExists(atPath: path), + !isValidLocalFile(path: path, validateAsImage: validateAsImage) else { + return + } + + try? fileManager.removeItem(atPath: path) + } +} + +protocol NCMediaViewerLoading: Sendable { + func metadata(for ocId: String, account: String) async -> tableMetadata? + + func localMediaURL(for metadata: tableMetadata) async -> URL? + + func previewURL(for metadata: tableMetadata, ext: String) async -> URL? + + func downloadMedia(for metadata: tableMetadata) async throws -> URL + + func localLivePhotoURL(for metadata: tableMetadata) async -> URL? + + func downloadLivePhotoMedia(for metadata: tableMetadata) async -> URL? +} diff --git a/iOSClient/Viewer/NCViewerMedia/NCMediaViewerHostingController.swift b/iOSClient/Viewer/NCViewerMedia/NCMediaViewerHostingController.swift new file mode 100644 index 0000000000..7218a03912 --- /dev/null +++ b/iOSClient/Viewer/NCViewerMedia/NCMediaViewerHostingController.swift @@ -0,0 +1,505 @@ +// 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 + +/// Hosts the SwiftUI media viewer inside a UIKit controller. +@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 = NCMediaViewerFloatingTitleView() + + private lazy var floatingTitleDateFormatter: DateFormatter = { + let formatter = DateFormatter() + formatter.locale = .current + formatter.dateStyle = .medium + formatter.timeStyle = .short + return formatter + }() + + private lazy var moreNavigationItem: UIBarButtonItem = { + let item = UIBarButtonItem( + image: NCImageCache.shared.getImageButtonMore(), + primaryAction: nil, + menu: nil + ) + + item.menu = UIMenu(title: "", children: [ + UIDeferredMenuElement.uncached { [weak self, weak item] 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: item + ).viewMenu() { + completion(menu.children) + } else { + completion([]) + } + } + ]) + + return item + }() + + 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. + 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( + onDeletedOcId: { [weak self] deletedOcId in + guard let self else { + return + } + + self.model.markPageAsDeleted(ocId: deletedOcId) + }, + onReloadDataSource: { [weak self] in + guard let self else { + return + } + + await self.model.reloadPage(index: self.model.selectedIndex) + } + ) + + 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. + 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() { + // Stop any remaining media playback before releasing the viewer hierarchy. + // This notification is intentionally global and should only be used for + // viewer-wide teardown, not for normal page-to-page navigation. + NotificationCenter.default.post( + name: .ncMediaViewerStopPlayback, + object: nil + ) + } + + /// Closes the viewer, forwarding the selected media identifier when no explicit identifier is provided. + /// - Parameter ocId: The media identifier that should be used by the caller to resolve the closing animation source frame. + func close(ocId: String? = nil) { + let closingOcId = ocId ?? model.selectedMetadata?.ocId + + stop() + onClose(closingOcId) + } + + // 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 using the current media metadata. + 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 color for the current background. + 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. + private func floatingTitleSecondaryText(for metadata: tableMetadata) -> String? { + floatingTitleDateFormatter.string(from: metadata.date as Date) + } + + /// Shows or hides the viewer chrome. + 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(ocId: model.selectedMetadata?.ocId) + } + + @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. + 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. + 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. + 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 selected media item as deleted. + @MainActor + func markCurrentItemAsDeleted() { + guard let metadata = model.selectedMetadata else { + return + } + + model.markPageAsDeleted(ocId: metadata.ocId) + } + + /// Marks a specific media item as deleted. + @MainActor + func markItemAsDeleted(ocId: String) { + model.markPageAsDeleted(ocId: ocId) + } +} + +// MARK: - Media Viewer Transfer Delegate + +/// Bridges transfer events into the MainActor-isolated media viewer controller. +final class NCMediaViewerTransferDelegate: NSObject, NCTransferDelegate { + private let onDeletedOcId: @MainActor (_ ocId: String) -> Void + private let onReloadDataSource: @MainActor () async -> Void + let sceneIdentifier: String = "" + + init( + onDeletedOcId: @escaping @MainActor (_ ocId: String) -> Void, + onReloadDataSource: @escaping @MainActor () async -> Void + ) { + self.onDeletedOcId = onDeletedOcId + self.onReloadDataSource = onReloadDataSource + } + + func transferReloadData(serverUrl: String?) { } + + func transferReloadDataSource( + serverUrl: String?, + requestData: Bool, + status: Int? + ) { + Task { @MainActor in + await onReloadDataSource() + } + } + + 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..12153850dc --- /dev/null +++ b/iOSClient/Viewer/NCViewerMedia/NCMediaViewerPresenter.swift @@ -0,0 +1,590 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Marino Faggiana +// SPDX-License-Identifier: GPL-3.0-or-later + +import SwiftUI +import NextcloudKit +import UIKit + +/// 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, 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 +/// 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, page navigation, +/// and chrome-aware page background updates. +/// +/// 7. `NCMediaViewerPageView` +/// Per-page SwiftUI renderer. Switches on `NCMediaViewerPageState`, applies +/// the chrome-aware background style, and routes each page to the correct +/// content view. +/// +/// 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. +/// +/// 10. Audio flow: +/// `NCMediaViewerPageView` +/// -> `NCAudioViewerContentView`. +/// Audio playback stays inside SwiftUI and uses a local media URL plus an +/// optional preview image as artwork. +/// +/// 11. Video SwiftUI flow: +/// `NCMediaViewerPageView` +/// -> `NCVideoViewerContentView` +/// -> `NCVideoPlaybackCoverView` +/// -> `NCVideoURLResolver` +/// -> `NCVideoPlaybackController`. +/// 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. +/// +/// 12. `NCVideoPlaybackController` +/// Chooses the playback engine. It tries AVFoundation when possible and falls +/// back to VLC for unsupported or legacy formats. +/// +/// 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. +/// +/// 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. +/// +/// 15. 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. + +@MainActor +final class NCMediaViewerPresenter: NSObject { + static let shared = NCMediaViewerPresenter() + + private var navigationController: UINavigationController? + private weak var viewerContainerView: UIView? + private var currentViewerTransitionSource: NCMediaViewerTransitionSource? + private weak var currentModel: NCMediaViewerModel? + + private var closingTransitionSourceProvider: ((_ ocId: String) -> NCMediaViewerTransitionSource?)? + 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. + func show( + model: NCMediaViewerModel, + viewerTransitionSource: NCMediaViewerTransitionSource?, + from sourceView: UIView? = nil, + contextMenuController: NCMainTabBarController? = nil, + closingTransitionSourceProvider: ((_ ocId: String) -> NCMediaViewerTransitionSource?)? = 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. + 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 transparent navigation bar used by the viewer. + 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 gesture used to close the viewer. + 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 when vertical movement wins over paging. + @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. + private func animateOpening( + viewerTransitionSource: NCMediaViewerTransitionSource, + 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. + private func animateClosing( + viewerTransitionSource: NCMediaViewerTransitionSource, + 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 item. + private func currentClosingTransitionSource() -> NCMediaViewerTransitionSource? { + let ocId = forcedClosingOcId ?? currentModel?.selectedOcId + + guard let ocId else { + return nil + } + + return closingTransitionSourceProvider?(ocId) + } + + /// Returns the best available image 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, _, _): + return imageFromURL(localURL) ?? imageFromURL(previewURL) + + case .audio(_, let previewURL): + return imageFromURL(previewURL) + + case .video: + return nil + + case .ready(let localURL, let previewURL): + return imageFromURL(localURL) ?? imageFromURL(previewURL) + + case .downloading(let previewURL, _), + .failed(let previewURL, _): + guard page.metadata?.classFile != NKTypeClassFile.audio.rawValue, + page.metadata?.classFile != NKTypeClassFile.video.rawValue else { + return nil + } + + return imageFromURL(previewURL) + + case .deleted, + .idle, + .loadingMetadata, + .metadataMissing, + .checkingLocalFile: + return nil + } + } + + 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. + private func cleanup() { + // Stop any remaining media playback before releasing the viewer hierarchy. + NotificationCenter.default.post( + name: .ncMediaViewerStopPlayback, + object: nil + ) + + navigationController = nil + viewerContainerView = nil + currentViewerTransitionSource = nil + currentModel = nil + closingTransitionSourceProvider = nil + forcedClosingOcId = nil + } + + // MARK: - Helpers + + /// Returns the active foreground key window. + 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 container. + 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 7304ddafbb..0000000000 --- a/iOSClient/Viewer/NCViewerMedia/NCPlayer/NCPlayerToolBar.swift +++ /dev/null @@ -1,452 +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) - } - }) { _, response, error in - Task { - if let banner { - banner.dismiss() - } - - let ocId = metadata.ocId - let allHeaderFields = response?.response?.allHeaderFields - let nkComm = NextcloudKit.shared.nkCommonInstance - let etag = nkComm.normalizedETag(nkComm.findHeader("oc-etag", allHeaderFields: allHeaderFields)) - - 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/NCMediaViewerDetailView.swift b/iOSClient/Viewer/NCViewerMedia/Views/NCMediaViewerDetailView.swift new file mode 100644 index 0000000000..d92dbbcb82 --- /dev/null +++ b/iOSClient/Viewer/NCViewerMedia/Views/NCMediaViewerDetailView.swift @@ -0,0 +1,490 @@ +// 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 +struct NCMediaViewerDetailView: View { + let metadata: tableMetadata + let exif: ExifData + + private let utilityFileSystem = NCUtilityFileSystem() + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 18) { + dateSection + mediaSummaryCard + locationSection + } + .frame(maxWidth: .infinity, alignment: .leading) + .padding(.horizontal, 16) + .padding(.vertical, 20) + } + .scrollContentBackground(.hidden) + .background(Color.ncViewerBackground(.system)) + .presentationBackground(Color.ncViewerBackground(.system)) + } + + private var mediaSummaryCard: some View { + VStack(alignment: .leading, spacing: 0) { + HStack(alignment: .firstTextBaseline, spacing: 8) { + Text(cameraText) + .font(.body) + .lineLimit(1) + + Spacer(minLength: 8) + + if !metadata.fileExtension.isEmpty { + detailBadge(metadata.fileExtension.uppercased()) + } + + if metadata.isLivePhoto { + Image(systemName: "livephoto") + .font(.title3) + .foregroundStyle(.secondary) + } + } + .padding(.horizontal, 16) + .padding(.vertical, 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) + + 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 + private var dateSection: some View { + if let date = exif.date as Date? { + VStack(alignment: .leading, spacing: 4) { + Text(dayString(from: date)) + .font(.body) + + 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 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(spacing: 0) { + ZStack { + Map( + initialPosition: .region( + MKCoordinateRegion( + center: coordinate, + latitudinalMeters: 500, + longitudinalMeters: 500 + ) + ) + ) { + Marker("", coordinate: coordinate) + } + .allowsHitTesting(false) + + Button { + openMaps( + coordinate: coordinate, + name: exif.location + ) + } label: { + Color.clear + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + .frame(height: 180) + + if let location = exif.location, !location.isEmpty { + Button { + openMaps( + coordinate: coordinate, + name: location + ) + } label: { + HStack(spacing: 6) { + Text(location) + .lineLimit(1) + .foregroundStyle(.tint) + + Image(systemName: "chevron.right") + .font(.subheadline.weight(.semibold)) + .foregroundStyle(.secondary) + + Spacer(minLength: 0) + } + .padding(.horizontal, 16) + .padding(.vertical, 12) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .background(.secondary.opacity(0.08)) + } + } + .clipShape(RoundedRectangle(cornerRadius: 16)) + } 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(.subheadline) + .foregroundStyle(.secondary) + .lineLimit(1) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background( + .secondary.opacity(0.15), + in: RoundedRectangle(cornerRadius: 7, style: .continuous) + ) + } + + // 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] { + [ + exif.iso.map { "ISO \($0)" }, + exif.lensLength.map { "\($0) mm" }, + exif.exposureValue.map { "\($0) ev" }, + exif.apertureValue.map { "ƒ\($0)" }, + exif.shutterSpeedApex.map { "1/\(Int(pow(2, $0))) s" } + ].map { $0 ?? "-" } + } + + 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() + } +} + +// 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) + } + } + } + } +} + +// MARK: - Previews + +#Preview("Full EXIF") { + let metadata: tableMetadata = { + let metadata = tableMetadata() + metadata.fileNameView = "IMG_0042.HEIC" + metadata.size = 3_145_728 + metadata.livePhotoFile = "IMG_0042.MOV" + return metadata + }() + + let exif: ExifData = { + var exif = ExifData() + exif.make = "Apple" + exif.model = "iPhone 17 Pro" + exif.lensModel = "iPhone 17 Pro back triple camera 6.765mm f/1.78" + exif.width = 5712 + exif.height = 4284 + exif.iso = 64 + exif.lensLength = 7 + exif.exposureValue = 0 + exif.apertureValue = 1.78 + exif.shutterSpeedApex = 9.0 + exif.latitude = 48.137154 + exif.longitude = 11.576124 + exif.location = "Munich, Germany" + exif.date = Date(timeIntervalSince1970: 1_750_000_000) + return exif + }() + + NCMediaViewerDetailView(metadata: metadata, exif: exif) +} + +#Preview("No EXIF") { + let metadata: tableMetadata = { + let metadata = tableMetadata() + metadata.fileNameView = "Document scan.png" + metadata.size = 482_133 + return metadata + }() + + NCMediaViewerDetailView(metadata: metadata, exif: ExifData()) +} diff --git a/iOSClient/Viewer/NCViewerMedia/Views/NCMediaViewerFloatingTitleView.swift b/iOSClient/Viewer/NCViewerMedia/Views/NCMediaViewerFloatingTitleView.swift new file mode 100644 index 0000000000..3e2d727adf --- /dev/null +++ b/iOSClient/Viewer/NCViewerMedia/Views/NCMediaViewerFloatingTitleView.swift @@ -0,0 +1,189 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Marino Faggiana +// SPDX-License-Identifier: GPL-3.0-or-later + +import UIKit + +final class NCMediaViewerFloatingTitleView: 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) + + translatesAutoresizingMaskIntoConstraints = false + backgroundColor = .clear + layoutMargins = UIEdgeInsets(top: 0, left: 0, bottom: 0, right: 0) + isAccessibilityElement = true + + configureLabels() + configureStackView() + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + // Attach directly to the navigation bar to match real button layout. + 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() + } + + func updateHorizontalAlignment() { + centerXConstraint?.constant = 0 + } + + func updateNavigationItemHeight() { + guard let navigationBar else { + return + } + + heightConstraint?.constant = navigationItemHeight(in: navigationBar) + } + + // Use visible bar item height when possible. + 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 + } + + 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 + } + + 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: ", ") + } + + func clear() { + update( + primaryText: nil, + secondaryText: nil, + textColor: .white + ) + } + + 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 + } + + 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/NCViewerMedia/Views/NCMediaViewerPageView.swift b/iOSClient/Viewer/NCViewerMedia/Views/NCMediaViewerPageView.swift new file mode 100644 index 0000000000..c27df1cc11 --- /dev/null +++ b/iOSClient/Viewer/NCViewerMedia/Views/NCMediaViewerPageView.swift @@ -0,0 +1,422 @@ +// 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 + +struct NCMediaViewerPageView: View { + + // 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 localURL, let previewURL): + videoStateView( + localURL: localURL, + previewURL: previewURL + ) + + case .audio(let localURL, let previewURL): + audioStateView( + localURL: localURL, + previewURL: previewURL + ) + + case .downloading(let previewURL, let progress): + downloadingStateView( + previewURL: previewURL, + progress + ) + + case .ready(let localURL, let previewURL): + genericReadyStateView( + 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 + ) + } + } + .background(Color.ncViewerBackground(backgroundStyle)) + .ignoresSafeArea() + } + + private var backgroundStyle: NCViewerBackgroundStyle { + ncViewerBackgroundStyle( + for: page.metadata, + isChromeHidden: isChromeHidden + ) + } + + // Neighbor pages must not consume auto-play. + private var effectiveShouldAutoPlay: Bool { + isSelected && shouldAutoPlay + } + + private func goToPreviousPage(_ requestedAutoPlay: Bool) { + guard canGoPrevious else { + return + } + + onPreviousPage( + isSelected && requestedAutoPlay + ) + } + + private func goToNextPage(_ requestedAutoPlay: Bool) { + guard canGoNext else { + return + } + + onNextPage( + isSelected && requestedAutoPlay + ) + } + + private func consumeAutoPlayIfNeeded() { + guard isSelected else { + return + } + + onAutoPlayConsumed() + } + + // Video controllers delegate boundary checks to the paging coordinator. + private func goToPreviousPageFromVideo() { + onPreviousPage(false) + } + + 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(NSLocalizedString("_media_not_available_", comment: "")) + .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(NSLocalizedString("_media_no_longer_available_", comment: "")) + .font(.headline) + + Text(NSLocalizedString("_this_item_has_been_deleted_", comment: "")) + .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( + 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") + .background(Color.ncViewerBackground(backgroundStyle)) + } else { + metadataMissingView + } + } + + @ViewBuilder + private func audioStateView( + localURL: URL, + previewURL: URL? + ) -> some View { + if let metadata = page.metadata { + NCAudioViewerContentView( + metadata: metadata, + localURL: localURL, + previewURL: previewURL, + backgroundStyle: backgroundStyle, + canGoPrevious: canGoPrevious, + canGoNext: canGoNext, + shouldAutoPlay: effectiveShouldAutoPlay, + onPrevious: goToPreviousPage, + onNext: goToNextPage, + onAutoPlayConsumed: consumeAutoPlayIfNeeded, + onToggleChrome: onToggleChrome + ) + .background(Color.ncViewerBackground(backgroundStyle)) + } else { + metadataMissingView + } + } + + @ViewBuilder + private func downloadingStateView( + previewURL: URL?, + _ progress: Double? + ) -> some View { + switch page.metadata?.classFile { + case NKTypeClassFile.video.rawValue: + if isSelected { + videoStateView( + localURL: nil, + previewURL: previewURL + ) + } 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 genericReadyStateView( + localURL: URL, + previewURL: URL? + ) -> some View { + 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 + } + } + + @ViewBuilder + private func failedStateView( + previewURL: URL?, + _ message: String + ) -> some View { + if let previewURL { + previewOnlyView(previewURL: previewURL) + } else { + Color.ncViewerBackground(backgroundStyle) + .ignoresSafeArea() + } + } + + @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()) + } + + // Keep double tap reserved for image zoom. + 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 + } +} diff --git a/iOSClient/Viewer/NCViewerMedia/Views/NCMediaViewerPagingView.swift b/iOSClient/Viewer/NCViewerMedia/Views/NCMediaViewerPagingView.swift new file mode 100644 index 0000000000..e0d46ff5ce --- /dev/null +++ b/iOSClient/Viewer/NCViewerMedia/Views/NCMediaViewerPagingView.swift @@ -0,0 +1,807 @@ +// 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 + +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.updateVisibleMetadataTitle(for: context.coordinator.model.selectedIndex) + } + + 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.jumpToSelectedIndexIfNeeded(animated: false) + context.coordinator.refreshVisibleCells() + } + + func makeCoordinator() -> NCMediaViewerPagingCoordinator { + NCMediaViewerPagingCoordinator( + model: model, + contextMenuController: contextMenuController, + navigationBar: navigationBar, + onVisibleMetadataChanged: onVisibleMetadataChanged, + onClose: onClose + ) + } +} + +// MARK: - Media Viewer Collection View + +final class NCMediaViewerCollectionView: UICollectionView { + var onLayoutSubviews: (() -> Void)? + + override func layoutSubviews() { + super.layoutSubviews() + onLayoutSubviews?() + } +} + +// MARK: - Media Viewer Paging Coordinator + +@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 + guard let self else { + return + } + + self.refreshVisibleCells() + self.updateCollectionBackground() + self.updateVisibleMetadataTitle(for: self.model.selectedIndex) + } + } + + // MARK: - Layout + + 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) + } + + func relayoutAndKeepCurrentIndex(size: CGSize) { + guard let collectionView else { + return + } + + guard size.width > 0, + size.height > 0 else { + return + } + + // Ignore intermediate offsets while the layout is being resized. + 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 + + private func backgroundColor(for page: NCMediaViewerPageModel?) -> UIColor { + UIColor.ncViewerBackground( + ncViewerBackgroundStyle( + for: page?.metadata, + isChromeHidden: model.isChromeHidden + ) + ) + } + + 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 + } + + 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 + + func scrollToInitialIndexIfNeeded(animated: Bool) { + guard !didScrollToInitialIndex else { + return + } + + guard model.numberOfPages > 0 else { + return + } + + guard let collectionView else { + return + } + + collectionView.layoutIfNeeded() + + let index = model.currentSelectedIndex + + guard index >= 0, + index < model.numberOfPages else { + return + } + + jumpToIndex( + index, + animated: animated + ) + + didScrollToInitialIndex = true + lastVisibleIndex = index + updateCollectionBackground(for: index) + updateVisibleMetadataTitle(for: index) + refreshVisibleCells() + } + + func scrollToCurrentIndex(animated: Bool) { + scrollToIndex( + model.selectedIndex, + animated: animated + ) + } + + func jumpToSelectedIndexIfNeeded(animated: Bool) { + guard model.numberOfPages > 0 else { + return + } + + let index = model.selectedIndex + + guard index >= 0, + index < model.numberOfPages else { + return + } + + guard lastVisibleIndex != index else { + return + } + + scrollToIndex( + index, + animated: animated + ) + } + + private func scrollToIndex( + _ index: Int, + animated: Bool + ) { + guard model.numberOfPages > 0 else { + return + } + + guard index >= 0, + index < model.numberOfPages else { + return + } + + lastVisibleIndex = index + + jumpToIndex( + index, + animated: animated + ) + + updateCollectionBackground(for: index) + updateVisibleMetadataTitle(for: index) + refreshVisibleCells() + } + + private func jumpToIndex( + _ index: Int, + animated: Bool + ) { + guard let collectionView else { + return + } + + collectionView.layoutIfNeeded() + + guard collectionView.bounds.width > 0 else { + return + } + + if animated { + collectionView.scrollToItem( + at: IndexPath(item: index, section: 0), + at: .centeredHorizontally, + animated: true + ) + } else { + let targetOffset = CGPoint( + x: CGFloat(index) * collectionView.bounds.width, + y: 0 + ) + + collectionView.setContentOffset( + targetOffset, + animated: false + ) + } + } + + // MARK: - Visible Cell Refresh + + 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 + + private func moveToPage( + offset: Int, + shouldAutoPlay: Bool + ) { + let targetIndex = model.selectedIndex + offset + + guard targetIndex >= 0, + targetIndex < model.numberOfPages else { + return + } + + // 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. + NotificationCenter.default.post( + name: .ncMediaViewerStopPlayback, + object: nil + ) + + if shouldAutoPlay { + model.requestAutoPlay(at: targetIndex) + } + + // Selection is finalized when the scroll animation ends. + isUserPaging = true + lastVisibleIndex = targetIndex + + updateCollectionBackground(for: targetIndex) + updateVisibleMetadataTitle(for: targetIndex) + refreshVisibleCells() + + scrollToIndex( + targetIndex, + animated: true + ) + } + + 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 + + // 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 + ) + + refreshVisibleCells() + } + + func scrollViewWillEndDragging( + _ scrollView: UIScrollView, + withVelocity velocity: CGPoint, + targetContentOffset: UnsafeMutablePointer + ) { + guard isScrollGeometryStable(scrollView) 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() + } + + 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, + width: scrollView.bounds.width + ) + } + + 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 isScrollGeometryStable(scrollView) 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) + } + } + + private func updateSelectedIndexFromScrollView(_ scrollView: UIScrollView) { + guard isScrollGeometryStable(scrollView) else { + return + } + + guard let index = pageIndex(for: scrollView) else { + return + } + + // The settled page is now the selected page. + 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 + +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 + + 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 + } + } + + 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/NCMediaViewerThumbnail.swift b/iOSClient/Viewer/NCViewerMedia/Views/NCMediaViewerThumbnail.swift new file mode 100644 index 0000000000..a31d923d24 --- /dev/null +++ b/iOSClient/Viewer/NCViewerMedia/Views/NCMediaViewerThumbnail.swift @@ -0,0 +1,952 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Marino Faggiana +// SPDX-License-Identifier: GPL-3.0-or-later + +import UIKit +import SwiftUI +import NextcloudKit + +// MARK: - Layout + +private enum NCMediaViewerThumbnailLayout { + /// Real square thumbnail image size for non-selected items. + static let thumbnailSize: CGFloat = UIDevice.current.userInterfaceIdiom == .pad ? 45 : 30 + + /// Total vertical lane height for each collection view cell. + /// The selected thumbnail uses this value as its square image size. + static let itemContainerHeight: CGFloat = UIDevice.current.userInterfaceIdiom == .pad ? 60 : 45 + + /// Preferred SwiftUI container height for the thumbnail strip. + static var preferredHeight: CGFloat { + itemContainerHeight + 10 + } + + /// Horizontal spacing between thumbnail cells. + static let itemSpacing: CGFloat = UIDevice.current.userInterfaceIdiom == .pad ? 6 : 3 + + /// Square thumbnail image size used by the currently selected item. + static var selectedThumbnailSize: CGFloat { + itemContainerHeight + } + + /// Extra horizontal width assigned to the selected item. + static let selectedExtraWidth: CGFloat = 30 + + /// Corner radius used by the thumbnail image and placeholder. + static let cornerRadius: CGFloat = 10 + + /// Maximum number of decoded preview images kept in memory. + static let thumbnailCacheLimit: Int = 80 + + /// Number of thumbnails prefetched before and after the current centered item. + static let prefetchRadius: Int = UIDevice.current.userInterfaceIdiom == .pad ? 80 : 20 +} + +// MARK: - Thumbnail + +struct NCMediaViewerThumbnail: UIViewRepresentable, Equatable { + let selectedIndex: Int + let numberOfPages: Int + let reloadRevision: Int + let metadataProvider: (_ index: Int) -> tableMetadata? + let metadataResolver: (_ index: Int) async -> tableMetadata? + let previewURLProvider: (_ metadata: tableMetadata) async -> URL? + let isDeletedProvider: (_ index: Int) -> Bool + let onSelect: (_ index: Int) -> Void + + static var preferredHeight: CGFloat { + NCMediaViewerThumbnailLayout.preferredHeight + } + + static func == ( + lhs: NCMediaViewerThumbnail, + rhs: NCMediaViewerThumbnail + ) -> Bool { + lhs.selectedIndex == rhs.selectedIndex && + lhs.numberOfPages == rhs.numberOfPages && + lhs.reloadRevision == rhs.reloadRevision + } + + func makeUIView(context: Context) -> UICollectionView { + let layout = UICollectionViewFlowLayout() + layout.scrollDirection = .horizontal + layout.minimumLineSpacing = NCMediaViewerThumbnailLayout.itemSpacing + layout.minimumInteritemSpacing = NCMediaViewerThumbnailLayout.itemSpacing + layout.sectionInset = .zero + + let collectionView = NCMediaViewerThumbnailCollectionView( + frame: .zero, + collectionViewLayout: layout + ) + + collectionView.backgroundColor = .clear + collectionView.showsHorizontalScrollIndicator = false + collectionView.alwaysBounceHorizontal = true + collectionView.contentInsetAdjustmentBehavior = .never + collectionView.clipsToBounds = false + collectionView.dataSource = context.coordinator + collectionView.delegate = context.coordinator + collectionView.prefetchDataSource = context.coordinator + + collectionView.register( + NCMediaViewerThumbnailUICollectionCell.self, + forCellWithReuseIdentifier: NCMediaViewerThumbnailUICollectionCell.reuseIdentifier + ) + + context.coordinator.collectionView = collectionView + collectionView.onBoundsSizeChanged = { [weak coordinator = context.coordinator] size in + coordinator?.collectionViewBoundsDidChange(size) + } + + return collectionView + } + + func updateUIView( + _ collectionView: UICollectionView, + context: Context + ) { + context.coordinator.selectedIndex = selectedIndex + context.coordinator.numberOfPages = numberOfPages + context.coordinator.reloadRevision = reloadRevision + context.coordinator.metadataProvider = metadataProvider + context.coordinator.metadataResolver = metadataResolver + context.coordinator.previewURLProvider = previewURLProvider + context.coordinator.isDeletedProvider = isDeletedProvider + context.coordinator.onSelect = onSelect + context.coordinator.syncDisplayedSelectedIndexFromInput() + + context.coordinator.reloadCollectionViewIfNeeded() + context.coordinator.scrollToSelectedIndexIfNeeded(animated: false) + context.coordinator.performInitialDeferredCenteringIfNeeded() + context.coordinator.prefetchAroundDisplayedSelectedIndexIfNeeded() + } + + func makeCoordinator() -> Coordinator { + Coordinator( + selectedIndex: selectedIndex, + numberOfPages: numberOfPages, + reloadRevision: reloadRevision, + metadataProvider: metadataProvider, + metadataResolver: metadataResolver, + previewURLProvider: previewURLProvider, + isDeletedProvider: isDeletedProvider, + onSelect: onSelect + ) + } +} + +// MARK: - Coordinator + +extension NCMediaViewerThumbnail { + @MainActor + final class Coordinator: NSObject, + UICollectionViewDataSource, + UICollectionViewDelegateFlowLayout, + UICollectionViewDataSourcePrefetching { + var selectedIndex: Int + var numberOfPages: Int + var reloadRevision: Int + var metadataProvider: (_ index: Int) -> tableMetadata? + var metadataResolver: (_ index: Int) async -> tableMetadata? + var previewURLProvider: (_ metadata: tableMetadata) async -> URL? + var isDeletedProvider: (_ index: Int) -> Bool + var onSelect: (_ index: Int) -> Void + + weak var collectionView: UICollectionView? + + private var lastNumberOfPages: Int? + private var lastReloadRevision: Int? + private var lastCenteredIndex: Int? + private var lastCenteredBoundsSize: CGSize = .zero + private var didPerformInitialDeferredCentering = false + private var pendingPrefetchIndexes = Set() + private var displayedSelectedIndex: Int? + private var isUserScrollingThumbnails = false + private var shouldEmphasizeSelectedThumbnail = true + private var lastSentSelectedIndex: Int? + private let selectionFeedbackGenerator = UISelectionFeedbackGenerator() + private let imageCache = NSCache() + + init( + selectedIndex: Int, + numberOfPages: Int, + reloadRevision: Int, + metadataProvider: @escaping (_ index: Int) -> tableMetadata?, + metadataResolver: @escaping (_ index: Int) async -> tableMetadata?, + previewURLProvider: @escaping (_ metadata: tableMetadata) async -> URL?, + isDeletedProvider: @escaping (_ index: Int) -> Bool, + onSelect: @escaping (_ index: Int) -> Void + ) { + self.selectedIndex = selectedIndex + self.numberOfPages = numberOfPages + self.reloadRevision = reloadRevision + self.lastReloadRevision = reloadRevision + self.metadataProvider = metadataProvider + self.metadataResolver = metadataResolver + self.previewURLProvider = previewURLProvider + self.isDeletedProvider = isDeletedProvider + self.onSelect = onSelect + self.displayedSelectedIndex = selectedIndex + self.lastSentSelectedIndex = selectedIndex + super.init() + + imageCache.countLimit = NCMediaViewerThumbnailLayout.thumbnailCacheLimit + } + + // MARK: - UICollectionViewDataSource + + func collectionView( + _ collectionView: UICollectionView, + numberOfItemsInSection section: Int + ) -> Int { + numberOfPages + } + + func collectionView( + _ collectionView: UICollectionView, + cellForItemAt indexPath: IndexPath + ) -> UICollectionViewCell { + guard let cell = collectionView.dequeueReusableCell( + withReuseIdentifier: NCMediaViewerThumbnailUICollectionCell.reuseIdentifier, + for: indexPath + ) as? NCMediaViewerThumbnailUICollectionCell else { + return UICollectionViewCell() + } + + configure( + cell, + at: indexPath.item + ) + + return cell + } + + // MARK: - UICollectionViewDelegate + + func collectionView( + _ collectionView: UICollectionView, + didSelectItemAt indexPath: IndexPath + ) { + let selectedIndex = indexPath.item + + shouldEmphasizeSelectedThumbnail = true + displayedSelectedIndex = selectedIndex + lastCenteredIndex = nil + lastSentSelectedIndex = selectedIndex + + collectionView.collectionViewLayout.invalidateLayout() + scrollToSelectedIndexIfNeeded(animated: false) + prefetchThumbnailsAround(selectedIndex) + onSelect(selectedIndex) + } + + // MARK: - UIScrollViewDelegate + + func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { + isUserScrollingThumbnails = true + shouldEmphasizeSelectedThumbnail = false + selectionFeedbackGenerator.prepare() + + collectionView?.collectionViewLayout.invalidateLayout() + refreshVisibleCells() + } + + func scrollViewDidScroll(_ scrollView: UIScrollView) { + guard isUserScrollingThumbnails else { + return + } + + selectCenteredThumbnailDuringScrollIfNeeded() + } + + func scrollViewDidEndDragging( + _ scrollView: UIScrollView, + willDecelerate decelerate: Bool + ) { + guard !decelerate else { + return + } + + finishUserThumbnailScroll() + } + + func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { + finishUserThumbnailScroll() + } + + // MARK: - UICollectionViewDelegateFlowLayout + + func collectionView( + _ collectionView: UICollectionView, + layout collectionViewLayout: UICollectionViewLayout, + sizeForItemAt indexPath: IndexPath + ) -> CGSize { + let baseWidth = NCMediaViewerThumbnailLayout.thumbnailSize + let extraWidth = shouldEmphasizeSelectedThumbnail && isDisplayedCurrentThumbnail(at: indexPath.item) + ? NCMediaViewerThumbnailLayout.selectedExtraWidth + : 0 + + return CGSize( + width: baseWidth + extraWidth, + height: NCMediaViewerThumbnailLayout.itemContainerHeight + ) + } + + // MARK: - UICollectionViewDataSourcePrefetching + + func collectionView( + _ collectionView: UICollectionView, + prefetchItemsAt indexPaths: [IndexPath] + ) { + for indexPath in indexPaths { + prefetchThumbnail(at: indexPath.item) + } + } + + // MARK: - Public Coordinator Updates + + func syncDisplayedSelectedIndexFromInput() { + guard selectedIndex >= 0, + selectedIndex < numberOfPages else { + return + } + + guard !isUserScrollingThumbnails else { + return + } + + displayedSelectedIndex = selectedIndex + lastSentSelectedIndex = selectedIndex + } + + func reloadCollectionViewIfNeeded() { + guard let collectionView else { + return + } + + let didChangeReloadRevision = lastReloadRevision != reloadRevision + + if didChangeReloadRevision { + lastReloadRevision = reloadRevision + pendingPrefetchIndexes.removeAll() + imageCache.removeAllObjects() + refreshVisibleCells() + prefetchAroundDisplayedSelectedIndexIfNeeded() + } + + guard lastNumberOfPages != numberOfPages else { + return + } + + lastNumberOfPages = numberOfPages + lastCenteredIndex = nil + lastCenteredBoundsSize = .zero + didPerformInitialDeferredCentering = false + pendingPrefetchIndexes.removeAll() + imageCache.removeAllObjects() + collectionView.reloadData() + } + + func performInitialDeferredCenteringIfNeeded() { + guard !didPerformInitialDeferredCentering else { + return + } + + didPerformInitialDeferredCentering = true + + DispatchQueue.main.async { [weak self] in + guard let self else { + return + } + + self.lastCenteredIndex = nil + self.lastCenteredBoundsSize = .zero + self.scrollToSelectedIndexIfNeeded(animated: false) + self.prefetchAroundDisplayedSelectedIndexIfNeeded() + } + } + + func prefetchAroundDisplayedSelectedIndexIfNeeded() { + guard numberOfPages > 0 else { + return + } + + let index = displayedSelectedIndex ?? selectedIndex + + guard index >= 0, + index < numberOfPages else { + return + } + + prefetchThumbnailsAround(index) + } + + func collectionViewBoundsDidChange(_ size: CGSize) { + guard size.width > 0, + size.height > 0 else { + return + } + + guard !isUserScrollingThumbnails else { + return + } + + lastCenteredIndex = nil + lastCenteredBoundsSize = .zero + collectionView?.collectionViewLayout.invalidateLayout() + scrollToSelectedIndexIfNeeded(animated: false) + } + + func scrollToSelectedIndexIfNeeded(animated: Bool) { + guard let collectionView, + numberOfPages > 0 else { + return + } + + guard !isUserScrollingThumbnails else { + return + } + + let index = displayedSelectedIndex ?? selectedIndex + + guard index >= 0, + index < numberOfPages else { + return + } + + let boundsSize = collectionView.bounds.size + + guard boundsSize.width > 0, + boundsSize.height > 0 else { + lastCenteredIndex = nil + lastCenteredBoundsSize = .zero + return + } + + if lastCenteredIndex == index, + lastCenteredBoundsSize == boundsSize { + refreshVisibleCells() + return + } + + collectionView.collectionViewLayout.invalidateLayout() + collectionView.layoutIfNeeded() + updateContentInsetsIfNeeded() + + let indexPath = IndexPath( + item: index, + section: 0 + ) + + guard let attributes = collectionView.layoutAttributesForItem(at: indexPath) else { + collectionView.scrollToItem( + at: indexPath, + at: .centeredHorizontally, + animated: animated + ) + + lastCenteredIndex = index + lastCenteredBoundsSize = boundsSize + + refreshVisibleCells() + return + } + + let targetOffsetX = attributes.center.x - collectionView.bounds.width / 2 + let minOffsetX = -collectionView.adjustedContentInset.left + let maxOffsetX = max( + minOffsetX, + collectionView.contentSize.width - collectionView.bounds.width + collectionView.adjustedContentInset.right + ) + + let clampedOffsetX = min( + max(targetOffsetX, minOffsetX), + maxOffsetX + ) + + collectionView.setContentOffset( + CGPoint( + x: clampedOffsetX, + y: 0 + ), + animated: animated + ) + + lastCenteredIndex = index + lastCenteredBoundsSize = boundsSize + + refreshVisibleCells() + } + + // MARK: - Thumbnail Scroll Selection + + private func selectCenteredThumbnailDuringScrollIfNeeded() { + guard let centeredIndex = centeredThumbnailIndex() else { + return + } + + guard centeredIndex >= 0, + centeredIndex < numberOfPages else { + return + } + + guard lastSentSelectedIndex != centeredIndex else { + return + } + + displayedSelectedIndex = centeredIndex + lastCenteredIndex = nil + lastSentSelectedIndex = centeredIndex + selectionFeedbackGenerator.selectionChanged() + selectionFeedbackGenerator.prepare() + + prefetchThumbnailsAround(centeredIndex) + onSelect(centeredIndex) + } + + private func finishUserThumbnailScroll() { + guard isUserScrollingThumbnails else { + return + } + + isUserScrollingThumbnails = false + shouldEmphasizeSelectedThumbnail = true + lastCenteredIndex = nil + + collectionView?.collectionViewLayout.invalidateLayout() + scrollToSelectedIndexIfNeeded(animated: true) + } + + private func centeredThumbnailIndex() -> Int? { + guard let collectionView else { + return nil + } + + let visibleRect = CGRect( + origin: collectionView.contentOffset, + size: collectionView.bounds.size + ) + + let centerPoint = CGPoint( + x: visibleRect.midX, + y: visibleRect.midY + ) + + if let indexPath = collectionView.indexPathForItem(at: centerPoint) { + return indexPath.item + } + + let visibleIndexPaths = collectionView.indexPathsForVisibleItems + + guard !visibleIndexPaths.isEmpty else { + return nil + } + + return visibleIndexPaths + .compactMap { indexPath -> (index: Int, distance: CGFloat)? in + guard let attributes = collectionView.layoutAttributesForItem(at: indexPath) else { + return nil + } + + return ( + index: indexPath.item, + distance: abs(attributes.center.x - centerPoint.x) + ) + } + .min { $0.distance < $1.distance }? + .index + } + + // MARK: - Insets + + private func updateContentInsetsIfNeeded() { + guard let collectionView else { + return + } + + let selectedItemWidth = NCMediaViewerThumbnailLayout.thumbnailSize + NCMediaViewerThumbnailLayout.selectedExtraWidth + + let horizontalInset = max( + 0, + (collectionView.bounds.width - selectedItemWidth) / 2 + ) + + let contentInset = UIEdgeInsets( + top: 0, + left: horizontalInset, + bottom: 0, + right: horizontalInset + ) + + guard collectionView.contentInset != contentInset else { + return + } + + collectionView.contentInset = contentInset + collectionView.scrollIndicatorInsets = contentInset + } + + // MARK: - Cell Refresh + + private func refreshVisibleCells() { + guard let collectionView else { + return + } + + for indexPath in collectionView.indexPathsForVisibleItems { + guard let cell = collectionView.cellForItem(at: indexPath) as? NCMediaViewerThumbnailUICollectionCell else { + continue + } + + configure( + cell, + at: indexPath.item + ) + } + } + + private func refreshThumbnailIfVisible(at index: Int) { + guard let collectionView else { + return + } + + let indexPath = IndexPath( + item: index, + section: 0 + ) + + guard collectionView.indexPathsForVisibleItems.contains(indexPath), + let cell = collectionView.cellForItem(at: indexPath) as? NCMediaViewerThumbnailUICollectionCell else { + return + } + + configure( + cell, + at: index + ) + } + + // MARK: - Cell Configuration + + private func configure( + _ cell: NCMediaViewerThumbnailUICollectionCell, + at index: Int + ) { + let isDeleted = isDeletedProvider(index) + let metadata = isDeleted ? nil : metadataProvider(index) + let ocId = metadata?.ocId + let isCurrent = shouldEmphasizeSelectedThumbnail && isDisplayedCurrentThumbnail(at: index) + let isVideo = !isDeleted && metadata?.classFile == NKTypeClassFile.video.rawValue + let image = isDeleted ? nil : image(for: ocId) + + if !isDeleted, image == nil { + loadThumbnailIfNeeded( + index: index, + metadata: metadata + ) + } + + cell.configure( + image: image, + isCurrent: isCurrent, + isVideo: isVideo, + isDeleted: isDeleted + ) + } + + private func isDisplayedCurrentThumbnail(at index: Int) -> Bool { + if let displayedSelectedIndex { + return displayedSelectedIndex == index + } + + return selectedIndex == index + } + + private func image(for ocId: String?) -> UIImage? { + guard let ocId, + !ocId.isEmpty else { + return nil + } + + return imageCache.object(forKey: ocId as NSString) + } + + // MARK: - Preview Loading + + private func prefetchThumbnailsAround(_ index: Int) { + guard numberOfPages > 0 else { + return + } + + let radius = NCMediaViewerThumbnailLayout.prefetchRadius + let lowerBound = max(0, index - radius) + let upperBound = min(numberOfPages - 1, index + radius) + + guard lowerBound <= upperBound else { + return + } + + let indexes = (lowerBound...upperBound) + .sorted { + abs($0 - index) < abs($1 - index) + } + + for targetIndex in indexes { + prefetchThumbnail(at: targetIndex) + } + } + + private func prefetchThumbnail(at index: Int) { + guard !isDeletedProvider(index) else { + return + } + + loadThumbnailIfNeeded( + index: index, + metadata: metadataProvider(index) + ) + } + + private func loadThumbnailIfNeeded( + index: Int, + metadata initialMetadata: tableMetadata? + ) { + guard index >= 0, + index < numberOfPages, + !isDeletedProvider(index), + !pendingPrefetchIndexes.contains(index) else { + return + } + + if let ocId = initialMetadata?.ocId, + !ocId.isEmpty, + imageCache.object(forKey: ocId as NSString) != nil { + refreshThumbnailIfVisible(at: index) + return + } + + pendingPrefetchIndexes.insert(index) + + Task { [weak self] in + guard let self else { + return + } + + let metadata = if let initialMetadata { + initialMetadata + } else { + await self.metadataResolver(index) + } + + guard let metadata, + !metadata.ocId.isEmpty else { + await MainActor.run { + _ = self.pendingPrefetchIndexes.remove(index) + } + return + } + + guard !self.isDeletedProvider(index) else { + await MainActor.run { + _ = self.pendingPrefetchIndexes.remove(index) + } + return + } + + guard let previewURL = await self.previewURLProvider(metadata) else { + await MainActor.run { + _ = self.pendingPrefetchIndexes.remove(index) + } + return + } + + guard let image = await Self.makeImage(from: previewURL) else { + await MainActor.run { + _ = self.pendingPrefetchIndexes.remove(index) + } + return + } + + await MainActor.run { + _ = self.pendingPrefetchIndexes.remove(index) + + guard !self.isDeletedProvider(index) else { + return + } + + self.imageCache.setObject( + image, + forKey: metadata.ocId as NSString + ) + + self.refreshThumbnailIfVisible(at: index) + } + } + } + + private static func makeImage(from previewURL: URL?) async -> UIImage? { + guard let previewURL else { + return nil + } + + return await Task.detached(priority: .utility) { + UIImage(contentsOfFile: previewURL.path) + }.value + } + } +} + +// MARK: - Collection View + +private final class NCMediaViewerThumbnailCollectionView: UICollectionView { + var onBoundsSizeChanged: ((CGSize) -> Void)? + + private var lastBoundsSize: CGSize = .zero + + override func layoutSubviews() { + super.layoutSubviews() + + let currentBoundsSize = bounds.size + + guard currentBoundsSize != lastBoundsSize else { + return + } + + lastBoundsSize = currentBoundsSize + onBoundsSizeChanged?(currentBoundsSize) + } +} + +private final class NCMediaViewerThumbnailUICollectionCell: UICollectionViewCell { + static let reuseIdentifier = "NCMediaViewerThumbnailUICollectionCell" + + private let imageView = UIImageView() + private let placeholderView = UIView() + private let placeholderIconView = UIImageView(image: UIImage(systemName: "photo")) + private let playIconView = UIImageView(image: UIImage(systemName: "play.fill")) + + private var isCurrentThumbnail = false + + override init(frame: CGRect) { + super.init(frame: frame) + + setupViews() + } + + required init?(coder: NSCoder) { + nil + } + + override func prepareForReuse() { + super.prepareForReuse() + + isCurrentThumbnail = false + imageView.image = nil + placeholderIconView.image = UIImage(systemName: "photo") + playIconView.isHidden = true + placeholderView.isHidden = false + layer.zPosition = 0 + isSelected = false + isHighlighted = false + } + + override var isSelected: Bool { + didSet { + super.isSelected = false + } + } + + override var isHighlighted: Bool { + didSet { + super.isHighlighted = false + } + } + + override func layoutSubviews() { + super.layoutSubviews() + + let thumbnailSize = isCurrentThumbnail + ? NCMediaViewerThumbnailLayout.selectedThumbnailSize + : NCMediaViewerThumbnailLayout.thumbnailSize + + let thumbnailFrame = CGRect( + x: (contentView.bounds.width - thumbnailSize) / 2, + y: (contentView.bounds.height - thumbnailSize) / 2, + width: thumbnailSize, + height: thumbnailSize + ) + + imageView.frame = thumbnailFrame + placeholderView.frame = thumbnailFrame + + placeholderIconView.center = CGPoint( + x: placeholderView.bounds.midX, + y: placeholderView.bounds.midY + ) + + playIconView.center = CGPoint( + x: thumbnailFrame.midX, + y: thumbnailFrame.midY + ) + } + + func configure( + image: UIImage?, + isCurrent: Bool, + isVideo: Bool, + isDeleted: Bool + ) { + isCurrentThumbnail = isCurrent + imageView.image = isDeleted ? nil : image + placeholderView.isHidden = imageView.image != nil + placeholderIconView.image = UIImage(systemName: isDeleted ? "trash" : "photo")? + .withRenderingMode(.alwaysTemplate) + placeholderIconView.tintColor = .systemGray + playIconView.image = playIconView.image?.withRenderingMode(.alwaysTemplate) + playIconView.tintColor = .systemGray + playIconView.isHidden = isDeleted || !isVideo + layer.zPosition = isCurrent ? 10 : 0 + + setNeedsLayout() + } + + private func setupViews() { + contentView.clipsToBounds = false + + imageView.contentMode = .scaleAspectFill + imageView.clipsToBounds = true + imageView.layer.cornerRadius = NCMediaViewerThumbnailLayout.cornerRadius + imageView.layer.cornerCurve = .continuous + + placeholderView.backgroundColor = UIColor.white.withAlphaComponent(0.16) + placeholderView.clipsToBounds = true + placeholderView.layer.cornerRadius = NCMediaViewerThumbnailLayout.cornerRadius + placeholderView.layer.cornerCurve = .continuous + + placeholderIconView.tintColor = UIColor.white.withAlphaComponent(0.75) + placeholderIconView.contentMode = .center + placeholderIconView.preferredSymbolConfiguration = UIImage.SymbolConfiguration( + pointSize: 18, + weight: .medium + ) + + playIconView.tintColor = .white + playIconView.contentMode = .center + playIconView.preferredSymbolConfiguration = UIImage.SymbolConfiguration( + pointSize: 14, + weight: .semibold + ) + playIconView.layer.shadowColor = UIColor.black.cgColor + playIconView.layer.shadowOpacity = 0.35 + playIconView.layer.shadowRadius = 4 + playIconView.layer.shadowOffset = CGSize( + width: 0, + height: 2 + ) + + contentView.addSubview(imageView) + contentView.addSubview(placeholderView) + placeholderView.addSubview(placeholderIconView) + contentView.addSubview(playIconView) + } +} 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 191e681307..132065b771 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) } } @@ -187,6 +191,7 @@ class NCViewerRichDocument: UIViewController, WKNavigationDelegate, WKScriptMess if message.body as? String == "share" { NCCreate().createShare(controller: self.controller, + presentViewController: self.controller, metadata: metadata, page: .sharing) }