diff --git a/Nextcloud.xcodeproj/project.pbxproj b/Nextcloud.xcodeproj/project.pbxproj index da11cd9ed5..518ff4f43b 100644 --- a/Nextcloud.xcodeproj/project.pbxproj +++ b/Nextcloud.xcodeproj/project.pbxproj @@ -11,7 +11,6 @@ 2C1D5D7923E2DE9100334ABB /* NCBrand.swift in Sources */ = {isa = PBXBuildFile; fileRef = F76B3CCD1EAE01BD00921AC9 /* NCBrand.swift */; }; 2C33C48223E2C475005F963B /* NotificationService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 2C33C48123E2C475005F963B /* NotificationService.swift */; }; 2C33C48623E2C475005F963B /* Notification Service Extension.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = 2C33C47F23E2C475005F963B /* Notification Service Extension.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; - 2F96A1BAFB10ACFEAC68EF1C /* NCContextMenuPlayerTracks.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4C7A5B36D1ED178FB6B76CB /* NCContextMenuPlayerTracks.swift */; }; 370D26AF248A3D7A00121797 /* NCCellMain.swift in Sources */ = {isa = PBXBuildFile; fileRef = 370D26AE248A3D7A00121797 /* NCCellMain.swift */; }; A5A87F9E4B0E4441A6A4BC20 /* NCContextMenuProfile.swift in Sources */ = {isa = PBXBuildFile; fileRef = BB7697C94BA14450A0867940 /* NCContextMenuProfile.swift */; }; AA3C85E82D36B08C00F74F12 /* UITestBackend.swift in Sources */ = {isa = PBXBuildFile; fileRef = AA3C85E72D36B08C00F74F12 /* UITestBackend.swift */; }; @@ -86,7 +85,6 @@ AFCE353927E5DE0500FEA6C2 /* Shareable.swift in Sources */ = {isa = PBXBuildFile; fileRef = AFCE353827E5DE0400FEA6C2 /* Shareable.swift */; }; CB3666201AF7550816B5CD6A /* NCContextMenuComment.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8932E90EC4278026D86CCCC9 /* NCContextMenuComment.swift */; }; D5B6AA7827200C7200D49C24 /* NCActivityTableViewCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = D5B6AA7727200C7200D49C24 /* NCActivityTableViewCell.swift */; }; - F310B1EF2BA862F1001C42F5 /* NCViewerMedia+VisionKit.swift in Sources */ = {isa = PBXBuildFile; fileRef = F310B1EE2BA862F1001C42F5 /* NCViewerMedia+VisionKit.swift */; }; F31165022F9674A1009A1E37 /* AppIcon.icon in Resources */ = {isa = PBXBuildFile; fileRef = F31165012F9674A1009A1E37 /* AppIcon.icon */; }; F317C82E2E844C5300761AEA /* ClientIntegrationUIViewer.swift in Sources */ = {isa = PBXBuildFile; fileRef = F317C82D2E844C5300761AEA /* ClientIntegrationUIViewer.swift */; }; F321DA8A2B71205A00DDA0E6 /* NCTrashSelectTabBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = F321DA892B71205A00DDA0E6 /* NCTrashSelectTabBar.swift */; }; @@ -205,9 +203,6 @@ F70557BF2ED44F1800135623 /* UploadBannerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F70557BB2ED44F1800135623 /* UploadBannerView.swift */; }; F70716E62987F81500E72C1D /* DocumentActionViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = F70716E52987F81500E72C1D /* DocumentActionViewController.swift */; }; F70716ED2987F81500E72C1D /* File Provider Extension UI.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = F70716E32987F81500E72C1D /* File Provider Extension UI.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; - F70753EB2542A99800972D44 /* NCViewerMediaPage.swift in Sources */ = {isa = PBXBuildFile; fileRef = F70753EA2542A99800972D44 /* NCViewerMediaPage.swift */; }; - F70753F12542A9A200972D44 /* NCViewerMedia.swift in Sources */ = {isa = PBXBuildFile; fileRef = F70753F02542A9A200972D44 /* NCViewerMedia.swift */; }; - F70753F72542A9C000972D44 /* NCViewerMediaPage.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = F70753F62542A9C000972D44 /* NCViewerMediaPage.storyboard */; }; F707C26521A2DC5200F6181E /* NCStoreReview.swift in Sources */ = {isa = PBXBuildFile; fileRef = F707C26421A2DC5200F6181E /* NCStoreReview.swift */; }; F70821D829E59E6D001CA2D7 /* TagListView in Frameworks */ = {isa = PBXBuildFile; productRef = F70821D729E59E6D001CA2D7 /* TagListView */; }; F70898672EDDB39B00EF85BD /* NCNetworking+TransferDelegate.swift in Sources */ = {isa = PBXBuildFile; fileRef = F70898662EDDB39300EF85BD /* NCNetworking+TransferDelegate.swift */; }; @@ -253,9 +248,10 @@ F7160A822BE933390034DCB3 /* RealmSwift in Frameworks */ = {isa = PBXBuildFile; productRef = F7160A812BE933390034DCB3 /* RealmSwift */; }; F71638922FA0C20C00A913B7 /* NCMoreView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F71638912FA0C1FC00A913B7 /* NCMoreView.swift */; }; F71638942FA0F65A00A913B7 /* NCMoreModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F71638932FA0F64B00A913B7 /* NCMoreModel.swift */; }; + F716DA652FA4E87B006A6703 /* NCImageZoomView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F716DA642FA4E878006A6703 /* NCImageZoomView.swift */; }; + F716DA672FA5F01A006A6703 /* NCMediaViewerPagingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F716DA662FA5F019006A6703 /* NCMediaViewerPagingView.swift */; }; F717402D24F699A5000C87D5 /* NCFavorite.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = F717402B24F699A5000C87D5 /* NCFavorite.storyboard */; }; F717402E24F699A5000C87D5 /* NCFavorite.swift in Sources */ = {isa = PBXBuildFile; fileRef = F717402C24F699A5000C87D5 /* NCFavorite.swift */; }; - F718C24E254D507B00C5C256 /* NCViewerMediaDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F718C24D254D507B00C5C256 /* NCViewerMediaDetailView.swift */; }; F718E25A2DF2D5D1004038AF /* NCBackgroundLocationUploadManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = F718E2572DF2D5C3004038AF /* NCBackgroundLocationUploadManager.swift */; }; F71916122E2901FB00E13E96 /* NCNetworking+Upload.swift in Sources */ = {isa = PBXBuildFile; fileRef = F71916102E2901E800E13E96 /* NCNetworking+Upload.swift */; }; F71916142E2901FB00E13E96 /* NCNetworking+Upload.swift in Sources */ = {isa = PBXBuildFile; fileRef = F71916102E2901E800E13E96 /* NCNetworking+Upload.swift */; }; @@ -272,6 +268,7 @@ F71F6D0C2B6A6A5E00F1EB15 /* ThreadSafeArray.swift in Sources */ = {isa = PBXBuildFile; fileRef = F71F6D062B6A6A5E00F1EB15 /* ThreadSafeArray.swift */; }; F71F6D0D2B6A6A5E00F1EB15 /* ThreadSafeArray.swift in Sources */ = {isa = PBXBuildFile; fileRef = F71F6D062B6A6A5E00F1EB15 /* ThreadSafeArray.swift */; }; F71FA7992F3508C600E86192 /* NCNetworking+WebDAV.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7327E2F2B73A86700A462C7 /* NCNetworking+WebDAV.swift */; }; + F721C50A2FB6F9AA00207DA9 /* NCCollectionViewCommon+TransitionSourceBlink.swift in Sources */ = {isa = PBXBuildFile; fileRef = F721C5092FB6F9AA00207DA9 /* NCCollectionViewCommon+TransitionSourceBlink.swift */; }; F722133B2D40EF9D002F7438 /* NCFilesNavigationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = F722133A2D40EF8C002F7438 /* NCFilesNavigationController.swift */; }; F7226EDC1EE4089300EBECB1 /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = F7226EDB1EE4089300EBECB1 /* Main.storyboard */; }; F722F0112CFF569500065FB5 /* MainInterface.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = F722F0102CFF569500065FB5 /* MainInterface.storyboard */; }; @@ -325,7 +322,6 @@ F7327E302B73A86700A462C7 /* NCNetworking+WebDAV.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7327E2F2B73A86700A462C7 /* NCNetworking+WebDAV.swift */; }; F7327E352B73AEDE00A462C7 /* NCNetworking+LivePhoto.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7327E342B73AEDE00A462C7 /* NCNetworking+LivePhoto.swift */; }; F7327E3B2B73B8D600A462C7 /* Array+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7AC1CAF28AB94490032D99F /* Array+Extension.swift */; }; - F732D23327CF8AED000B0F1B /* NCPlayerToolBar.xib in Resources */ = {isa = PBXBuildFile; fileRef = F732D23227CF8AED000B0F1B /* NCPlayerToolBar.xib */; }; F733598125C1C188002ABA72 /* NCAskAuthorization.swift in Sources */ = {isa = PBXBuildFile; fileRef = F733598025C1C188002ABA72 /* NCAskAuthorization.swift */; }; F7346E1628B0EF5C006CE2D2 /* Widget.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7346E1528B0EF5C006CE2D2 /* Widget.swift */; }; F7346E1C28B0EF5E006CE2D2 /* Widget.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = F7346E1028B0EF5B006CE2D2 /* Widget.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; @@ -395,6 +391,7 @@ F749B654297B0F2400087535 /* NCManageDatabase+Avatar.swift in Sources */ = {isa = PBXBuildFile; fileRef = F749B650297B0F2400087535 /* NCManageDatabase+Avatar.swift */; }; F749B656297B0F2400087535 /* NCManageDatabase+Avatar.swift in Sources */ = {isa = PBXBuildFile; fileRef = F749B650297B0F2400087535 /* NCManageDatabase+Avatar.swift */; }; F749E4E91DC1FB38009BA2FD /* Share.appex in Embed Foundation Extensions */ = {isa = PBXBuildFile; fileRef = F7CE8AFB1DC1F8D8009CAE48 /* Share.appex */; settings = {ATTRIBUTES = (RemoveHeadersOnCopy, ); }; }; + F749ED312FADD62600CE8DFA /* NCMediaViewerDetailView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F749ED302FADD62400CE8DFA /* NCMediaViewerDetailView.swift */; }; F74AF3A4247FB6AE00AC767B /* NCUtilityFileSystem.swift in Sources */ = {isa = PBXBuildFile; fileRef = F74AF3A3247FB6AE00AC767B /* NCUtilityFileSystem.swift */; }; F74AF3A5247FB6AE00AC767B /* NCUtilityFileSystem.swift in Sources */ = {isa = PBXBuildFile; fileRef = F74AF3A3247FB6AE00AC767B /* NCUtilityFileSystem.swift */; }; F74B6D952A7E239A00F03C5F /* NCManageDatabase+Chunk.swift in Sources */ = {isa = PBXBuildFile; fileRef = F74B6D942A7E239A00F03C5F /* NCManageDatabase+Chunk.swift */; }; @@ -412,12 +409,15 @@ F74C863D2AEFBFD9009A1D4A /* LRUCache in Frameworks */ = {isa = PBXBuildFile; productRef = F74C863C2AEFBFD9009A1D4A /* LRUCache */; }; F74D50352C9855A000BBBF4C /* NCCollectionViewCommon+CollectionViewDataSourcePrefetching.swift in Sources */ = {isa = PBXBuildFile; fileRef = F74D50342C9855A000BBBF4C /* NCCollectionViewCommon+CollectionViewDataSourcePrefetching.swift */; }; F74D50362C9856D300BBBF4C /* NCCollectionViewDataSource.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7C1EEA425053A9C00866ACC /* NCCollectionViewDataSource.swift */; }; + F74E3EEB2FB0AD8500252FA0 /* Notification+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = F74E3EEA2FB0AD7800252FA0 /* Notification+Extension.swift */; }; F7501C322212E57500FB1415 /* NCMedia.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = F7501C302212E57400FB1415 /* NCMedia.storyboard */; }; F7501C332212E57500FB1415 /* NCMedia.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7501C312212E57400FB1415 /* NCMedia.swift */; }; F751247C2C42919C00E63DB8 /* NCPhotoCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = F751247A2C42919C00E63DB8 /* NCPhotoCell.swift */; }; F751247E2C42919C00E63DB8 /* NCPhotoCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = F751247B2C42919C00E63DB8 /* NCPhotoCell.xib */; }; F752BA052E58C05200616A26 /* Maintenance.swift in Sources */ = {isa = PBXBuildFile; fileRef = F752BA042E58C05200616A26 /* Maintenance.swift */; }; F753BA93281FD8020015BFB6 /* EasyTipView in Frameworks */ = {isa = PBXBuildFile; productRef = F753BA92281FD8020015BFB6 /* EasyTipView */; }; + F7547FE32FB742A400E372C3 /* NCVideoVLCViewControls.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7547FE22FB7429200E372C3 /* NCVideoVLCViewControls.swift */; }; + F7547FE62FB76C1900E372C3 /* NCVideoControlsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7547FE52FB76C1800E372C3 /* NCVideoControlsView.swift */; }; F755BD9B20594AC7008C5FBB /* NCService.swift in Sources */ = {isa = PBXBuildFile; fileRef = F755BD9A20594AC7008C5FBB /* NCService.swift */; }; F755CB402B8CB13C00CE27E9 /* NCMediaLayout.swift in Sources */ = {isa = PBXBuildFile; fileRef = F755CB3F2B8CB13C00CE27E9 /* NCMediaLayout.swift */; }; F757CC8229E7F88B00F31428 /* NCManageDatabase+Groupfolders.swift in Sources */ = {isa = PBXBuildFile; fileRef = F757CC8129E7F88B00F31428 /* NCManageDatabase+Groupfolders.swift */; }; @@ -479,6 +479,7 @@ F763413D2EBE5DBB0056F538 /* FileProviderExtension+Thumbnail.swift in Sources */ = {isa = PBXBuildFile; fileRef = F771E3F520E239B400AFB62D /* FileProviderExtension+Thumbnail.swift */; }; F763413E2EBE5DC00056F538 /* FileProviderItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = F771E3D420E2392D00AFB62D /* FileProviderItem.swift */; }; F763413F2EBE5DC40056F538 /* FileProviderUtility.swift in Sources */ = {isa = PBXBuildFile; fileRef = F76673EF22C90433007ED366 /* FileProviderUtility.swift */; }; + F7635D8D2FB1F820007F658D /* NCVideoVLCPresenter.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7635D8C2FB1F81D007F658D /* NCVideoVLCPresenter.swift */; }; F763D29D2A249C4500A3C901 /* NCManageDatabase+Capabilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = F763D29C2A249C4500A3C901 /* NCManageDatabase+Capabilities.swift */; }; F763D29E2A249C4500A3C901 /* NCManageDatabase+Capabilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = F763D29C2A249C4500A3C901 /* NCManageDatabase+Capabilities.swift */; }; F763D29F2A249C4500A3C901 /* NCManageDatabase+Capabilities.swift in Sources */ = {isa = PBXBuildFile; fileRef = F763D29C2A249C4500A3C901 /* NCManageDatabase+Capabilities.swift */; }; @@ -604,6 +605,9 @@ F783030328B4C4DD00B84583 /* ThreadSafeDictionary.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7245923289BB50B00474787 /* ThreadSafeDictionary.swift */; }; F783030728B4C52800B84583 /* UIColor+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = F70CEF5523E9C7E50007035B /* UIColor+Extension.swift */; }; F783034428B5142B00B84583 /* NextcloudKit in Frameworks */ = {isa = PBXBuildFile; productRef = F783034328B5142B00B84583 /* NextcloudKit */; }; + F78448B52FB1BE9000F2909A /* NCVideoViewerContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F78448A82FB1BE9000F2909A /* NCVideoViewerContentView.swift */; }; + F78448BA2FB1BE9000F2909A /* NCVideoPlaybackController.swift in Sources */ = {isa = PBXBuildFile; fileRef = F78448A32FB1BE9000F2909A /* NCVideoPlaybackController.swift */; }; + F78448BE2FB1C33B00F2909A /* NCVideoVLCViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = F78448BD2FB1C33B00F2909A /* NCVideoVLCViewController.swift */; }; F785129C2D7989B30087DDD0 /* NCNetworking+TermsOfService.swift in Sources */ = {isa = PBXBuildFile; fileRef = F785129A2D79899E0087DDD0 /* NCNetworking+TermsOfService.swift */; }; F785EE9D246196DF00B3F945 /* NCNetworkingE2EE.swift in Sources */ = {isa = PBXBuildFile; fileRef = F785EE9C246196DF00B3F945 /* NCNetworkingE2EE.swift */; }; F785EE9E2461A09900B3F945 /* NCNetworking.swift in Sources */ = {isa = PBXBuildFile; fileRef = F75A9EE523796C6F0044CFCE /* NCNetworking.swift */; }; @@ -644,7 +648,10 @@ F78F74342163757000C2ADAD /* NCTrash.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = F78F74332163757000C2ADAD /* NCTrash.storyboard */; }; F78F74362163781100C2ADAD /* NCTrash.swift in Sources */ = {isa = PBXBuildFile; fileRef = F78F74352163781100C2ADAD /* NCTrash.swift */; }; F790110E21415BF600D7B136 /* NCViewerRichDocument.swift in Sources */ = {isa = PBXBuildFile; fileRef = F790110D21415BF600D7B136 /* NCViewerRichDocument.swift */; }; + F79377052FBD86AF00DE56DE /* 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 */; }; @@ -665,8 +672,6 @@ F79EC78926316AC4004E59D6 /* NCPopupViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = F702F30725EE5D47008F8E80 /* NCPopupViewController.swift */; }; F79ED0F12D2FCA5B00A389D9 /* NCSectionFirstHeader.swift in Sources */ = {isa = PBXBuildFile; fileRef = F78ACD51219046DC0088454D /* NCSectionFirstHeader.swift */; }; F79ED0F22D2FCA6A00A389D9 /* NCRecommendationsCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = F75D901E2D2BE12E003E740B /* NCRecommendationsCell.xib */; }; - F79EDAA326B004980007D134 /* NCPlayerToolBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = F79EDA9F26B004980007D134 /* NCPlayerToolBar.swift */; }; - F79EDAA526B004980007D134 /* NCPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = F79EDAA126B004980007D134 /* NCPlayer.swift */; }; F79FFB262A97C24A0055EEA4 /* NCNetworkingE2EEMarkFolder.swift in Sources */ = {isa = PBXBuildFile; fileRef = F79FFB252A97C24A0055EEA4 /* NCNetworkingE2EEMarkFolder.swift */; }; F79FFB272A97C24A0055EEA4 /* NCNetworkingE2EEMarkFolder.swift in Sources */ = {isa = PBXBuildFile; fileRef = F79FFB252A97C24A0055EEA4 /* NCNetworkingE2EEMarkFolder.swift */; }; F7A03E2F2D425A14007AA677 /* NCFavoriteNavigationController.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7A03E2E2D425A14007AA677 /* NCFavoriteNavigationController.swift */; }; @@ -701,6 +706,10 @@ F7A8D74128F18254008BBE1C /* UIColor+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = F70CEF5523E9C7E50007035B /* UIColor+Extension.swift */; }; F7A8D74228F18261008BBE1C /* NCUtility.swift in Sources */ = {isa = PBXBuildFile; fileRef = F70BFC7320E0FA7C00C67599 /* NCUtility.swift */; }; F7A8D74428F1827B008BBE1C /* ThreadSafeDictionary.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7245923289BB50B00474787 /* ThreadSafeDictionary.swift */; }; + F7A98A4E2FC97414009E6313 /* NCVideoURLResolver.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7A98A4D2FC97414009E6313 /* NCVideoURLResolver.swift */; }; + F7A98A502FC9744A009E6313 /* NCVideoPlaybackCoverView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7A98A4F2FC9744A009E6313 /* NCVideoPlaybackCoverView.swift */; }; + F7A98A522FC97464009E6313 /* NCVideoViewerContentView+AVPlayer.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7A98A512FC97464009E6313 /* NCVideoViewerContentView+AVPlayer.swift */; }; + F7A98A542FC9746C009E6313 /* NCVideoViewerContentView+VLC.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7A98A532FC9746C009E6313 /* NCVideoViewerContentView+VLC.swift */; }; F7AC1CB028AB94490032D99F /* Array+Extension.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7AC1CAF28AB94490032D99F /* Array+Extension.swift */; }; F7AC934A296193050002BC0F /* Reasons to use Nextcloud.pdf in Resources */ = {isa = PBXBuildFile; fileRef = F7AC9349296193050002BC0F /* Reasons to use Nextcloud.pdf */; }; F7AE00F5230D5F9E007ACF8A /* NCLoginProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7AE00F4230D5F9E007ACF8A /* NCLoginProvider.swift */; }; @@ -788,6 +797,12 @@ F7CBC1252BAC8B0000EC1D55 /* NCSectionFirstHeaderEmptyData.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7CBC1222BAC8B0000EC1D55 /* NCSectionFirstHeaderEmptyData.swift */; }; F7CBC1262BAC8B0000EC1D55 /* NCSectionFirstHeaderEmptyData.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7CBC1222BAC8B0000EC1D55 /* NCSectionFirstHeaderEmptyData.swift */; }; F7CCAB512ECF316700F8E68B /* NCCollectionViewCommon+SyncMetadata.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7CCAB502ECF315F00F8E68B /* NCCollectionViewCommon+SyncMetadata.swift */; }; + F7CDB5C32FA33CA300F72306 /* NCMediaViewerPageView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7CDB5B92FA33CA300F72306 /* NCMediaViewerPageView.swift */; }; + F7CDB5C42FA33CA300F72306 /* NCImageViewerContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7CDB5B62FA33CA300F72306 /* NCImageViewerContentView.swift */; }; + F7CDB5C52FA33CA300F72306 /* NCMediaViewerModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7CDB5B82FA33CA300F72306 /* NCMediaViewerModel.swift */; }; + F7CDB5C62FA33CA300F72306 /* NCMediaViewerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7CDB5BB2FA33CA300F72306 /* NCMediaViewerView.swift */; }; + F7CDB5CC2FA33CA300F72306 /* NCNextcloudMediaViewerLoader.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7CDB5BD2FA33CA300F72306 /* NCNextcloudMediaViewerLoader.swift */; }; + F7CDB5D32FA3448B00F72306 /* NCAudioViewerContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7CDB5D22FA3448A00F72306 /* NCAudioViewerContentView.swift */; }; F7CEE6002BA9A5C9003EFD89 /* NCTrashGridCell.xib in Resources */ = {isa = PBXBuildFile; fileRef = F7CEE5FE2BA9A5C9003EFD89 /* NCTrashGridCell.xib */; }; F7CEE6012BA9A5C9003EFD89 /* NCTrashGridCell.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7CEE5FF2BA9A5C9003EFD89 /* NCTrashGridCell.swift */; }; F7CF06802E0FF3990063AD04 /* NCAppStateManager.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7CF067A2E0FF38F0063AD04 /* NCAppStateManager.swift */; }; @@ -897,6 +912,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 */; }; @@ -928,6 +949,7 @@ F7FA7FFC2C0F4EE40072FC60 /* NCViewerQuickLookView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7FA7FFB2C0F4EE40072FC60 /* NCViewerQuickLookView.swift */; }; F7FA80002C0F4F3B0072FC60 /* NCUploadAssetsModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7FA7FFE2C0F4F3B0072FC60 /* NCUploadAssetsModel.swift */; }; F7FA80012C0F4F3B0072FC60 /* NCUploadAssetsView.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7FA7FFF2C0F4F3B0072FC60 /* NCUploadAssetsView.swift */; }; + F7FAAC222FB773CC00DCA45B /* NCVideoAVPlayerViewControls.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7FAAC212FB773CA00DCA45B /* NCVideoAVPlayerViewControls.swift */; }; F7FAFD3A28BFA948000777FE /* NCContextMenuNotification.swift in Sources */ = {isa = PBXBuildFile; fileRef = F7FAFD3928BFA947000777FE /* NCContextMenuNotification.swift */; }; F7FDFF692E437E55000D7688 /* NCAccountRequest.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = F7FDFF512E437E55000D7688 /* NCAccountRequest.storyboard */; }; F7FDFF6A2E437E55000D7688 /* NCShareAccounts.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = F7FDFF532E437E55000D7688 /* NCShareAccounts.storyboard */; }; @@ -1261,12 +1283,10 @@ AFCE353427E4ED5900FEA6C2 /* DateFormatter+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "DateFormatter+Extension.swift"; sourceTree = ""; }; AFCE353627E4ED7B00FEA6C2 /* NCShareCells.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCShareCells.swift; sourceTree = ""; }; AFCE353827E5DE0400FEA6C2 /* Shareable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Shareable.swift; sourceTree = ""; }; - B4C7A5B36D1ED178FB6B76CB /* NCContextMenuPlayerTracks.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCContextMenuPlayerTracks.swift; sourceTree = ""; }; BB7697C94BA14450A0867940 /* NCContextMenuProfile.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCContextMenuProfile.swift; sourceTree = ""; }; C0046CDA2A17B98400D87C9D /* NextcloudUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = NextcloudUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; C04E2F202A17BB4D001BAD85 /* NextcloudIntegrationTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = NextcloudIntegrationTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; D5B6AA7727200C7200D49C24 /* NCActivityTableViewCell.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCActivityTableViewCell.swift; sourceTree = ""; }; - F310B1EE2BA862F1001C42F5 /* NCViewerMedia+VisionKit.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NCViewerMedia+VisionKit.swift"; sourceTree = ""; }; F31165012F9674A1009A1E37 /* AppIcon.icon */ = {isa = PBXFileReference; lastKnownFileType = folder.iconcomposer.icon; path = AppIcon.icon; sourceTree = ""; }; F317C82D2E844C5300761AEA /* ClientIntegrationUIViewer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClientIntegrationUIViewer.swift; sourceTree = ""; }; F321DA892B71205A00DDA0E6 /* NCTrashSelectTabBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCTrashSelectTabBar.swift; sourceTree = ""; }; @@ -1331,9 +1351,6 @@ F70557BB2ED44F1800135623 /* UploadBannerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = UploadBannerView.swift; sourceTree = ""; }; F70716E32987F81500E72C1D /* File Provider Extension UI.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = "File Provider Extension UI.appex"; sourceTree = BUILT_PRODUCTS_DIR; }; F70716E52987F81500E72C1D /* DocumentActionViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DocumentActionViewController.swift; sourceTree = ""; }; - F70753EA2542A99800972D44 /* NCViewerMediaPage.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NCViewerMediaPage.swift; sourceTree = ""; }; - F70753F02542A9A200972D44 /* NCViewerMedia.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NCViewerMedia.swift; sourceTree = ""; }; - F70753F62542A9C000972D44 /* NCViewerMediaPage.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = NCViewerMediaPage.storyboard; sourceTree = ""; }; F707C26421A2DC5200F6181E /* NCStoreReview.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCStoreReview.swift; sourceTree = ""; }; F70898662EDDB39300EF85BD /* NCNetworking+TransferDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NCNetworking+TransferDelegate.swift"; sourceTree = ""; }; F70898682EDDB51200EF85BD /* NCSelectOpen+SelectDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NCSelectOpen+SelectDelegate.swift"; sourceTree = ""; }; @@ -1366,9 +1383,10 @@ F71638932FA0F64B00A913B7 /* NCMoreModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCMoreModel.swift; sourceTree = ""; }; F7169A301EE59BB70086BD69 /* it */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = it; path = it.lproj/Localizable.strings; sourceTree = ""; }; F7169A4C1EE59C640086BD69 /* tr */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = tr; path = tr.lproj/Localizable.strings; sourceTree = ""; }; + F716DA642FA4E878006A6703 /* NCImageZoomView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCImageZoomView.swift; sourceTree = ""; }; + F716DA662FA5F019006A6703 /* NCMediaViewerPagingView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCMediaViewerPagingView.swift; sourceTree = ""; }; F717402B24F699A5000C87D5 /* NCFavorite.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; path = NCFavorite.storyboard; sourceTree = ""; }; F717402C24F699A5000C87D5 /* NCFavorite.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NCFavorite.swift; sourceTree = ""; }; - F718C24D254D507B00C5C256 /* NCViewerMediaDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCViewerMediaDetailView.swift; sourceTree = ""; }; F718E2572DF2D5C3004038AF /* NCBackgroundLocationUploadManager.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCBackgroundLocationUploadManager.swift; sourceTree = ""; }; F71916102E2901E800E13E96 /* NCNetworking+Upload.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NCNetworking+Upload.swift"; sourceTree = ""; }; F719D9DF288D37A300762E33 /* NCColorPicker.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = NCColorPicker.storyboard; sourceTree = ""; }; @@ -1376,6 +1394,7 @@ F71CFA662F2A07C6007A3AE9 /* NCMedia+Netwoking.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NCMedia+Netwoking.swift"; sourceTree = ""; }; F71D2FB62E09BBD700B751CC /* NCAutoUploadModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCAutoUploadModel.swift; sourceTree = ""; }; F71F6D062B6A6A5E00F1EB15 /* ThreadSafeArray.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ThreadSafeArray.swift; sourceTree = ""; }; + F721C5092FB6F9AA00207DA9 /* NCCollectionViewCommon+TransitionSourceBlink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NCCollectionViewCommon+TransitionSourceBlink.swift"; sourceTree = ""; }; F722133A2D40EF8C002F7438 /* NCFilesNavigationController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCFilesNavigationController.swift; sourceTree = ""; }; F7226EDB1EE4089300EBECB1 /* Main.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; path = Main.storyboard; sourceTree = ""; }; F722F0102CFF569500065FB5 /* MainInterface.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = MainInterface.storyboard; sourceTree = ""; }; @@ -1408,7 +1427,6 @@ F7327E1F2B73A42F00A462C7 /* NCNetworking+Download.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NCNetworking+Download.swift"; sourceTree = ""; }; F7327E2F2B73A86700A462C7 /* NCNetworking+WebDAV.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NCNetworking+WebDAV.swift"; sourceTree = ""; }; F7327E342B73AEDE00A462C7 /* NCNetworking+LivePhoto.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NCNetworking+LivePhoto.swift"; sourceTree = ""; }; - F732D23227CF8AED000B0F1B /* NCPlayerToolBar.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; path = NCPlayerToolBar.xib; sourceTree = ""; }; F733598025C1C188002ABA72 /* NCAskAuthorization.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCAskAuthorization.swift; sourceTree = ""; }; F7346E1028B0EF5B006CE2D2 /* Widget.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = Widget.appex; sourceTree = BUILT_PRODUCTS_DIR; }; F7346E1528B0EF5C006CE2D2 /* Widget.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Widget.swift; sourceTree = ""; }; @@ -1436,6 +1454,7 @@ F747EB0C2C4AC1FF00F959A8 /* NCCollectionViewCommon+CollectionViewDelegateFlowLayout.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NCCollectionViewCommon+CollectionViewDelegateFlowLayout.swift"; sourceTree = ""; }; F749B649297B0CBB00087535 /* NCManageDatabase+Share.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NCManageDatabase+Share.swift"; sourceTree = ""; }; F749B650297B0F2400087535 /* NCManageDatabase+Avatar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NCManageDatabase+Avatar.swift"; sourceTree = ""; }; + F749ED302FADD62400CE8DFA /* NCMediaViewerDetailView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCMediaViewerDetailView.swift; sourceTree = ""; }; F74AF3A3247FB6AE00AC767B /* NCUtilityFileSystem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCUtilityFileSystem.swift; sourceTree = ""; }; F74B6D942A7E239A00F03C5F /* NCManageDatabase+Chunk.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NCManageDatabase+Chunk.swift"; sourceTree = ""; }; F74B91E42F51D4100050813D /* InfoBannerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InfoBannerView.swift; sourceTree = ""; }; @@ -1443,6 +1462,7 @@ F74C0434253F1CDC009762AB /* NCShares.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NCShares.swift; sourceTree = ""; }; F74C0435253F1CDC009762AB /* NCShares.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; path = NCShares.storyboard; sourceTree = ""; }; F74D50342C9855A000BBBF4C /* NCCollectionViewCommon+CollectionViewDataSourcePrefetching.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NCCollectionViewCommon+CollectionViewDataSourcePrefetching.swift"; sourceTree = ""; }; + F74E3EEA2FB0AD7800252FA0 /* Notification+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Notification+Extension.swift"; sourceTree = ""; }; F7501C302212E57400FB1415 /* NCMedia.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; path = NCMedia.storyboard; sourceTree = ""; }; F7501C312212E57400FB1415 /* NCMedia.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NCMedia.swift; sourceTree = ""; }; F751247A2C42919C00E63DB8 /* NCPhotoCell.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NCPhotoCell.swift; sourceTree = ""; }; @@ -1451,6 +1471,8 @@ F753701822723D620041C76C /* gl */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = gl; path = gl.lproj/Localizable.strings; sourceTree = ""; }; F753701922723E0D0041C76C /* ca */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ca; path = ca.lproj/Localizable.strings; sourceTree = ""; }; F753701A22723EC80041C76C /* da */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = da; path = da.lproj/Localizable.strings; sourceTree = ""; }; + F7547FE22FB7429200E372C3 /* NCVideoVLCViewControls.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCVideoVLCViewControls.swift; sourceTree = ""; }; + F7547FE52FB76C1800E372C3 /* NCVideoControlsView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCVideoControlsView.swift; sourceTree = ""; }; F755BD9A20594AC7008C5FBB /* NCService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCService.swift; sourceTree = ""; }; F755CB3F2B8CB13C00CE27E9 /* NCMediaLayout.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NCMediaLayout.swift; sourceTree = ""; }; F757CC8129E7F88B00F31428 /* NCManageDatabase+Groupfolders.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "NCManageDatabase+Groupfolders.swift"; sourceTree = ""; }; @@ -1478,6 +1500,7 @@ F76340F32EBDE9740056F538 /* NCManageDatabaseCore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCManageDatabaseCore.swift; sourceTree = ""; }; F76340FB2EBDF64A0056F538 /* NCManageDatabase+Tag.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NCManageDatabase+Tag.swift"; sourceTree = ""; }; F76341172EBE0BB80056F538 /* NCNetworking+NextcloudKitDelegate.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NCNetworking+NextcloudKitDelegate.swift"; sourceTree = ""; }; + F7635D8C2FB1F81D007F658D /* NCVideoVLCPresenter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCVideoVLCPresenter.swift; sourceTree = ""; }; F763D29C2A249C4500A3C901 /* NCManageDatabase+Capabilities.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "NCManageDatabase+Capabilities.swift"; sourceTree = ""; }; F765E9CC295C585800A09ED8 /* NCUploadScanDocument.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCUploadScanDocument.swift; sourceTree = ""; }; F765F72F25237E3F00391DBE /* NCRecent.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NCRecent.swift; sourceTree = ""; }; @@ -1572,6 +1595,9 @@ F7814E952F3B5F170074DA3A /* NCSVGRenderer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCSVGRenderer.swift; sourceTree = ""; }; F7816EF12C2C3E1F00A52517 /* NCPushNotification.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCPushNotification.swift; sourceTree = ""; }; F7817CF729801A3500FFBC65 /* Data+Extension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "Data+Extension.swift"; sourceTree = ""; }; + F78448A32FB1BE9000F2909A /* NCVideoPlaybackController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCVideoPlaybackController.swift; sourceTree = ""; }; + F78448A82FB1BE9000F2909A /* NCVideoViewerContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCVideoViewerContentView.swift; sourceTree = ""; }; + F78448BD2FB1C33B00F2909A /* NCVideoVLCViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCVideoVLCViewController.swift; sourceTree = ""; }; F785129A2D79899E0087DDD0 /* NCNetworking+TermsOfService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NCNetworking+TermsOfService.swift"; sourceTree = ""; }; F785EE9C246196DF00B3F945 /* NCNetworkingE2EE.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCNetworkingE2EE.swift; sourceTree = ""; }; F7864ACB2A78FE73004870E0 /* NCManageDatabase+LocalFile.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = "NCManageDatabase+LocalFile.swift"; sourceTree = ""; }; @@ -1600,6 +1626,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 = ""; }; @@ -1611,8 +1640,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 = ""; }; @@ -1625,6 +1652,10 @@ F7A573682E190377009C9257 /* NCShareExtensionData.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCShareExtensionData.swift; sourceTree = ""; }; F7A7FDDB2C2DBD6200E9A93A /* NCDeepLinkHandler.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NCDeepLinkHandler.swift; sourceTree = ""; }; F7A846DD2BB01ACB0024816F /* NCTrashCellProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCTrashCellProtocol.swift; sourceTree = ""; }; + F7A98A4D2FC97414009E6313 /* NCVideoURLResolver.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCVideoURLResolver.swift; sourceTree = ""; }; + F7A98A4F2FC9744A009E6313 /* NCVideoPlaybackCoverView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCVideoPlaybackCoverView.swift; sourceTree = ""; }; + F7A98A512FC97464009E6313 /* NCVideoViewerContentView+AVPlayer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NCVideoViewerContentView+AVPlayer.swift"; sourceTree = ""; }; + F7A98A532FC9746C009E6313 /* NCVideoViewerContentView+VLC.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NCVideoViewerContentView+VLC.swift"; sourceTree = ""; }; F7AA41B827C7CF4600494705 /* ca */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = ca; path = ca.lproj/InfoPlist.strings; sourceTree = ""; }; F7AA41B927C7CF4B00494705 /* zh-Hans */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hans"; path = "zh-Hans.lproj/InfoPlist.strings"; sourceTree = ""; }; F7AA41BA27C7CF5000494705 /* zh-Hant-TW */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = "zh-Hant-TW"; path = "zh-Hant-TW.lproj/InfoPlist.strings"; sourceTree = ""; }; @@ -1758,6 +1789,12 @@ F7CBC1222BAC8B0000EC1D55 /* NCSectionFirstHeaderEmptyData.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NCSectionFirstHeaderEmptyData.swift; sourceTree = ""; }; F7CC04E61F5AD50D00378CEF /* es */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = es; path = es.lproj/Localizable.strings; sourceTree = ""; }; F7CCAB502ECF315F00F8E68B /* NCCollectionViewCommon+SyncMetadata.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "NCCollectionViewCommon+SyncMetadata.swift"; sourceTree = ""; }; + F7CDB5B62FA33CA300F72306 /* NCImageViewerContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCImageViewerContentView.swift; sourceTree = ""; }; + F7CDB5B82FA33CA300F72306 /* NCMediaViewerModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCMediaViewerModel.swift; sourceTree = ""; }; + F7CDB5B92FA33CA300F72306 /* NCMediaViewerPageView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCMediaViewerPageView.swift; sourceTree = ""; }; + F7CDB5BB2FA33CA300F72306 /* NCMediaViewerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCMediaViewerView.swift; sourceTree = ""; }; + F7CDB5BD2FA33CA300F72306 /* NCNextcloudMediaViewerLoader.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCNextcloudMediaViewerLoader.swift; sourceTree = ""; }; + F7CDB5D22FA3448A00F72306 /* NCAudioViewerContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCAudioViewerContentView.swift; sourceTree = ""; }; F7CE8AFA1DC1F8D8009CAE48 /* Nextcloud.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Nextcloud.app; sourceTree = BUILT_PRODUCTS_DIR; }; F7CE8AFB1DC1F8D8009CAE48 /* Share.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = Share.appex; sourceTree = BUILT_PRODUCTS_DIR; }; F7CEE5FE2BA9A5C9003EFD89 /* NCTrashGridCell.xib */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.xib; path = NCTrashGridCell.xib; sourceTree = ""; }; @@ -1828,6 +1865,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 = ""; }; @@ -1851,6 +1893,7 @@ F7FA7FFB2C0F4EE40072FC60 /* NCViewerQuickLookView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NCViewerQuickLookView.swift; sourceTree = ""; }; F7FA7FFE2C0F4F3B0072FC60 /* NCUploadAssetsModel.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NCUploadAssetsModel.swift; sourceTree = ""; }; F7FA7FFF2C0F4F3B0072FC60 /* NCUploadAssetsView.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NCUploadAssetsView.swift; sourceTree = ""; }; + F7FAAC212FB773CA00DCA45B /* NCVideoAVPlayerViewControls.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCVideoAVPlayerViewControls.swift; sourceTree = ""; }; F7FAFD3928BFA947000777FE /* NCContextMenuNotification.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = NCContextMenuNotification.swift; sourceTree = ""; }; F7FDFF512E437E55000D7688 /* NCAccountRequest.storyboard */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; path = NCAccountRequest.storyboard; sourceTree = ""; }; F7FDFF522E437E55000D7688 /* NCAccountRequest.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NCAccountRequest.swift; sourceTree = ""; }; @@ -2051,7 +2094,6 @@ F78C6FDD296D677300C952C3 /* NCContextMenuMain.swift */, F72EC7252F45C90600A2135C /* NCContextMenuNavigation.swift */, F7FAFD3928BFA947000777FE /* NCContextMenuNotification.swift */, - B4C7A5B36D1ED178FB6B76CB /* NCContextMenuPlayerTracks.swift */, F72EC7272F45FF0600A2135C /* NCContextMenuPlus.swift */, BB7697C94BA14450A0867940 /* NCContextMenuProfile.swift */, AF93471127E2341B002537EE /* NCContextMenuShare.swift */, @@ -2352,6 +2394,16 @@ path = Shares; sourceTree = ""; }; + F716DA682FA5F137006A6703 /* Content */ = { + isa = PBXGroup; + children = ( + F74E3EE42FB07F2500252FA0 /* Image */, + F74E3EE52FB07F3000252FA0 /* Audio */, + F78448AE2FB1BE9000F2909A /* Video */, + ); + path = Content; + sourceTree = ""; + }; F720B5B72507B9A5008C94E5 /* Cell */ = { isa = PBXGroup; children = ( @@ -2475,6 +2527,15 @@ path = NCViewerDirectEditing; sourceTree = ""; }; + F749ED342FAF0EE200CE8DFA /* Core */ = { + isa = PBXGroup; + children = ( + F7CDB5BB2FA33CA300F72306 /* NCMediaViewerView.swift */, + F7CDB5B82FA33CA300F72306 /* NCMediaViewerModel.swift */, + ); + path = Core; + sourceTree = ""; + }; F74D3DB81BAC1941000BAE4B /* Networking */ = { isa = PBXGroup; children = ( @@ -2499,6 +2560,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 = ( @@ -2551,7 +2630,6 @@ children = ( F75FE06B2BB01D0D00A0EFEF /* Cell */, F70D7C3525FFBF81002B9E34 /* NCCollectionViewCommon.swift */, - F76995F32F9A4AC000291FA7 /* NCCollectionViewCommon+UIEditMenuInteractionDelegate.swift */, F7CAFE172F164B9200DB35A5 /* NCCollectionViewCommon+CellDelegate.swift */, F7743A132C33F13A0034F670 /* NCCollectionViewCommon+CollectionViewDataSource.swift */, F74D50342C9855A000BBBF4C /* NCCollectionViewCommon+CollectionViewDataSourcePrefetching.swift */, @@ -2563,6 +2641,8 @@ F7865FF02F39D32500D09AE4 /* NCCollectionViewCommon+Search.swift */, F36E64F62B9245210085ABB5 /* NCCollectionViewCommon+SelectTabBarDelegate.swift */, F7CCAB502ECF315F00F8E68B /* NCCollectionViewCommon+SyncMetadata.swift */, + F721C5092FB6F9AA00207DA9 /* NCCollectionViewCommon+TransitionSourceBlink.swift */, + F76995F32F9A4AC000291FA7 /* NCCollectionViewCommon+UIEditMenuInteractionDelegate.swift */, F7D4BF002CA1831600A5E746 /* NCCollectionViewCommonPinchGesture.swift */, F38F71242B6BBDC300473CDC /* NCCollectionViewCommonSelectTabBar.swift */, F7C1EEA425053A9C00866ACC /* NCCollectionViewDataSource.swift */, @@ -2765,6 +2845,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 = ( @@ -2806,25 +2922,12 @@ path = Trash; sourceTree = ""; }; - F79018B1240962C7007C9B6D /* NCViewerMedia */ = { - isa = PBXGroup; - children = ( - F70753EA2542A99800972D44 /* NCViewerMediaPage.swift */, - F718C24D254D507B00C5C256 /* NCViewerMediaDetailView.swift */, - F70753F02542A9A200972D44 /* NCViewerMedia.swift */, - F310B1EE2BA862F1001C42F5 /* NCViewerMedia+VisionKit.swift */, - F70753F62542A9C000972D44 /* NCViewerMediaPage.storyboard */, - F79EDA9E26B004980007D134 /* NCPlayer */, - ); - path = NCViewerMedia; - sourceTree = ""; - }; F79630EC215526B60015EEA5 /* Viewer */ = { isa = PBXGroup; children = ( F7F9D1BA25397CE000D9BFF5 /* NCViewer.swift */, F7EFA47725ADBA500083159A /* NCViewerProviderContextMenu.swift */, - F79018B1240962C7007C9B6D /* NCViewerMedia */, + F7CDB5C12FA33CA300F72306 /* NCViewerMedia */, F723986A253C9C0E00257F49 /* NCViewerQuickLook */, F76D3CEF2428B3DD005DFA87 /* NCViewerPDF */, F73D11FF253C5F5400DF9BEC /* NCViewerDirectEditing */, @@ -2846,16 +2949,6 @@ path = Select; sourceTree = ""; }; - F79EDA9E26B004980007D134 /* NCPlayer */ = { - isa = PBXGroup; - children = ( - F79EDAA126B004980007D134 /* NCPlayer.swift */, - F732D23227CF8AED000B0F1B /* NCPlayerToolBar.xib */, - F79EDA9F26B004980007D134 /* NCPlayerToolBar.swift */, - ); - path = NCPlayer; - sourceTree = ""; - }; F7A0D14E259229FA008F8A13 /* Extensions */ = { isa = PBXGroup; children = ( @@ -3101,6 +3194,39 @@ 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 */, + F749ED302FADD62400CE8DFA /* NCMediaViewerDetailView.swift */, + ); + path = Views; + sourceTree = ""; + }; F7D4BF0A2CA2E8D800A5E746 /* Models */ = { isa = PBXGroup; children = ( @@ -3237,6 +3363,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 = ( @@ -4060,7 +4196,6 @@ F7CBC1232BAC8B0000EC1D55 /* NCSectionFirstHeaderEmptyData.xib in Resources */, F700510122DF63AC003A3356 /* NCShare.storyboard in Resources */, F787704F22E7019900F287A9 /* NCShareLinkCell.xib in Resources */, - F70753F72542A9C000972D44 /* NCViewerMediaPage.storyboard in Resources */, F7F4F10627ECDBDB008676F9 /* Inconsolata-Medium.ttf in Resources */, F7AC934A296193050002BC0F /* Reasons to use Nextcloud.pdf in Resources */, F761856A29E98543006EB3B0 /* NCIntro.storyboard in Resources */, @@ -4075,7 +4210,6 @@ F751247E2C42919C00E63DB8 /* NCPhotoCell.xib in Resources */, F704B5E32430AA6F00632F5F /* NCCreateFormUploadConflict.storyboard in Resources */, F7EDE509262DA9D600414FE6 /* NCSelectCommandViewSelect.xib in Resources */, - F732D23327CF8AED000B0F1B /* NCPlayerToolBar.xib in Resources */, F73D11FA253C5F4800DF9BEC /* NCViewerDirectEditing.storyboard in Resources */, F7EDE51B262DD0C400414FE6 /* NCSelectCommandViewCopyMove.xib in Resources */, F7FF2CB12842159500EBB7A1 /* NCSectionHeader.xib in Resources */, @@ -4324,6 +4458,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 */, @@ -4541,7 +4676,6 @@ F78C6FDE296D677300C952C3 /* NCContextMenuMain.swift in Sources */, A5A87F9E4B0E4441A6A4BC20 /* NCContextMenuProfile.swift in Sources */, CB3666201AF7550816B5CD6A /* NCContextMenuComment.swift in Sources */, - 2F96A1BAFB10ACFEAC68EF1C /* NCContextMenuPlayerTracks.swift in Sources */, F7E402332BA89551007E5609 /* NCTrash+Networking.swift in Sources */, F73EF7A72B0223900087E6E9 /* NCManageDatabase+Comments.swift in Sources */, F33918C42C7CD8F2002D9AA1 /* FileNameValidator+Extensions.swift in Sources */, @@ -4558,6 +4692,7 @@ F7DF7B3F2F1A2EF900514020 /* WarningBannerView.swift in Sources */, F768822C2C0DD1E7001CF441 /* NCPreferences.swift in Sources */, F7CAFE1D2F17A35F00DB35A5 /* NCNetworking+Actor.swift in Sources */, + F7EDBB4B2FA89F6800098C42 /* NCLivePhotoViewerContentView.swift in Sources */, F3754A7D2CF87D600009312E /* SetupPasscodeView.swift in Sources */, F73EF7D72B0226080087E6E9 /* NCManageDatabase+Tip.swift in Sources */, F3374A842D64AC31002A38F9 /* AssistantLabelStyle.swift in Sources */, @@ -4567,6 +4702,7 @@ F78ACD4021903CC20088454D /* NCGridCell.swift in Sources */, F7D890752BD25C570050B8A6 /* NCCollectionViewCommon+DragDrop.swift in Sources */, F7BD0A042C4689E9003A4A6D /* NCMedia+MediaLayout.swift in Sources */, + F7CDB5D32FA3448B00F72306 /* NCAudioViewerContentView.swift in Sources */, F718E25A2DF2D5D1004038AF /* NCBackgroundLocationUploadManager.swift in Sources */, F761856B29E98543006EB3B0 /* NCIntroViewController.swift in Sources */, F7743A142C33F13A0034F670 /* NCCollectionViewCommon+CollectionViewDataSource.swift in Sources */, @@ -4586,24 +4722,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 */, @@ -4615,6 +4758,7 @@ AFCE353727E4ED7B00FEA6C2 /* NCShareCells.swift in Sources */, F75A9EE623796C6F0044CFCE /* NCNetworking.swift in Sources */, F72EC7282F45FF1400A2135C /* NCContextMenuPlus.swift in Sources */, + F7EDBB522FA8CACD00098C42 /* NCMediaViewerHostingController.swift in Sources */, AA8D31552D41052300FE2775 /* NCManageDatabase+DownloadLimit.swift in Sources */, F758B460212C56A400515F55 /* NCScan.swift in Sources */, F76882262C0DD1E7001CF441 /* NCSettingsView.swift in Sources */, @@ -4622,6 +4766,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 */, @@ -4644,6 +4791,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 */, @@ -4683,6 +4831,7 @@ F76882352C0DD1E7001CF441 /* NCWebBrowserView.swift in Sources */, F72CA0572F5048C3002E2F06 /* UIApplication+Extension.swift in Sources */, F3A047972BD2668800658E7B /* NCAssistantEmptyView.swift in Sources */, + F7547FE32FB742A400E372C3 /* NCVideoVLCViewControls.swift in Sources */, F757CC8D29E82D0500F31428 /* NCGroupfolders.swift in Sources */, F34BDB3A2F5744EC007A222C /* UINavigationItem+Extension.swift in Sources */, F7F3E58E2D3BB65600A32B14 /* NCNetworking+Recommendations.swift in Sources */, @@ -4708,7 +4857,6 @@ F75DD765290ABB25002EB562 /* Intent.intentdefinition in Sources */, F7D4BF012CA1831900A5E746 /* NCCollectionViewCommonPinchGesture.swift in Sources */, F74B6D952A7E239A00F03C5F /* NCManageDatabase+Chunk.swift in Sources */, - F310B1EF2BA862F1001C42F5 /* NCViewerMedia+VisionKit.swift in Sources */, F702F2F725EE5CED008F8E80 /* NCLogin.swift in Sources */, F75D90212D2BE26F003E740B /* NCRecommendationsCell.swift in Sources */, F7E98C1627E0D0FC001F9F19 /* NCManageDatabase+Video.swift in Sources */, @@ -4732,15 +4880,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 */, @@ -4778,6 +4925,7 @@ F74B91E92F51D45A0050813D /* ErrorBannerView.swift in Sources */, F7E8A391295DC5E0006CB2D0 /* View+Extension.swift in Sources */, F7CB77642F5843E500DE649A /* UIFont+Extension.swift in Sources */, + F7A98A4E2FC97414009E6313 /* NCVideoURLResolver.swift in Sources */, F79B869B265E19D40085C0E0 /* NSMutableAttributedString+Extension.swift in Sources */, F7B7504B2397D38F004E13EC /* UIImage+Extension.swift in Sources */, AF3FDCC22796ECC300710F60 /* NCTrash+CollectionView.swift in Sources */, @@ -4793,6 +4941,7 @@ AA8D316E2D4123B200FE2775 /* NCShareDownloadLimitTableViewControllerDelegate.swift in Sources */, F36C514F2E89393C0097E5F7 /* UIView+BlurVibrancy.swift in Sources */, AA8D316F2D4123B200FE2775 /* NCShareDownloadLimitTableViewController.swift in Sources */, + F78448BE2FB1C33B00F2909A /* NCVideoVLCViewController.swift in Sources */, AA8D31702D4123B200FE2775 /* DownloadLimitViewModel.swift in Sources */, AA8D31712D4123B200FE2775 /* NCShareDownloadLimitViewController.swift in Sources */, AB6000012F60000100FE2775 /* NCTagEditorModel.swift in Sources */, @@ -4804,7 +4953,6 @@ F7D368DF2DAFE19E0037E7C6 /* NCActivityNavigationController.swift in Sources */, F7A03E332D426115007AA677 /* NCMoreNavigationController.swift in Sources */, F7E402312BA891EB007E5609 /* NCTrash+SelectTabBarDelegate.swift in Sources */, - F70753EB2542A99800972D44 /* NCViewerMediaPage.swift in Sources */, F7817CF829801A3500FFBC65 /* Data+Extension.swift in Sources */, F749B651297B0F2400087535 /* NCManageDatabase+Avatar.swift in Sources */, F7FAFD3A28BFA948000777FE /* NCContextMenuNotification.swift in Sources */, @@ -4817,15 +4965,20 @@ 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 */, F737DA9D2B7B893C0063BAFC /* NCPasscode.swift in Sources */, F77C97392953131000FDDD09 /* NCCameraRoll.swift in Sources */, + F7EDBB5C2FA8DBE800098C42 /* NCMediaViewerPresenter.swift in Sources */, F7CADEFD2EA159210057849E /* NCMetadataTranfersSuccess.swift in Sources */, F343A4B32A1E01FF00DDA874 /* PHAsset+Extension.swift in Sources */, F70968A424212C4E00ED60E5 /* NCLivePhoto.swift in Sources */, F7C30DFA291BCF790017149B /* NCNetworkingE2EECreateFolder.swift in Sources */, F72CA05C2F5051DB002E2F06 /* AlertActionBannerView.swift in Sources */, F76995F42F9A4AC400291FA7 /* NCCollectionViewCommon+UIEditMenuInteractionDelegate.swift in Sources */, + F7547FE62FB76C1900E372C3 /* NCVideoControlsView.swift in Sources */, F722133B2D40EF9D002F7438 /* NCFilesNavigationController.swift in Sources */, F7BC288026663F85004D46C5 /* NCViewCertificateDetails.swift in Sources */, F78B87E92B62550800C65ADC /* NCMediaDownloadThumbnail.swift in Sources */, @@ -4840,6 +4993,7 @@ F749B64A297B0CBB00087535 /* NCManageDatabase+Share.swift in Sources */, F7C9555521F0C5470024296E /* NCActivity.swift in Sources */, F7725A60251F33BB00D125E0 /* NCFiles.swift in Sources */, + F721C50A2FB6F9AA00207DA9 /* NCCollectionViewCommon+TransitionSourceBlink.swift in Sources */, F704B5E52430AA8000632F5F /* NCCreateFormUploadConflict.swift in Sources */, F7865FF12F39D32F00D09AE4 /* NCCollectionViewCommon+Search.swift in Sources */, F7327E352B73AEDE00A462C7 /* NCNetworking+LivePhoto.swift in Sources */, @@ -4855,6 +5009,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 */, @@ -4886,12 +5041,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+Metadata.swift b/iOSClient/Data/NCManageDatabase+Metadata.swift index dc32ea891c..96c13a1883 100644 --- a/iOSClient/Data/NCManageDatabase+Metadata.swift +++ b/iOSClient/Data/NCManageDatabase+Metadata.swift @@ -1356,6 +1356,26 @@ extension NCManageDatabase { } ?? 0 } + /// Returns only the ocIds that still have a matching metadata row in Realm. + /// + /// - Parameter ocIds: Candidate media ocIds used by the media viewer. + /// - Returns: Valid ocIds preserving the original input order. + func getValidMetadataOcIdsAsync(_ ocIds: [String]) async -> [String] { + guard !ocIds.isEmpty else { + return [] + } + + return await core.performRealmReadAsync { realm in + let existingOcIds = Set( + realm.objects(tableMetadata.self) + .filter("ocId IN %@", ocIds) + .map(\.ocId) + ) + + return ocIds.filter { existingOcIds.contains($0) } + } ?? [] + } + func metadataExistsAsync(predicate: NSPredicate) async -> Bool { await core.performRealmReadAsync { realm in realm.objects(tableMetadata.self) diff --git a/iOSClient/Files/NCFiles.swift b/iOSClient/Files/NCFiles.swift index 68c13890b2..b8ef53c5c2 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 { @@ -355,31 +343,11 @@ class NCFiles: NCCollectionViewCommon { return (metadatas, error, reloadRequired) } - func blinkCell(fileName: String?) { - if let fileName = fileName, let metadata = database.getMetadata(predicate: NSPredicate(format: "account == %@ AND serverUrl == %@ AND fileName == %@", session.account, self.serverUrl, fileName)) { - let indexPath = self.dataSource.getIndexPathMetadata(ocId: metadata.ocId) - if let indexPath = indexPath { - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - UIView.animate(withDuration: 0.3) { - self.collectionView.scrollToItem(at: indexPath, at: .centeredVertically, animated: false) - } completion: { _ in - if let cell = self.collectionView.cellForItem(at: indexPath) { - cell.backgroundColor = .darkGray - UIView.animate(withDuration: 2) { - cell.backgroundColor = .clear - } - } - } - } - } - } - } - func open(metadata: tableMetadata?) async { guard let metadata else { return } - await didSelectMetadata(metadata, withOcIds: false) + await didSelectMetadata(metadata, withOcIds: false, viewerTransitionSource: nil) } // MARK: - NCAccountSettingsModelDelegate diff --git a/iOSClient/Main/Collection Common/Cell/NCCellMain.swift b/iOSClient/Main/Collection Common/Cell/NCCellMain.swift index 71b9ec76c6..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..1b5390de87 100644 --- a/iOSClient/Main/Collection Common/NCCollectionViewCommon+CellDelegate.swift +++ b/iOSClient/Main/Collection Common/NCCollectionViewCommon+CellDelegate.swift @@ -22,7 +22,7 @@ extension NCCollectionViewCommon: NCListCellDelegate, NCGridCellDelegate { func tapShareListItem(with metadata: tableMetadata?, button: UIButton, sender: Any) { Task { guard let metadata else { return } - NCCreate().createShare(controller: self.controller, metadata: metadata, page: .sharing) + NCCreate().createShare(controller: self.controller, viewController: self.controller, metadata: metadata, page: .sharing) } } } diff --git a/iOSClient/Main/Collection Common/NCCollectionViewCommon+CollectionViewDelegate.swift b/iOSClient/Main/Collection Common/NCCollectionViewCommon+CollectionViewDelegate.swift index 9b9a422625..b0750ff381 100644 --- a/iOSClient/Main/Collection Common/NCCollectionViewCommon+CollectionViewDelegate.swift +++ b/iOSClient/Main/Collection Common/NCCollectionViewCommon+CollectionViewDelegate.swift @@ -10,7 +10,7 @@ import LucidBanner extension NCCollectionViewCommon: UICollectionViewDelegate { @MainActor - func didSelectMetadata(_ metadata: tableMetadata, withOcIds: Bool) 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+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..d91319d156 100644 --- a/iOSClient/Main/Create/NCCreate.swift +++ b/iOSClient/Main/Create/NCCreate.swift @@ -49,7 +49,7 @@ class NCCreate: NSObject { url: url, session: session, sceneIdentifier: controller.sceneIdentifier) - if let vc = await NCViewer().getViewerController(metadata: metadata, delegate: viewController) { + if let vc = await NCViewer().getViewerController(metadata: metadata, delegate: viewController, viewerTransitionSource: nil) { viewController.navigationController?.pushViewController(vc, animated: true) } @@ -79,7 +79,7 @@ class NCCreate: NSObject { session: session, sceneIdentifier: controller.sceneIdentifier) - if let vc = await NCViewer().getViewerController(metadata: metadata, delegate: viewController) { + if let vc = await NCViewer().getViewerController(metadata: metadata, delegate: viewController, viewerTransitionSource: nil) { viewController.navigationController?.pushViewController(vc, animated: true) } } @@ -157,7 +157,7 @@ class NCCreate: NSObject { return (templates, selectedTemplate, ext) } - func createShare(controller: NCMainTabBarController?, metadata: tableMetadata, page: NCBrandOptions.NCInfoPagingTab) { + func createShare(controller: NCMainTabBarController?, viewController: UIViewController?, metadata: tableMetadata, page: NCBrandOptions.NCInfoPagingTab) { guard let controller else { return } @@ -211,7 +211,7 @@ class NCCreate: NSObject { shareNavigationController?.modalPresentationStyle = .formSheet if let shareNavigationController = shareNavigationController { - controller.present(shareNavigationController, animated: true, completion: nil) + viewController?.present(shareNavigationController, animated: true, completion: nil) } } } diff --git a/iOSClient/Main/NCMainNavigationController.swift b/iOSClient/Main/NCMainNavigationController.swift index ccce146116..86d9da34bc 100644 --- a/iOSClient/Main/NCMainNavigationController.swift +++ b/iOSClient/Main/NCMainNavigationController.swift @@ -292,7 +292,6 @@ class NCMainNavigationController: UINavigationController, UINavigationController guard !(collectionViewCommon?.isEditMode ?? false), !(trashViewController?.isEditMode ?? false), !(mediaViewController?.isEditMode ?? false), - !(topViewController is NCViewerMediaPage), !(topViewController is NCViewerPDF), !(topViewController is NCViewerRichDocument), !(topViewController is NCViewerDirectEditing) diff --git a/iOSClient/Main/NCPickerViewController.swift b/iOSClient/Main/NCPickerViewController.swift index eb05a89255..8245cdb69c 100644 --- a/iOSClient/Main/NCPickerViewController.swift +++ b/iOSClient/Main/NCPickerViewController.swift @@ -166,7 +166,7 @@ class NCDocumentPickerViewController: NSObject, UIDocumentPickerDelegate { await UIAlertController.warningAsync( message: message, presenter: self.controller) } else { if let metadata = await database.addAndReturnMetadataAsync(metadata), - let vc = await NCViewer().getViewerController(metadata: metadata, delegate: viewController) { + let vc = await NCViewer().getViewerController(metadata: metadata, delegate: viewController, viewerTransitionSource: nil) { viewController.navigationController?.pushViewController(vc, animated: true) } } diff --git a/iOSClient/Media/NCMedia+CollectionViewDelegate.swift b/iOSClient/Media/NCMedia+CollectionViewDelegate.swift index fde3168ff7..66904485e3 100644 --- a/iOSClient/Media/NCMedia+CollectionViewDelegate.swift +++ b/iOSClient/Media/NCMedia+CollectionViewDelegate.swift @@ -23,15 +23,82 @@ extension NCMedia: UICollectionViewDelegate { tabBarSelect.selectCount = fileSelect.count } else if let metadata = await self.database.getMetadataFromOcIdAsync(metadata.ocId) { let image = utility.getImage(ocId: metadata.ocId, etag: metadata.etag, ext: global.previewExt1024, userId: metadata.userId, urlBase: metadata.urlBase) + var viewerTransitionSource: NCMediaViewerTransitionSource? let ocIds = dataSource.metadatas.map { $0.ocId } - if let vc = await NCViewer().getViewerController(metadata: metadata, ocIds: ocIds, image: image, delegate: self) { - self.navigationController?.pushViewController(vc, animated: true) + if let imageView = cell.imageItem, + let image = imageView.image, + let window = imageView.window { + let sourceFrame = imageView.convert(imageView.bounds, to: window) + viewerTransitionSource = 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, let metadata = database.getMetadataFromOcId(ocId) diff --git a/iOSClient/Media/NCMediaNavigationController.swift b/iOSClient/Media/NCMediaNavigationController.swift index c517a91508..ee7ac04c20 100644 --- a/iOSClient/Media/NCMediaNavigationController.swift +++ b/iOSClient/Media/NCMediaNavigationController.swift @@ -159,7 +159,7 @@ class NCMediaNavigationController: NCMainNavigationController { sceneIdentifier: self.controller?.sceneIdentifier) await self.database.addMetadataAsync(metadata) - if let vc = await NCViewer().getViewerController(metadata: metadata, delegate: self) { + if let vc = await NCViewer().getViewerController(metadata: metadata, delegate: self, viewerTransitionSource: nil) { self.navigationController?.pushViewController(vc, animated: true) } } diff --git a/iOSClient/Menu/NCContextMenuMain.swift b/iOSClient/Menu/NCContextMenuMain.swift index 2926e69636..09d24d27a9 100644 --- a/iOSClient/Menu/NCContextMenuMain.swift +++ b/iOSClient/Menu/NCContextMenuMain.swift @@ -9,6 +9,7 @@ import Alamofire import NextcloudKit import LucidBanner +/// A context menu used in ``NCCollectionViewCommon`` and ``NCMedia`` /// A context menu used in ``NCCollectionViewCommon`` and ``NCMedia`` /// See ``NCCollectionViewCommon/collectionView(_:contextMenuConfigurationForItemAt:point:)``, /// ``NCCollectionViewCommon/openContextMenu(with:button:sender:)``, ``NCMedia/collectionView(_:contextMenuConfigurationForItemAt:point:)`` for usage details. @@ -95,6 +96,7 @@ class NCContextMenuMain: NSObject { image: utility.loadImage(named: "info.circle.fill") ) { _ in NCCreate().createShare(controller: self.controller, + viewController: self.controller, metadata: metadata, page: .activity) } diff --git a/iOSClient/Menu/NCContextMenuPlayerTracks.swift b/iOSClient/Menu/NCContextMenuPlayerTracks.swift deleted file mode 100644 index 43ecdb2598..0000000000 --- a/iOSClient/Menu/NCContextMenuPlayerTracks.swift +++ /dev/null @@ -1,148 +0,0 @@ -// SPDX-FileCopyrightText: Nextcloud GmbH -// SPDX-FileCopyrightText: 2026 Milen Pivchev -// SPDX-License-Identifier: GPL-3.0-or-later - -import UIKit -import NextcloudKit -import MobileVLCKit - -/// A context menu for video player track selection (subtitles, audio tracks). -/// See ``NCPlayerToolBar`` for usage details. -class NCContextMenuPlayerTracks: NSObject { - enum TrackType { - case subtitle - case audio - } - - let trackType: TrackType - let currentIndex: Int? - let ncplayer: NCPlayer? - let metadata: tableMetadata? - let viewerMediaPage: NCViewerMediaPage? - private let database = NCManageDatabase.shared - - init(trackType: TrackType, - tracks: [Any], - trackIndexes: [Any], - currentIndex: Int?, - ncplayer: NCPlayer?, - metadata: tableMetadata?, - viewerMediaPage: NCViewerMediaPage?) { - self.trackType = trackType - self.currentIndex = currentIndex - self.ncplayer = ncplayer - self.metadata = metadata - self.viewerMediaPage = viewerMediaPage - } - - func viewMenu() -> UIMenu { - var children: [UIMenuElement] = [] - - // Add track action - switch self.trackType { - case .subtitle: - let deferredElement = UIDeferredMenuElement.uncached { [self] completion in - guard let player = ncplayer?.player else { return completion([]) } - let spuTracks = player.videoSubTitlesNames - let spuTrackIndexes = player.videoSubTitlesIndexes - - var actions = [UIAction]() - var subTitleIndex: Int? - - if let data = self.database.getVideo(metadata: metadata), let idx = data.currentVideoSubTitleIndex { - subTitleIndex = idx - } else if let idx = ncplayer?.player.currentVideoSubTitleIndex { - subTitleIndex = Int(idx) - } - - if !spuTracks.isEmpty { - for index in 0...spuTracks.count - 1 { - guard let title = spuTracks[index] as? String, let idx = spuTrackIndexes[index] as? Int32 else { return } - - let action = makeTrackAction(title: title, index: idx, isSelected: (subTitleIndex ?? -9999) == idx) - actions.append(action) - } - } - - completion(actions) - } - - children.append(deferredElement) - case .audio: - let deferredElement = UIDeferredMenuElement.uncached { [self] completion in - guard let player = ncplayer?.player else { return completion([]) } - let audioTracks = player.audioTrackNames - let audioTrackIndexes = player.audioTrackIndexes - - var actions = [UIAction]() - var audioIndex: Int? - - if let data = self.database.getVideo(metadata: metadata), let idx = data.currentAudioTrackIndex { - audioIndex = idx - } else if let idx = ncplayer?.player.currentAudioTrackIndex { - audioIndex = Int(idx) - } - - if !audioTracks.isEmpty { - for index in 0...audioTracks.count - 1 { - guard let title = audioTracks[index] as? String, let idx = audioTrackIndexes[index] as? Int32 else { return } - - let action = makeTrackAction(title: title, index: idx, isSelected: (audioIndex ?? -9999) == idx) - actions.append(action) - } - } - - completion(actions) - } - - children.append(deferredElement) - } - - children.append(makeAddTrackAction()) - - return UIMenu(title: "", children: children) - } - - private func makeTrackAction(title: String, index: Int32, isSelected: Bool) -> UIAction { - UIAction( - title: title, - state: isSelected ? .on : .off - ) { _ in - guard let metadata = self.metadata else { return } - - switch self.trackType { - case .subtitle: - self.ncplayer?.player.currentVideoSubTitleIndex = index - self.database.addVideo(metadata: metadata, currentVideoSubTitleIndex: Int(index)) - case .audio: - self.ncplayer?.player.currentAudioTrackIndex = index - self.database.addVideo(metadata: metadata, currentAudioTrackIndex: Int(index)) - } - } - } - - private func makeAddTrackAction() -> UIAction { - let title = trackType == .subtitle - ? NSLocalizedString("_add_subtitle_", comment: "") - : NSLocalizedString("_add_audio_", comment: "") - - return UIAction(title: title) { _ in - guard let metadata = self.metadata else { return } - let storyboard = UIStoryboard(name: "NCSelect", bundle: nil) - if let navigationController = storyboard.instantiateInitialViewController() as? UINavigationController, - let viewController = navigationController.topViewController as? NCSelect { - - viewController.delegate = self.viewerMediaPage?.currentViewController.playerToolBar - viewController.typeOfCommandView = .nothing - viewController.includeDirectoryE2EEncryption = false - viewController.enableSelectFile = true - viewController.type = self.trackType == .subtitle ? "subtitle" : "audio" - viewController.serverUrl = metadata.serverUrl - viewController.session = NCSession.shared.getSession(account: metadata.account) - viewController.controller = nil - - self.viewerMediaPage?.present(navigationController, animated: true, completion: nil) - } - } - } -} diff --git a/iOSClient/Menu/NCContextMenuViewer.swift b/iOSClient/Menu/NCContextMenuViewer.swift index 460574ee8c..2d96bc0589 100644 --- a/iOSClient/Menu/NCContextMenuViewer.swift +++ b/iOSClient/Menu/NCContextMenuViewer.swift @@ -11,6 +11,7 @@ import NextcloudKit class NCContextMenuViewer: NSObject { let metadata: tableMetadata let controller: NCMainTabBarController? + let viewController: UIViewController? let webView: Bool let sender: Any? private let database = NCManageDatabase.shared @@ -20,9 +21,14 @@ class NCContextMenuViewer: NSObject { SceneManager.shared.getWindowScene(controller: controller) } - init(metadata: tableMetadata, controller: NCMainTabBarController?, webView: Bool, sender: Any?) { + init(metadata: tableMetadata, + controller: NCMainTabBarController?, + viewController: UIViewController?, + webView: Bool, + sender: Any?) { self.metadata = metadata self.controller = controller + self.viewController = viewController self.webView = webView self.sender = sender } @@ -40,12 +46,12 @@ class NCContextMenuViewer: NSObject { // DETAIL if !(!capabilities.fileSharingApiEnabled && !capabilities.filesComments && capabilities.activity.isEmpty) { - menuElements.append(makeDetailAction(metadata: metadata, controller: controller)) + menuElements.append(makeDetailAction(metadata: metadata, controller: controller, viewController: viewController)) } // VIEW IN FOLDER if !webView { - menuElements.append(makeViewInFolderAction(metadata: metadata, controller: controller)) + menuElements.append(makeViewInFolderAction(metadata: metadata, controller: controller, viewController: viewController)) } // FAVORITE @@ -85,26 +91,42 @@ class NCContextMenuViewer: NSObject { // MARK: - Private Action Makers - private func makeDetailAction(metadata: tableMetadata, controller: NCMainTabBarController) -> UIAction { + private func makeDetailAction(metadata: tableMetadata, controller: NCMainTabBarController, viewController: UIViewController?) -> UIAction { UIAction( title: NSLocalizedString("_details_", comment: ""), image: UIImage(systemName: "info") ) { _ in NCCreate().createShare(controller: controller, + viewController: viewController, metadata: metadata, page: .activity) } } - private func makeViewInFolderAction(metadata: tableMetadata, controller: NCMainTabBarController) -> UIAction { + private func makeViewInFolderAction(metadata: tableMetadata, controller: NCMainTabBarController, viewController: UIViewController?) -> UIAction { UIAction( title: NSLocalizedString("_view_in_folder_", comment: ""), image: UIImage(systemName: "questionmark.folder") ) { _ in Task { - await NCNetworking.shared.blinkInFolder(serverUrl: metadata.serverUrl, - fileName: metadata.fileName, - sceneIdentifier: controller.sceneIdentifier) + if let files = await NCNetworking.shared.moveInFolder(serverUrl: metadata.serverUrl, + sceneIdentifier: controller.sceneIdentifier) { + + files.loadViewIfNeeded() + files.view.layoutIfNeeded() + files.collectionView.layoutIfNeeded() + + if let mediaViewer = viewController as? NCMediaViewerHostingController { + mediaViewer.close() + } else if let mediaViewer = viewController as? NCVideoVLCViewController { + mediaViewer.closeImmediately() + } else if let mediaViewer = viewController as? NCVideoAVPlayerViewController { + mediaViewer.closeImmediately() + } + + try? await Task.sleep(for: .seconds(0.6)) + files.blinkItem(ocId: metadata.ocId) + } } } } diff --git a/iOSClient/NCGlobal.swift b/iOSClient/NCGlobal.swift index 2e6ad38aee..1bb3c80bcc 100644 --- a/iOSClient/NCGlobal.swift +++ b/iOSClient/NCGlobal.swift @@ -403,6 +403,7 @@ final class NCGlobal: Sendable { let logTagSpeedUpSyncMetadata = "SYNC METADATA" let logTagNetworkingTasks = "NETWORKING TASKS" let logTagMetadataTransfers = "METADATA TRANSFERS" + let logTagViewer = "VIEWERS" // USER DEFAULTS // diff --git a/iOSClient/Networking/NCNetworking+Recommendations.swift b/iOSClient/Networking/NCNetworking+Recommendations.swift index 9750e6aec4..fae0d04b05 100644 --- a/iOSClient/Networking/NCNetworking+Recommendations.swift +++ b/iOSClient/Networking/NCNetworking+Recommendations.swift @@ -39,7 +39,9 @@ extension NCNetworking { if results.error == .success, let file = results.files?.first { let metadata = await NCManageDatabaseCreateMetadata().convertFileToMetadataAsync(file) - await NCManageDatabase.shared.addMetadataAsync(metadata) + if await NCManageDatabase.shared.getMetadataFromOcIdAsync(metadata.ocId) == nil { + await NCManageDatabase.shared.addMetadataAsync(metadata) + } if metadata.isLivePhoto, metadata.isVideo { continue diff --git a/iOSClient/Networking/NCNetworking+TransferDelegate.swift b/iOSClient/Networking/NCNetworking+TransferDelegate.swift index 82362b7324..d07d8bca52 100644 --- a/iOSClient/Networking/NCNetworking+TransferDelegate.swift +++ b/iOSClient/Networking/NCNetworking+TransferDelegate.swift @@ -95,7 +95,7 @@ extension NCNetworking: NCTransferDelegate { if let viewController = controller.currentViewController() { let image = NCUtility().getImage(ocId: metadata.ocId, etag: metadata.etag, ext: NCGlobal.shared.previewExt1024, userId: metadata.userId, urlBase: metadata.urlBase) Task { - if let vc = await NCViewer().getViewerController(metadata: metadata, image: image, delegate: viewController) { + if let vc = await NCViewer().getViewerController(metadata: metadata, image: image, delegate: viewController, viewerTransitionSource: nil) { viewController.navigationController?.pushViewController(vc, animated: true) } } @@ -215,7 +215,7 @@ extension NCNetworking: NCTransferDelegate { ) let fileSize = attr[FileAttributeKey.size] as? UInt64 ?? 0 if fileSize > 0 { - if let vc = await NCViewer().getViewerController(metadata: metadata, delegate: viewController) { + if let vc = await NCViewer().getViewerController(metadata: metadata, delegate: viewController, viewerTransitionSource: nil) { viewController.navigationController?.pushViewController(vc, animated: true) } return @@ -255,7 +255,7 @@ extension NCNetworking: NCTransferDelegate { ) if metadata.isAudioOrVideo { - if let vc = await NCViewer().getViewerController(metadata: metadata, delegate: viewController) { + if let vc = await NCViewer().getViewerController(metadata: metadata, delegate: viewController, viewerTransitionSource: nil) { viewController.navigationController?.pushViewController(vc, animated: true) } return @@ -290,7 +290,7 @@ extension NCNetworking: NCTransferDelegate { if download.nkError == .success { await NCManageDatabase.shared.addLocalFilesAsync(metadatas: [metadata]) - if let vc = await NCViewer().getViewerController(metadata: metadata, delegate: viewController) { + if let vc = await NCViewer().getViewerController(metadata: metadata, delegate: viewController, viewerTransitionSource: nil) { viewController.navigationController?.pushViewController(vc, animated: true) } } @@ -313,52 +313,71 @@ extension NCNetworking: NCTransferDelegate { } @MainActor - func blinkInFolder(serverUrl: String, - fileName: String, - sceneIdentifier: String) async { + func moveInFolder(serverUrl: String, sceneIdentifier: String) async -> NCFiles? { guard let controller = SceneManager.shared.getController(sceneIdentifier: sceneIdentifier), let navigationController = controller.viewControllers?.first as? UINavigationController - else { return } + else { + return nil + } + let session = NCSession.shared.getSession(controller: controller) - var serverUrlPush = self.utilityFileSystem.getHomeServer(session: session) + var serverUrlPush = utilityFileSystem.getHomeServer(session: session) navigationController.popToRootViewController(animated: false) controller.selectedIndex = 0 + if serverUrlPush == serverUrl, - let viewController = navigationController.topViewController as? NCFiles { - Task { - viewController.blinkCell(fileName: fileName) - } - return + let files = navigationController.topViewController as? NCFiles { + return files + } + + guard serverUrl.hasPrefix(serverUrlPush) else { + return nil } - let diffDirectory = serverUrl.replacingOccurrences(of: serverUrlPush, with: "") + let diffDirectory = String(serverUrl.dropFirst(serverUrlPush.count)) var subDirs = diffDirectory.split(separator: "/") + var lastFilesViewController: NCFiles? + while serverUrlPush != serverUrl, !subDirs.isEmpty { - guard let dir = subDirs.first else { - return - } - serverUrlPush = self.utilityFileSystem.createServerUrl(serverUrl: serverUrlPush, fileName: String(dir)) + let dir = String(subDirs.removeFirst()) + + serverUrlPush = utilityFileSystem.createServerUrl( + serverUrl: serverUrlPush, + fileName: dir + ) + + if let viewController = controller.navigationCollectionViewCommon.first(where: { + $0.navigationController == navigationController && + $0.serverUrl == serverUrlPush + })?.viewController as? NCFiles { - if let viewController = controller.navigationCollectionViewCommon.first(where: { $0.navigationController == navigationController && $0.serverUrl == serverUrlPush})?.viewController as? NCFiles, viewController.isViewLoaded { - viewController.fileNameBlink = fileName navigationController.pushViewController(viewController, animated: false) - } else { - if let viewController: NCFiles = UIStoryboard(name: "NCFiles", bundle: nil).instantiateInitialViewController() as? NCFiles { - viewController.serverUrl = serverUrlPush - viewController.titleCurrentFolder = String(dir) - viewController.navigationItem.backButtonTitle = viewController.titleCurrentFolder + lastFilesViewController = viewController - controller.navigationCollectionViewCommon.append(NavigationCollectionViewCommon(serverUrl: serverUrlPush, navigationController: navigationController, viewController: viewController)) + } else if let viewController = UIStoryboard(name: "NCFiles", bundle: nil).instantiateInitialViewController() as? NCFiles { - if serverUrlPush == serverUrl { - viewController.fileNameBlink = fileName - } - navigationController.pushViewController(viewController, animated: false) - } + viewController.serverUrl = serverUrlPush + viewController.titleCurrentFolder = dir + viewController.navigationItem.backButtonTitle = dir + + controller.navigationCollectionViewCommon.append( + NavigationCollectionViewCommon( + serverUrl: serverUrlPush, + navigationController: navigationController, + viewController: viewController + ) + ) + + navigationController.pushViewController(viewController, animated: false) + lastFilesViewController = viewController + + } else { + return nil } - subDirs.remove(at: 0) } + + return serverUrlPush == serverUrl ? lastFilesViewController : nil } } diff --git a/iOSClient/RichWorkspace/NCViewerRichWorkspaceWebView.swift b/iOSClient/RichWorkspace/NCViewerRichWorkspaceWebView.swift index 70d5358c0a..df0114b962 100644 --- a/iOSClient/RichWorkspace/NCViewerRichWorkspaceWebView.swift +++ b/iOSClient/RichWorkspace/NCViewerRichWorkspaceWebView.swift @@ -72,6 +72,7 @@ class NCViewerRichWorkspaceWebView: UIViewController, WKNavigationDelegate, WKSc if message.body as? String == "share", metadata != nil { NCCreate().createShare(controller: self.controller, + viewController: self.controller, metadata: metadata!, page: .sharing) } diff --git a/iOSClient/Select/NCSelect.swift b/iOSClient/Select/NCSelect.swift index 775de939ab..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/Supporting Files/en.lproj/Localizable.strings b/iOSClient/Supporting Files/en.lproj/Localizable.strings index 822dd4e1b1..d7b137435c 100644 --- a/iOSClient/Supporting Files/en.lproj/Localizable.strings +++ b/iOSClient/Supporting Files/en.lproj/Localizable.strings @@ -671,7 +671,7 @@ "_delete_in_progress_" = "Delete in progress …"; "_download_in_progress_" = "Download in progress …"; "_upload_in_progress_" = "Upload in progress …"; -"_transfer_in_progress_" = "Transfer in progress …"; +"_transfer_in_progress_" = "Transfer in progress …"; "_in_waiting_" = "In waiting"; "_in_progress_" = "In progress"; "_in_error_" = "In error"; @@ -682,6 +682,19 @@ "_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 diff --git a/iOSClient/Utility/NCUtilityFileSystem.swift b/iOSClient/Utility/NCUtilityFileSystem.swift index 7073605d7e..be71855854 100644 --- a/iOSClient/Utility/NCUtilityFileSystem.swift +++ b/iOSClient/Utility/NCUtilityFileSystem.swift @@ -849,4 +849,20 @@ final class NCUtilityFileSystem: NSObject, @unchecked Sendable { let parent = url.deletingLastPathComponent().lastPathComponent return parent == "f" ? id : nil } + + /// Extracts the numeric fileId prefix from a Nextcloud ocId. + /// + /// - Parameter ocId: Nextcloud ocId, usually composed by a numeric fileId prefix and an instance suffix. + /// - Returns: Numeric fileId string if available. + func extractFileId(from ocId: String) -> String? { + let prefix = ocId.prefix { character in + character.isNumber + } + + guard !prefix.isEmpty else { + return nil + } + + return String(Int(prefix) ?? 0) + } } diff --git a/iOSClient/Viewer/NCViewer.swift b/iOSClient/Viewer/NCViewer.swift index 353787937d..8c6a46371a 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,26 @@ class NCViewer: NSObject { // IMAGE AUDIO VIDEO else if metadata.isImage || metadata.isAudioOrVideo { - let viewerMediaPageContainer = UIStoryboard(name: "NCViewerMediaPage", bundle: nil).instantiateInitialViewController() as? NCViewerMediaPage - - viewerMediaPageContainer?.delegateViewController = delegate - if let ocIds { - viewerMediaPageContainer?.currentIndex = ocIds.firstIndex(where: { $0 == metadata.ocId }) ?? 0 - viewerMediaPageContainer?.ocIds = ocIds - } else { - viewerMediaPageContainer?.currentIndex = 0 - viewerMediaPageContainer?.ocIds = [metadata.ocId] - } - - return viewerMediaPageContainer + let mediaOcIds = ocIds ?? [metadata.ocId] + let mediaSearch = delegate is NCMedia + let model = NCMediaViewerModel(currentMetadata: metadata, ocIds: mediaOcIds, session: session, mediaSearch: mediaSearch, loader: NCMediaViewerLoader()) + + NCMediaViewerPresenter.shared.show( + model: model, + viewerTransitionSource: viewerTransitionSource, + from: delegate?.view, + contextMenuController: delegate?.tabBarController as? NCMainTabBarController, + closingTransitionSourceProvider: { ocId in + if let provider = delegate as? NCCollectionViewCommon { + return provider.viewerTransitionSource(for: ocId) + } else if let provider = delegate as? NCMedia { + return provider.viewerTransitionSource(for: ocId) + } else { + return nil + } + } + ) + return nil } // DOCUMENTS diff --git a/iOSClient/Viewer/NCViewerDirectEditing/NCViewerDirectEditing.swift b/iOSClient/Viewer/NCViewerDirectEditing/NCViewerDirectEditing.swift index 90fef2de6a..6332794545 100644 --- a/iOSClient/Viewer/NCViewerDirectEditing/NCViewerDirectEditing.swift +++ b/iOSClient/Viewer/NCViewerDirectEditing/NCViewerDirectEditing.swift @@ -40,7 +40,11 @@ class NCViewerDirectEditing: UIViewController, WKNavigationDelegate, WKScriptMes primaryAction: nil, menu: UIMenu(title: "", children: [ UIDeferredMenuElement.uncached { [self] completion in - if let menu = NCContextMenuViewer(metadata: self.metadata, controller: self.tabBarController as? NCMainTabBarController, webView: true, sender: self).viewMenu() { + if let menu = NCContextMenuViewer(metadata: self.metadata, + controller: self.tabBarController as? NCMainTabBarController, + viewController: self.tabBarController, + webView: true, + sender: self).viewMenu() { completion(menu.children) } } @@ -172,6 +176,7 @@ class NCViewerDirectEditing: UIViewController, WKNavigationDelegate, WKScriptMes if message.body as? String == "share" { NCCreate().createShare(controller: self.controller, + viewController: self.controller, metadata: metadata, page: .sharing) } diff --git a/iOSClient/Viewer/NCViewerMedia/Content/Audio/NCAudioViewerContentView.swift b/iOSClient/Viewer/NCViewerMedia/Content/Audio/NCAudioViewerContentView.swift new file mode 100644 index 0000000000..8d455c1cb0 --- /dev/null +++ b/iOSClient/Viewer/NCViewerMedia/Content/Audio/NCAudioViewerContentView.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 AVFoundation +import NextcloudKit + +// MARK: - Audio Viewer View + +struct NCAudioViewerContentView: View { + let metadata: tableMetadata + let localURL: URL + let previewURL: URL? + let canGoPrevious: Bool + let canGoNext: Bool + let shouldAutoPlay: Bool + let onPrevious: (_ shouldAutoPlay: Bool) -> Void + let onNext: (_ shouldAutoPlay: Bool) -> Void + let onAutoPlayConsumed: () -> Void + + @StateObject private var model: NCAudioViewerModel + + init( + metadata: tableMetadata, + localURL: URL, + previewURL: URL? = nil, + canGoPrevious: Bool = false, + canGoNext: Bool = false, + shouldAutoPlay: Bool = false, + onPrevious: @escaping (_ shouldAutoPlay: Bool) -> Void = { _ in }, + onNext: @escaping (_ shouldAutoPlay: Bool) -> Void = { _ in }, + onAutoPlayConsumed: @escaping () -> Void = {} + ) { + self.metadata = metadata + self.localURL = localURL + self.previewURL = previewURL + self.canGoPrevious = canGoPrevious + self.canGoNext = canGoNext + self.shouldAutoPlay = shouldAutoPlay + self.onPrevious = onPrevious + self.onNext = onNext + self.onAutoPlayConsumed = onAutoPlayConsumed + + _model = StateObject( + wrappedValue: NCAudioViewerPlaybackRegistry.shared.model( + for: metadata.ocId + ) + ) + } + + var body: some View { + GeometryReader { proxy in + let isLandscape = proxy.size.width > proxy.size.height + let artworkSize: CGFloat = isLandscape ? 110 : 180 + let mainSpacing: CGFloat = isLandscape ? 18 : 28 + let titleHorizontalPadding: CGFloat = 24 + let sliderHorizontalPadding: CGFloat = isLandscape ? 90 : 32 + let topPadding: CGFloat = isLandscape ? 72 : 0 + let buttonSpacing: CGFloat = isLandscape ? 24 : 28 + let sideButtonSize: CGFloat = isLandscape ? 30 : 34 + let playButtonSize: CGFloat = isLandscape ? 64 : 72 + + VStack(spacing: mainSpacing) { + artworkView(size: artworkSize) + if !isLandscape { + VStack(spacing: 8) { + Text(displayFileName) + .font(.headline) + .foregroundStyle(.white) + .lineLimit(2) + .multilineTextAlignment(.center) + + Text(metadata.contentType.isEmpty ? "Audio" : metadata.contentType) + .font(.footnote) + .foregroundStyle(.white.opacity(0.55)) + .lineLimit(1) + } + .padding(.horizontal, titleHorizontalPadding) + } + + VStack(spacing: 10) { + Slider( + value: Binding( + get: { model.currentTime }, + set: { model.seek(to: $0) } + ), + in: 0...max(model.duration, 1) + ) + .disabled(model.duration <= 0) + + HStack { + Text(formatTime(model.currentTime)) + + Spacer() + + Text(formatTime(model.duration)) + } + .font(.caption.monospacedDigit()) + .foregroundStyle(.white.opacity(0.6)) + } + .padding(.horizontal, sliderHorizontalPadding) + + HStack(spacing: buttonSpacing) { + Button { + model.toggleLoop() + } label: { + Image(systemName: model.isLoopEnabled ? "repeat.circle.fill" : "repeat.circle") + .font(.system(size: sideButtonSize, weight: .regular)) + .foregroundStyle(model.isLoopEnabled ? .white : .white.opacity(0.45)) + } + .buttonStyle(.plain) + + Button { + model.togglePlayback() + } label: { + Image(systemName: model.isPlaying ? "pause.circle.fill" : "play.circle.fill") + .font(.system(size: playButtonSize, weight: .regular)) + .foregroundStyle(.white) + } + .buttonStyle(.plain) + + Button { + model.restart() + } label: { + Image(systemName: "gobackward") + .font(.system(size: sideButtonSize, weight: .regular)) + .foregroundStyle(.white.opacity(0.45)) + } + .buttonStyle(.plain) + .disabled(model.duration <= 0) + } + } + .padding(.top, topPadding) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(Color.black) + } + .task(id: localURL) { + await model.load(url: localURL) + consumeAutoPlayIfNeeded() + } + .onChange(of: shouldAutoPlay) { _, newValue in + guard newValue else { + return + } + + consumeAutoPlayIfNeeded() + } + // 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(.white.opacity(0.08)) + .frame(width: size, height: size) + + Image(systemName: "waveform") + .font(.system(size: 76, weight: .regular)) + .foregroundStyle(.white.opacity(0.9)) + } + } + } + + private var previewImage: UIImage? { + guard let previewURL, + previewURL.isFileURL else { + return nil + } + + return UIImage(contentsOfFile: previewURL.path) + } + + // MARK: - Private + + 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..f1f7e71d3f --- /dev/null +++ b/iOSClient/Viewer/NCViewerMedia/Content/Image/NCImageViewerContentView.swift @@ -0,0 +1,295 @@ +// 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 + + @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) { + self.identifier = identifier + self.previewURL = previewURL + self.fullURL = fullURL + self.backgroundStyle = backgroundStyle + } + + var body: some View { + ZStack { + Color.ncViewerBackground(backgroundStyle) + .ignoresSafeArea() + + if let currentImage { + NCImageZoomView( + image: currentImage, + backgroundStyle: backgroundStyle, + allowsImageAnalysis: allowsImageAnalysis + ) + .ignoresSafeArea() + } else if let failedMessage { + failedView(failedMessage) + } else { + Color.ncViewerBackground(backgroundStyle) + .ignoresSafeArea() + } + } + .background(Color.ncViewerBackground(backgroundStyle)) + .task(id: taskIdentifier) { + await loadBestAvailableImage() + } + } + + // MARK: - Views + + private func failedView(_ message: String) -> some View { + VStack(spacing: 12) { + Image(systemName: "photo.badge.exclamationmark") + .font(.system(size: 44, weight: .regular)) + + Text(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 allowsImageAnalysis: Bool { + guard 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..1d0e5c8e58 --- /dev/null +++ b/iOSClient/Viewer/NCViewerMedia/Content/Image/NCLivePhotoViewerContentView.swift @@ -0,0 +1,393 @@ +// 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 + ) + } + + 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..3b3f1f97dd --- /dev/null +++ b/iOSClient/Viewer/NCViewerMedia/Content/Video/AVPlayer/NCVideoAVPlayerViewController.swift @@ -0,0 +1,989 @@ +// 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( + image: NCImageCache.shared.getImageButtonMore(), + primaryAction: nil, + menu: makeMoreMenu() + ) + + private lazy var mediaDetailNavigationItem = UIBarButtonItem( + image: NCUtility().loadImage( + named: "info.circle", + colors: [NCBrandColor.shared.iconImageColor] + ), + style: .plain, + target: self, + action: #selector(mediaDetailButtonTapped) + ) + + // MARK: - Init + + init( + metadata: tableMetadata, + 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() + } + + // Use this controller as sender so actions present above AVPlayer. + private func makeMoreMenu() -> UIMenu { + UIMenu(title: "", children: [ + UIDeferredMenuElement.uncached { [weak self] completion in + guard let self else { + completion([]) + return + } + + if let menu = NCContextMenuViewer( + metadata: self.metadata, + controller: self.contextMenuController, + viewController: self, + webView: false, + sender: self + ).viewMenu() { + completion(menu.children) + } else { + completion([]) + } + } + ]) + } + + @objc + private func closeTapped() { + close() + } + + @objc + private func mediaDetailButtonTapped() { + presentDetailView(animated: true) + } + + 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() + + 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 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..2ffd0184de --- /dev/null +++ b/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoControlsView.swift @@ -0,0 +1,706 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Marino Faggiana +// SPDX-License-Identifier: GPL-3.0-or-later + +import SwiftUI +import UIKit + +// MARK: - Video Controls View Delegate + +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 = 52 + 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 = 1 + case .vlcTracks: + visibleButtonsCount = 2 + } + + let totalWidth = (visibleButtonsCount * NCVideoControlsView.topActionsButtonSize) + (max(0, visibleButtonsCount - 1) * NCVideoControlsView.topActionsSpacing) + return NCVideoControlsView.topActionsHorizontalInset + (totalWidth / 2) + } + + private var centerControls: some View { + HStack(spacing: 28) { + circleButton( + systemName: "gobackward.10", + size: 44, + pointSize: 22, + isEnabled: state.isSeekingEnabled, + action: onSeekBackward + ) + + circleButton( + systemName: state.isPlaying ? "pause.fill" : "play.fill", + size: 62, + pointSize: 36, + isEnabled: true, + action: onPlayPause + ) + + circleButton( + systemName: "goforward.10", + size: 44, + pointSize: 22, + isEnabled: state.isSeekingEnabled, + action: onSeekForward + ) + } + .frame( + width: NCVideoControlsView.centerControlsWidth, + height: NCVideoControlsView.centerControlsHeight + ) + } + + private var bottomControls: some View { + HStack(spacing: NCVideoControlsView.topActionsSpacing) { + timeLabel(state.elapsedText) + .frame(width: 54) + + Slider( + value: Binding( + get: { + 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(.black.opacity(0.38)) + .opacity(state.isSeekingEnabled ? 1 : 0.45) + + timeLabel(state.remainingText) + .frame(width: 58) + } + .padding(.horizontal, 18) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background(.white.opacity(0.92)) + .clipShape(Capsule()) + .shadow(color: .black.opacity(0.16), radius: 18, x: 0, y: 5) + .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) + + 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(.black) + .frame( + width: NCVideoControlsView.topActionsButtonSize, + height: NCVideoControlsView.topActionsButtonSize + ) + .background(.white.opacity(0.92)) + .clipShape(Circle()) + .shadow(color: .black.opacity(0.16), radius: 14, x: 0, y: 4) + } + + private func circleButton( + systemName: String, + size: CGFloat, + pointSize: CGFloat, + isEnabled: Bool, + action: @escaping () -> Void + ) -> some View { + Button { + guard isEnabled else { + return + } + + action() + } label: { + Image(systemName: systemName) + .font(.system(size: pointSize, weight: .regular)) + .foregroundStyle(.black) + .frame(width: size, height: size) + .background(.white.opacity(0.92)) + .clipShape(Circle()) + .shadow(color: .black.opacity(0.16), radius: 14, x: 0, y: 4) + } + .buttonStyle(.plain) + .transaction { transaction in + transaction.animation = nil + } + } + + private func timeLabel(_ text: String) -> some View { + Text(text) + .font(.system(size: 15, weight: .medium, design: .rounded).monospacedDigit()) + .foregroundStyle(.black.opacity(0.72)) + .lineLimit(1) + .minimumScaleFactor(0.85) + } +} + +// MARK: - Preview + +#Preview("Video Controls") { + NCVideoControlsPreviewView() + .frame(width: 393, height: 852) + .background(Color.black) + .ignoresSafeArea() +} + +private struct NCVideoControlsPreviewView: UIViewRepresentable { + func makeUIView(context: Context) -> UIView { + let containerView = UIView() + containerView.backgroundColor = .black + + let controlsView = NCVideoControlsView() + controlsView.translatesAutoresizingMaskIntoConstraints = false + controlsView.setTopActionsMode(.pictureInPicture) + controlsView.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(controlsView) + + NSLayoutConstraint.activate([ + controlsView.leadingAnchor.constraint(equalTo: containerView.leadingAnchor), + controlsView.trailingAnchor.constraint(equalTo: containerView.trailingAnchor), + controlsView.topAnchor.constraint(equalTo: containerView.topAnchor), + controlsView.bottomAnchor.constraint(equalTo: containerView.bottomAnchor) + ]) + + return containerView + } + + func updateUIView( + _ uiView: UIView, + context: Context + ) { } +} diff --git a/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoPlaybackController.swift b/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoPlaybackController.swift new file mode 100644 index 0000000000..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..97aad01420 --- /dev/null +++ b/iOSClient/Viewer/NCViewerMedia/Content/Video/NCVideoPlaybackCoverView.swift @@ -0,0 +1,74 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Marino Faggiana +// SPDX-License-Identifier: GPL-3.0-or-later + +import SwiftUI + +struct NCVideoPlaybackCoverView: View { + let previewURL: URL? + let backgroundStyle: NCViewerBackgroundStyle = .system + let isPlayEnabled: Bool + let isLaunchingPlayback: Bool + let onToggleChrome: (() -> Void)? + let onPlay: () -> Void + + var body: some View { + ZStack { + if let previewURL { + AsyncImage(url: previewURL) { phase in + switch phase { + case .success(let image): + image + .resizable() + .scaledToFit() + + case .failure, + .empty: + Color.ncViewerBackground(backgroundStyle) + + @unknown default: + Color.ncViewerBackground(backgroundStyle) + } + } + .ignoresSafeArea() + } else { + Color.ncViewerBackground(backgroundStyle) + .ignoresSafeArea() + } + + Color.clear + .contentShape(Rectangle()) + .ignoresSafeArea() + .onTapGesture { + onToggleChrome?() + } + + Button { + guard isPlayEnabled else { + return + } + + onPlay() + } label: { + Image(systemName: "play.fill") + .font(.system(size: 36, weight: .regular)) + .foregroundStyle(isPlayEnabled ? .black : .black.opacity(0.35)) + .frame(width: 62, height: 62) + .background(.white.opacity(isPlayEnabled ? 0.92 : 0.45)) + .clipShape(Circle()) + .shadow( + color: .black.opacity(isPlayEnabled ? 0.16 : 0.08), + radius: 14, + x: 0, + y: 4 + ) + } + .buttonStyle(.plain) + .disabled(!isPlayEnabled || isLaunchingPlayback) + .opacity(isLaunchingPlayback ? 0 : 1) + .scaleEffect(isLaunchingPlayback ? 1.12 : 1) + .animation(.easeInOut(duration: 0.14), value: isLaunchingPlayback) + .accessibilityLabel(Text(NSLocalizedString("_play_", comment: ""))) + } + } +} diff --git a/iOSClient/Viewer/NCViewerMedia/Content/Video/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..725d2dfbb2 --- /dev/null +++ b/iOSClient/Viewer/NCViewerMedia/Content/Video/VLC/NCVideoVLCViewController.swift @@ -0,0 +1,972 @@ +// 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( + image: NCImageCache.shared.getImageButtonMore(), + primaryAction: nil, + menu: makeMoreMenu() + ) + + private lazy var mediaDetailNavigationItem = UIBarButtonItem( + image: NCUtility().loadImage( + named: "info.circle", + colors: [NCBrandColor.shared.iconImageColor] + ), + style: .plain, + target: self, + action: #selector(mediaDetailButtonTapped) + ) + + // MARK: - Init + + init( + metadata: tableMetadata, + 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() + } + + // Use this controller as sender so actions present above VLC. + private func makeMoreMenu() -> UIMenu { + UIMenu(title: "", children: [ + UIDeferredMenuElement.uncached { [weak self] completion in + guard let self else { + completion([]) + return + } + + if let menu = NCContextMenuViewer( + metadata: self.metadata, + controller: self.contextMenuController, + viewController: self, + webView: false, + sender: self + ).viewMenu() { + completion(menu.children) + } else { + completion([]) + } + } + ]) + } + + @objc + private func closeTapped() { + close() + } + + @objc + private func mediaDetailButtonTapped() { + presentDetailView(animated: true) + } + + 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..3baca12859 --- /dev/null +++ b/iOSClient/Viewer/NCViewerMedia/Core/NCMediaViewerModel.swift @@ -0,0 +1,991 @@ +// 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 initialSelectedIndex: 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 isChromeHidden = false + @Published private(set) var autoPlayTargetIndex: Int? + + // MARK: - Dependencies + + private let loader: NCMediaViewerLoading + + // MARK: - Source Context + + private let session: NCSession.Session + private let mediaSearch: Bool + + // 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 initialSelectedIndex: 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 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 + } + + revision += 1 + } + + // MARK: - Init + + init( + initialModel: NCMediaViewerInitialModel, + session: NCSession.Session, + mediaSearch: Bool, + loader: NCMediaViewerLoading + ) { + self.loader = loader + self.session = session + self.mediaSearch = mediaSearch + self.ocIds = initialModel.normalizedOcIds + self.selectedIndex = initialModel.initialSelectedIndex + + let currentPage = NCMediaViewerPageModel( + index: initialModel.initialSelectedIndex, + ocId: initialModel.currentMetadata.ocId, + metadata: initialModel.currentMetadata, + state: .idle + ) + + cachedPagesByOcId[initialModel.currentMetadata.ocId] = currentPage + } + + convenience init( + currentMetadata: tableMetadata, + ocIds: [String], + session: NCSession.Session, + mediaSearch: Bool, + loader: NCMediaViewerLoading + ) { + let initialModel = NCMediaViewerInitialModel( + currentMetadata: currentMetadata, + ocIds: ocIds + ) + + self.init( + initialModel: initialModel, + session: session, + mediaSearch: mediaSearch, + loader: loader + ) + } + + deinit { + loadingTasksByOcId.values.forEach { $0.task.cancel() } + loadingTasksByOcId.removeAll() + } + + // MARK: - Public API + + 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 + } + + selectedIndex = index + + prefetchNeighborPages(around: index) + await loadPageIfNeeded(index: index) + } + + 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) + } + + func reloadPage(index: Int) async { + guard ocIds.indices.contains(index) else { + return + } + + let ocId = ocIds[index] + + loadingTasksByOcId[ocId]?.task.cancel() + loadingTasksByOcId[ocId] = nil + + updatePage(ocId: ocId) { page in + page.state = .idle + } + + await loadPageIfNeeded(index: index) + } + + 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() + } + + // MARK: - Selected Page Loading + + private func loadPage(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 { + setState(.metadataMissing, for: ocId) + return + } + + setMetadata(metadata, for: ocId) + + let previewURL = currentPreviewURL(for: ocId) + + if let localURL = await loader.localMediaURL(for: metadata, index: index) { + 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, + index: index + ) + + 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, + index: index + ) + + 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, + index: index + ) + } + + 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, + index: index + ) + + 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, + index: index + ) + } 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, + index: index + ) + + 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, + index: index + ) + + 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) async -> tableMetadata? { + if let existingMetadata = cachedPagesByOcId[ocId]?.metadata { + return existingMetadata + } + + return await loader.metadata(for: ocId, account: session.account, mediaSearch: mediaSearch) + } + + 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, + index: index + ) + } 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, + index: index + ) + + 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, + mutation: (inout NCMediaViewerPageModel) -> Void + ) { + guard let index = ocIds.firstIndex(of: ocId) else { + return + } + + var page = cachedPagesByOcId[ocId] ?? NCMediaViewerPageModel( + index: index, + ocId: ocId, + metadata: nil, + state: .idle + ) + + mutation(&page) + + cachedPagesByOcId[ocId] = page + revision &+= 1 + } + + 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..f748af4e71 --- /dev/null +++ b/iOSClient/Viewer/NCViewerMedia/Core/NCMediaViewerView.swift @@ -0,0 +1,91 @@ +// 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() + } + .background(Color.ncViewerBackground(.system)) + .ignoresSafeArea() + .statusBarHidden(true) + .task { + await model.loadSelectedPageIfNeeded() + } + } +} + +// MARK: - Media Viewer Preview + +#if DEBUG +import NextcloudKit + +#Preview("Media Viewer - Light") { + NCMediaViewerView.previewView() + .preferredColorScheme(.light) +} + +#Preview("Media Viewer - Dark") { + NCMediaViewerView.previewView() + .preferredColorScheme(.dark) +} + +private extension NCMediaViewerView { + static func previewView() -> some View { + let metadata = tableMetadata() + metadata.ocId = "preview-ocid" + metadata.fileName = "preview.jpg" + metadata.fileNameView = "preview.jpg" + metadata.classFile = NKTypeClassFile.image.rawValue + + let model = NCMediaViewerModel( + currentMetadata: metadata.detachedCopy(), + ocIds: [ + metadata.ocId + ], + session: NCSession().getSession(account: ""), + mediaSearch: false, + loader: NCMediaViewerLoader() + ) + + return NCMediaViewerView(model: model) + } +} +#endif diff --git a/iOSClient/Viewer/NCViewerMedia/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..950875b5f9 --- /dev/null +++ b/iOSClient/Viewer/NCViewerMedia/Loading/NCNextcloudMediaViewerLoader.swift @@ -0,0 +1,215 @@ +// SPDX-FileCopyrightText: Nextcloud GmbH +// SPDX-FileCopyrightText: 2026 Marino Faggiana +// SPDX-License-Identifier: GPL-3.0-or-later + +import Foundation +import NextcloudKit + +// MARK: - Media Viewer Loader +final class NCMediaViewerLoader: NCMediaViewerLoading, @unchecked Sendable { + private let database = NCManageDatabase.shared + private let utilityFileSystem = NCUtilityFileSystem() + private let fileManager = FileManager.default + + // MARK: - NCMediaViewerLoading + func metadata(for ocId: String, account: String, mediaSearch: Bool) async -> tableMetadata? { + if let metadata = await database.getMetadataFromOcIdAsync(ocId) { + return metadata + } + + guard let fileId = NCUtilityFileSystem().extractFileId(from: ocId) else { + return nil + } + + let resultsFile = await NextcloudKit.shared.getFileFromFileIdAsync( + fileId: fileId, + account: account + ) + + guard resultsFile.error == .success, + let file = resultsFile.file else { + return nil + } + + let metadata = await NCManageDatabaseCreateMetadata().convertFileToMetadataAsync(file, mediaSearch: mediaSearch) + await NCManageDatabase.shared.addMetadataAsync(metadata) + + return metadata + } + + func previewURL(for metadata: tableMetadata, index: Int) async -> URL? { + let localPath = utilityFileSystem.getDirectoryProviderStorageImageOcId( + metadata.ocId, + etag: metadata.etag, + ext: NCGlobal.shared.previewExt1024, + userId: metadata.userId, + urlBase: metadata.urlBase + ) + + if isValidLocalFile(path: localPath) { + return URL(fileURLWithPath: localPath) + } + + let result = await NextcloudKit.shared.downloadPreviewAsync( + fileId: metadata.fileId, + etag: metadata.etag, + account: metadata.account + ) + + if result.error == .success, + let data = result.responseData?.data { + NCUtility().createImageFileFrom( + data: data, + metadata: metadata + ) + } + + guard isValidLocalFile(path: localPath) else { + return nil + } + + return URL(fileURLWithPath: localPath) + } + + func localMediaURL(for metadata: tableMetadata, index: Int) async -> URL? { + let localPath = fullLocalPath(for: metadata) + + guard isValidLocalFile(path: localPath) else { + return nil + } + + return URL(fileURLWithPath: localPath) + } + + func downloadMedia(for metadata: tableMetadata, index: Int) async throws -> URL { + if let localURL = await localMediaURL(for: metadata, index: index) { + 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 \(index)"]) + } + + let result = await NCNetworking.shared.downloadFile(metadata: metadata) + + if let afError = result.afError { + throw afError + } + + if result.nkError != .success { + throw result.nkError + } + + if let localURL = await localMediaURL(for: metadata, index: index) { + return localURL + } + + throw NSError(domain: "Download Media", code: 2) + } + + func localLivePhotoURL(for metadata: tableMetadata, index: Int) 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) else { + return nil + } + + return URL(fileURLWithPath: localPath) + } + + // Live Photo fallback is optional; the image viewer can continue without it. + func downloadLivePhotoMedia(for metadata: tableMetadata, index: Int) async -> URL? { + guard metadata.isLivePhoto else { + return nil + } + + if let localURL = await localLivePhotoURL(for: metadata, index: index) { + return localURL + } + + guard NCNetworking.shared.isOnline else { + return nil + } + + guard let livePhotoMetadata = database.getMetadataLivePhoto(metadata: metadata) else { + return nil + } + + guard !utilityFileSystem.fileProviderStorageExists(livePhotoMetadata) else { + return await localLivePhotoURL(for: metadata, index: index) + } + + guard let downloadMetadata = await database.setMetadataSessionInWaitDownloadAsync( + ocId: livePhotoMetadata.ocId, + session: NCNetworking.shared.sessionDownload, + selector: "" + ) else { + return nil + } + + let result = await NCNetworking.shared.downloadFile(metadata: downloadMetadata) + + if result.afError != nil || result.nkError != .success { + return nil + } + + if let localURL = await localLivePhotoURL(for: metadata, index: index) { + return localURL + } + + return nil + } + + // 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) -> Bool { + guard !path.isEmpty else { + return false + } + + guard fileManager.fileExists(atPath: path) else { + return false + } + + guard let attributes = try? fileManager.attributesOfItem(atPath: path), + let fileSize = attributes[.size] as? Int64, + fileSize > 0 else { + return false + } + + return true + } +} + +protocol NCMediaViewerLoading: Sendable { + func metadata(for ocId: String, account: String, mediaSearch: Bool) async -> tableMetadata? + + func localMediaURL(for metadata: tableMetadata, index: Int) async -> URL? + + func previewURL(for metadata: tableMetadata, index: Int) async -> URL? + + func downloadMedia(for metadata: tableMetadata, index: Int) async throws -> URL + + func localLivePhotoURL(for metadata: tableMetadata, index: Int) async -> URL? + + func downloadLivePhotoMedia(for metadata: tableMetadata, index: Int) async -> URL? +} diff --git a/iOSClient/Viewer/NCViewerMedia/NCMediaViewerHostingController.swift b/iOSClient/Viewer/NCViewerMedia/NCMediaViewerHostingController.swift new file mode 100644 index 0000000000..1f837f413d --- /dev/null +++ b/iOSClient/Viewer/NCViewerMedia/NCMediaViewerHostingController.swift @@ -0,0 +1,478 @@ +// 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( + image: NCImageCache.shared.getImageButtonMore(), + primaryAction: nil, + menu: UIMenu(title: "", children: [ + UIDeferredMenuElement.uncached { [weak self] completion in + guard let self, + let metadata = self.model.selectedMetadata else { + completion([]) + return + } + + if let menu = NCContextMenuViewer( + metadata: metadata, + controller: self.contextMenuController, + viewController: self, + webView: false, + sender: self + ).viewMenu() { + completion(menu.children) + } else { + completion([]) + } + } + ]) + ) + + private lazy var mediaDetailNavigationItem = UIBarButtonItem( + image: NCUtility().loadImage( + named: "info.circle", + colors: [NCBrandColor.shared.iconImageColor] + ), + style: .plain, + target: self, + action: #selector(mediaDetailButtonTapped) + ) + + /// Creates a media viewer hosting controller. + init( + model: NCMediaViewerModel, + contextMenuController: NCMainTabBarController?, + onClose: @escaping (_ ocId: String?) -> Void + ) { + self.model = model + self.contextMenuController = contextMenuController + self.onClose = onClose + + super.init( + rootView: NCMediaViewerView( + model: model, + contextMenuController: contextMenuController, + navigationBar: nil, + onVisibleMetadataChanged: { _, _ in }, + onClose: { _ in } + ) + ) + + rootView = makeRootView(navigationBar: nil) + + transferDelegate = NCMediaViewerTransferDelegate { [weak self] deletedOcId in + guard let self else { + return + } + + self.model.markPageAsDeleted(ocId: deletedOcId) + } + + view.backgroundColor = .ncViewerBackground(.system) + edgesForExtendedLayout = [.all] + extendedLayoutIncludesOpaqueBars = true + additionalSafeAreaInsets = .zero + + configureNavigationItem() + observeModel() + } + + @MainActor + @available(*, unavailable) + dynamic required init?(coder aDecoder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + + updateTitleLabel( + metadata: model.selectedMetadata, + backgroundColor: .ncViewerBackground(.system) + ) + } + + override func viewDidAppear(_ animated: Bool) { + super.viewDidAppear(animated) + + guard let transferDelegate else { + return + } + + Task { + await NCNetworking.shared.transferDispatcher.addDelegate(transferDelegate) + } + } + + override func viewDidDisappear(_ animated: Bool) { + super.viewDidDisappear(animated) + + guard let transferDelegate else { + return + } + + Task { + await NCNetworking.shared.transferDispatcher.removeDelegate(transferDelegate) + } + } + + override func viewDidLayoutSubviews() { + super.viewDidLayoutSubviews() + + updateRootViewNavigationBarIfNeeded() + configureFloatingTitleViewIfNeeded() + } + + private func updateRootViewNavigationBarIfNeeded() { + let navigationBar = navigationController?.navigationBar + + guard currentNavigationBar !== navigationBar else { + return + } + + currentNavigationBar = navigationBar + rootView = makeRootView(navigationBar: navigationBar) + } + + /// Builds the SwiftUI media viewer root view. + 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. + func close(ocId: String? = nil) { + stop() + onClose(ocId) + } + + // MARK: - Navigation + + /// Configures the navigation item used by the viewer. + private func configureNavigationItem() { + navigationItem.largeTitleDisplayMode = .never + navigationItem.title = nil + navigationItem.titleView = nil + + navigationItem.leftBarButtonItem = UIBarButtonItem( + image: UIImage(systemName: "chevron.left"), + style: .plain, + target: self, + action: #selector(closeButtonTapped) + ) + + navigationItem.rightBarButtonItems = [ + moreNavigationItem, + mediaDetailNavigationItem + ] + } + + /// Observes model changes and refreshes navigation UI. + private func observeModel() { + model.$isChromeHidden + .receive(on: RunLoop.main) + .sink { [weak self] isHidden in + self?.setChromeHidden(isHidden, animated: true) + } + .store(in: &cancellables) + } + + /// Configures the floating title view inside the navigation bar chrome. + private func configureFloatingTitleViewIfNeeded() { + guard let navigationBar = navigationController?.navigationBar else { + return + } + + floatingTitleView.attach(to: navigationBar) + } + + /// Updates the floating title 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() + } + + @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 + let sceneIdentifier: String = "" + + init(onDeletedOcId: @escaping @MainActor (_ ocId: String) -> Void) { + self.onDeletedOcId = onDeletedOcId + } + + func transferReloadData(serverUrl: String?) { } + + func transferReloadDataSource( + serverUrl: String?, + requestData: Bool, + status: Int? + ) { } + + func transferProgressDidUpdate( + progress: Float, + totalBytes: Int64, + totalBytesExpected: Int64, + fileName: String, + serverUrl: String + ) { } + + func transferChange( + status: String, + account: String, + fileName: String, + serverUrl: String, + selector: String?, + ocId: String, + destination: String?, + error: NKError + ) { + guard status == NCGlobal.shared.networkingStatusDelete, + error == .success else { + return + } + + Task { @MainActor in + onDeletedOcId(ocId) + } + } +} diff --git a/iOSClient/Viewer/NCViewerMedia/NCMediaViewerPresenter.swift b/iOSClient/Viewer/NCViewerMedia/NCMediaViewerPresenter.swift new file mode 100644 index 0000000000..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 742d90fddf..0000000000 --- a/iOSClient/Viewer/NCViewerMedia/NCPlayer/NCPlayerToolBar.swift +++ /dev/null @@ -1,448 +0,0 @@ -// SPDX-FileCopyrightText: Nextcloud GmbH -// SPDX-FileCopyrightText: 2021 Marino Faggiana -// SPDX-License-Identifier: GPL-3.0-or-later - -import Foundation -import NextcloudKit -import CoreMedia -import UIKit -import AVKit -import MediaPlayer -import MobileVLCKit -import Alamofire -import LucidBanner - -class NCPlayerToolBar: UIView { - @IBOutlet weak var utilityView: UIView! - @IBOutlet weak var fullscreenButton: UIButton! - @IBOutlet weak var subtitleButton: UIButton! - @IBOutlet weak var audioButton: UIButton! - - @IBOutlet weak var playerButtonView: UIStackView! - @IBOutlet weak var backButton: UIButton! - @IBOutlet weak var playButton: UIButton! - @IBOutlet weak var forwardButton: UIButton! - - @IBOutlet weak var playbackSliderView: UIView! - @IBOutlet weak var playbackSlider: NCPlayerToolBarSlider! - @IBOutlet weak var labelLeftTime: UILabel! - @IBOutlet weak var labelCurrentTime: UILabel! - @IBOutlet weak var repeatButton: UIButton! - - enum sliderEventType { - case none - case began - case ended - case moved - } - - var playbackSliderEvent: sliderEventType = .none - var isFullscreen: Bool = false - var playRepeat: Bool = false - - private var ncplayer: NCPlayer? - private var metadata: tableMetadata? - private let audioSession = AVAudioSession.sharedInstance() - private var pointSize: CGFloat = 0 - private let utilityFileSystem = NCUtilityFileSystem() - private let utility = NCUtility() - private let global = NCGlobal.shared - private let database = NCManageDatabase.shared - private weak var viewerMediaPage: NCViewerMediaPage? - private var buttonImage = UIImage() - - // MARK: - View Life Cycle - - override func awakeFromNib() { - super.awakeFromNib() - - self.backgroundColor = UIColor.black.withAlphaComponent(0.1) - - fullscreenButton.setImage(utility.loadImage(named: "arrow.up.left.and.arrow.down.right", colors: [.white]), for: .normal) - - subtitleButton.setImage(utility.loadImage(named: "captions.bubble", colors: [.white]), for: .normal) - subtitleButton.isEnabled = false - subtitleButton.showsMenuAsPrimaryAction = true - - audioButton.setImage(utility.loadImage(named: "speaker.zzz", colors: [.white]), for: .normal) - audioButton.isEnabled = false - audioButton.showsMenuAsPrimaryAction = true - - if UIDevice.current.userInterfaceIdiom == .pad { - pointSize = 60 - } else { - pointSize = 50 - } - - playerButtonView.spacing = pointSize - playerButtonView.isHidden = true - - buttonImage = UIImage(systemName: "gobackward.10", withConfiguration: UIImage.SymbolConfiguration(pointSize: pointSize))!.withTintColor(.white, renderingMode: .alwaysOriginal) - backButton.setImage(buttonImage, for: .normal) - - buttonImage = UIImage(systemName: "play.fill", withConfiguration: UIImage.SymbolConfiguration(pointSize: pointSize))!.withTintColor(.white, renderingMode: .alwaysOriginal) - playButton.setImage(buttonImage, for: .normal) - - buttonImage = UIImage(systemName: "goforward.10", withConfiguration: UIImage.SymbolConfiguration(pointSize: pointSize))!.withTintColor(.white, renderingMode: .alwaysOriginal) - forwardButton.setImage(buttonImage, for: .normal) - - playbackSlider.addTapGesture() - playbackSlider.setThumbImage(UIImage(systemName: "circle.fill", withConfiguration: UIImage.SymbolConfiguration(pointSize: 15)), for: .normal) - playbackSlider.value = 0 - playbackSlider.tintColor = .white - playbackSlider.addTarget(self, action: #selector(playbackValChanged(slider:event:)), for: .valueChanged) - repeatButton.setImage(utility.loadImage(named: "repeat", colors: [NCBrandColor.shared.iconImageColor2]), for: .normal) - - utilityView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(tap(gestureRecognizer:)))) - playbackSliderView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(tap(gestureRecognizer:)))) - playbackSliderView.addGestureRecognizer(UIPanGestureRecognizer(target: self, action: #selector(tap(gestureRecognizer:)))) - playerButtonView.addGestureRecognizer(UITapGestureRecognizer(target: self, action: #selector(tap(gestureRecognizer:)))) - - labelCurrentTime.textColor = .white - labelLeftTime.textColor = .white - - // Normally hide - self.alpha = 0 - self.isHidden = true - } - - required init?(coder aDecoder: NSCoder) { - super.init(coder: aDecoder) - } - - deinit { - print("deinit NCPlayerToolBar") - } - - // MARK: - - - func setBarPlayer(position: Float, ncplayer: NCPlayer? = nil, metadata: tableMetadata? = nil, viewerMediaPage: NCViewerMediaPage? = nil) { - if let ncplayer = ncplayer { - self.ncplayer = ncplayer - } - if let metadata = metadata { - self.metadata = metadata - } - if let viewerMediaPage = viewerMediaPage { - self.viewerMediaPage = viewerMediaPage - } - - playerButtonView.isHidden = true - - buttonImage = UIImage(systemName: "play.fill", withConfiguration: UIImage.SymbolConfiguration(pointSize: pointSize))!.withTintColor(.white, renderingMode: .alwaysOriginal) - playButton.setImage(buttonImage, for: .normal) - - playbackSlider.value = position - - labelCurrentTime.text = "--:--" - labelLeftTime.text = "--:--" - - if viewerMediaScreenMode == .normal { - show() - } else { - hide() - } - - MPNowPlayingInfoCenter.default().nowPlayingInfo?[MPNowPlayingInfoPropertyPlaybackRate] = position - - setupSubtitleButton() - setupAudioButton() - } - - public func updatePlaybackPosition() { - guard let ncplayer = self.ncplayer, - let media = ncplayer.player.media else { - return - } - - let length = media.length.intValue - - let position = ncplayer.player.position - - let currentSeconds = Double(position) * (Double(length) / 1000.0) - - let currentTimeObj = VLCTime(int: Int32(currentSeconds * 1000)) - let remainingTimeObj = VLCTime(int: Int32((Double(length) / 1000.0) - currentSeconds) * 1000) - - labelCurrentTime.text = currentTimeObj.stringValue == "--:--" ? "00:00" : currentTimeObj.stringValue - - let remaining = remainingTimeObj.stringValue - labelLeftTime.text = "-\(remaining)" - - if playbackSliderEvent == .ended { - playbackSlider.value = position - } - - MPNowPlayingInfoCenter.default().nowPlayingInfo?[MPMediaItemPropertyPlaybackDuration] = length / 1000 - MPNowPlayingInfoCenter.default().nowPlayingInfo?[MPNowPlayingInfoPropertyElapsedPlaybackTime] = currentSeconds - } - - public func updateTopToolBar(videoSubTitlesIndexes: [Any], audioTrackIndexes: [Any]) { - if let metadata = metadata, metadata.isVideo { - self.subtitleButton.isEnabled = true - self.audioButton.isEnabled = true - } - } - - // MARK: - - - public func show() { - UIView.animate(withDuration: 0.5, animations: { - self.alpha = 1 - }, completion: { (_: Bool) in - self.isHidden = false - }) - } - - func hide() { - UIView.animate(withDuration: 0.5, animations: { - self.alpha = 0 - }, completion: { (_: Bool) in - self.isHidden = true - }) - } - - func showPauseButton() { - buttonImage = UIImage(systemName: "pause.fill", withConfiguration: UIImage.SymbolConfiguration(pointSize: pointSize))!.withTintColor(.white, renderingMode: .alwaysOriginal) - playButton.setImage(buttonImage, for: .normal) - MPNowPlayingInfoCenter.default().nowPlayingInfo?[MPNowPlayingInfoPropertyPlaybackRate] = 1 - } - - func showPlayButton() { - buttonImage = UIImage(systemName: "play.fill", withConfiguration: UIImage.SymbolConfiguration(pointSize: pointSize))!.withTintColor(.white, renderingMode: .alwaysOriginal) - playButton.setImage(buttonImage, for: .normal) - MPNowPlayingInfoCenter.default().nowPlayingInfo?[MPNowPlayingInfoPropertyPlaybackRate] = 0 - } - - // MARK: - Event / Gesture - - @objc func playbackValChanged(slider: UISlider, event: UIEvent) { - guard let ncplayer = ncplayer else { return } - let newPosition = playbackSlider.value - - if let touchEvent = event.allTouches?.first { - switch touchEvent.phase { - case .began: - viewerMediaPage?.timerAutoHide?.invalidate() - playbackSliderEvent = .began - case .moved: - ncplayer.playerPosition(newPosition) - playbackSliderEvent = .moved - case .ended: - ncplayer.playerPosition(newPosition) - DispatchQueue.main.asyncAfter(deadline: .now() + 1) { - self.playbackSliderEvent = .ended - self.viewerMediaPage?.startTimerAutoHide() - } - default: - break - } - } else { - ncplayer.playerPosition(newPosition) - self.viewerMediaPage?.startTimerAutoHide() - } - } - - // MARK: - Action - - @objc func tap(gestureRecognizer: UITapGestureRecognizer) { } - - @IBAction func tapFullscreen(_ sender: Any) { - isFullscreen = !isFullscreen - if isFullscreen { - fullscreenButton.setImage(utility.loadImage(named: "arrow.down.right.and.arrow.up.left", colors: [.white]), for: .normal) - } else { - fullscreenButton.setImage(utility.loadImage(named: "arrow.up.left.and.arrow.down.right", colors: [.white]), for: .normal) - } - viewerMediaPage?.changeScreenMode(mode: viewerMediaScreenMode) - } - - private func setupSubtitleButton() { - guard let player = ncplayer?.player else { return } - - var currentIndex: Int? - if let data = database.getVideo(metadata: metadata), let idx = data.currentVideoSubTitleIndex { - currentIndex = idx - } else { - currentIndex = Int(player.currentVideoSubTitleIndex) - } - - subtitleButton.menu = NCContextMenuPlayerTracks( - trackType: .subtitle, - tracks: player.videoSubTitlesNames, - trackIndexes: player.videoSubTitlesIndexes, - currentIndex: currentIndex, - ncplayer: ncplayer, - metadata: metadata, - viewerMediaPage: viewerMediaPage - ).viewMenu() - } - - private func setupAudioButton() { - guard let player = ncplayer?.player else { return } - - var currentIndex: Int? - if let data = database.getVideo(metadata: metadata), let idx = data.currentAudioTrackIndex { - currentIndex = idx - } else { - currentIndex = Int(player.currentAudioTrackIndex) - } - - audioButton.menu = NCContextMenuPlayerTracks( - trackType: .audio, - tracks: player.audioTrackNames, - trackIndexes: player.audioTrackIndexes, - currentIndex: currentIndex, - ncplayer: ncplayer, - metadata: metadata, - viewerMediaPage: viewerMediaPage - ).viewMenu() - } - - @IBAction func tapPlayerPause(_ sender: Any) { - guard let ncplayer = ncplayer else { return } - - if ncplayer.isPlaying() { - ncplayer.playerPause() - } else { - ncplayer.playerPlay() - } - - self.viewerMediaPage?.startTimerAutoHide() - } - - @IBAction func tapForward(_ sender: Any) { - guard let ncplayer = ncplayer else { return } - - ncplayer.jumpForward(10) - self.viewerMediaPage?.startTimerAutoHide() - } - - @IBAction func tapBack(_ sender: Any) { - guard let ncplayer = ncplayer else { return } - - ncplayer.jumpBackward(10) - self.viewerMediaPage?.startTimerAutoHide() - } - - @IBAction func tapRepeat(_ sender: Any) { - if playRepeat { - playRepeat = false - repeatButton.setImage(utility.loadImage(named: "repeat", colors: [NCBrandColor.shared.iconImageColor2]), for: .normal) - } else { - playRepeat = true - repeatButton.setImage(utility.loadImage(named: "repeat", colors: [.white]), for: .normal) - } - } -} - -extension NCPlayerToolBar: NCSelectDelegate { - func dismissSelect(serverUrl: String?, metadata: tableMetadata?, type: String, items: [Any], overwrite: Bool, copy: Bool, move: Bool, session: NCSession.Session, controller: NCMainTabBarController?) { - if let metadata = metadata, let viewerMediaPage = viewerMediaPage { - let fileNameLocalPath = NCUtilityFileSystem().getDirectoryProviderStorageOcId(metadata.ocId, fileName: metadata.fileNameView, userId: metadata.userId, urlBase: metadata.urlBase) - let windowScene = SceneManager.shared.getWindowScene(controller: viewerMediaPage.tabBarController) - - if utilityFileSystem.fileProviderStorageExists(metadata) { - addPlaybackSlave(type: type, metadata: metadata) - } else { - var downloadRequest: DownloadRequest? - let (banner, token) = showHudBanner(windowScene: windowScene, - title: "_download_in_progress_", - stage: .button) { - if let request = downloadRequest { - request.cancel() - } - } - - NextcloudKit.shared.download(serverUrlFileName: metadata.serverUrlFileName, fileNameLocalPath: fileNameLocalPath, account: metadata.account, requestHandler: { request in - downloadRequest = request - }, taskHandler: { task in - Task { - let identifier = await NCNetworking.shared.networkingTasks.createIdentifier(account: metadata.account, - path: metadata.serverUrlFileName, - name: "download") - await NCNetworking.shared.networkingTasks.track(identifier: identifier, task: task) - - let ocId = metadata.ocId - await self.database.setMetadataSessionAsync(ocId: ocId, - sessionTaskIdentifier: task.taskIdentifier, - status: self.global.metadataStatusDownloading) - } - }, progressHandler: { progress in - Task {@MainActor in - banner?.update(payload: LucidBannerPayload.Update(progress: Double(progress.fractionCompleted)), - for: token) - } - }) { _, etag, _, _, _, _, error in - Task { - if let banner { - banner.dismiss() - } - - let ocId = metadata.ocId - await self.database.setMetadataSessionAsync(ocId: ocId, - session: "", - sessionTaskIdentifier: 0, - sessionError: "", - status: self.global.metadataStatusNormal, - etag: etag) - - if error == .success { - self.addPlaybackSlave(type: type, metadata: metadata) - } else if error.errorCode != 200 { - await showErrorBanner(windowScene: windowScene, - text: error.errorDescription, - errorCode: error.errorCode) - } - } - } - } - } - } - - // swiftlint:disable inclusive_language - func addPlaybackSlave(type: String, metadata: tableMetadata) { - // swiftlint:enable inclusive_language - let fileNameLocalPath = utilityFileSystem.getDirectoryProviderStorageOcId(metadata.ocId, fileName: metadata.fileNameView, userId: metadata.userId, urlBase: metadata.urlBase) - - if type == "subtitle" { - self.ncplayer?.player.addPlaybackSlave(URL(fileURLWithPath: fileNameLocalPath), type: .subtitle, enforce: true) - } else if type == "audio" { - self.ncplayer?.player.addPlaybackSlave(URL(fileURLWithPath: fileNameLocalPath), type: .audio, enforce: true) - } - } -} - -// https://stackoverflow.com/questions/13196263/custom-uislider-increase-hot-spot-size -// -class NCPlayerToolBarSlider: UISlider { - private var thumbTouchSize = CGSize(width: 100, height: 100) - - override func point(inside point: CGPoint, with event: UIEvent?) -> Bool { - let increasedBounds = bounds.insetBy(dx: -thumbTouchSize.width, dy: -thumbTouchSize.height) - let containsPoint = increasedBounds.contains(point) - return containsPoint - } - - override func beginTracking(_ touch: UITouch, with event: UIEvent?) -> Bool { - let percentage = CGFloat((value - minimumValue) / (maximumValue - minimumValue)) - let thumbSizeHeight = thumbRect(forBounds: bounds, trackRect: trackRect(forBounds: bounds), value: 0).size.height - let thumbPosition = thumbSizeHeight + (percentage * (bounds.size.width - (2 * thumbSizeHeight))) - let touchLocation = touch.location(in: self) - return touchLocation.x <= (thumbPosition + thumbTouchSize.width) && touchLocation.x >= (thumbPosition - thumbTouchSize.width) - } - - public func addTapGesture() { - let tap = UITapGestureRecognizer(target: self, action: #selector(handleTap(_:))) - - addGestureRecognizer(tap) - } - - @objc private func handleTap(_ sender: UITapGestureRecognizer) { - let location = sender.location(in: self) - let percent = minimumValue + Float(location.x / bounds.width) * (maximumValue - minimumValue) - - setValue(percent, animated: true) - sendActions(for: .valueChanged) - } -} diff --git a/iOSClient/Viewer/NCViewerMedia/NCPlayer/NCPlayerToolBar.xib b/iOSClient/Viewer/NCViewerMedia/NCPlayer/NCPlayerToolBar.xib deleted file mode 100644 index deaf0d7558..0000000000 --- a/iOSClient/Viewer/NCViewerMedia/NCPlayer/NCPlayerToolBar.xib +++ /dev/null @@ -1,162 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/iOSClient/Viewer/NCViewerMedia/NCViewerMedia+VisionKit.swift b/iOSClient/Viewer/NCViewerMedia/NCViewerMedia+VisionKit.swift deleted file mode 100644 index 0a79eb4d5a..0000000000 --- a/iOSClient/Viewer/NCViewerMedia/NCViewerMedia+VisionKit.swift +++ /dev/null @@ -1,29 +0,0 @@ -// SPDX-FileCopyrightText: Nextcloud GmbH -// SPDX-FileCopyrightText: 2024 Milen -// SPDX-License-Identifier: GPL-3.0-or-later - -import Foundation -import UIKit -import VisionKit - -extension NCViewerMedia { - func analyzeCurrentImage() { - if let image = image { - let interaction = ImageAnalysisInteraction() - let analyzer = ImageAnalyzer() - interaction.preferredInteractionTypes = [] - interaction.analysis = nil - - self.imageVideoContainer.addInteraction(interaction) - let configuration = ImageAnalyzer.Configuration([.text, .machineReadableCode, .visualLookUp]) - - Task { - let analysis = try? await analyzer.analyze(image, configuration: configuration) - if image == self.image { - interaction.analysis = analysis - interaction.preferredInteractionTypes = .automatic - } - } - } - } -} diff --git a/iOSClient/Viewer/NCViewerMedia/NCViewerMedia.swift b/iOSClient/Viewer/NCViewerMedia/NCViewerMedia.swift deleted file mode 100644 index 0aeb8f94e7..0000000000 --- a/iOSClient/Viewer/NCViewerMedia/NCViewerMedia.swift +++ /dev/null @@ -1,652 +0,0 @@ -// SPDX-FileCopyrightText: Nextcloud GmbH -// SPDX-FileCopyrightText: 2020 Marino Faggiana -// SPDX-License-Identifier: GPL-3.0-or-later - -import UIKit -import NextcloudKit -import EasyTipView -import SwiftUI -import MobileVLCKit -import Alamofire -import LucidBanner - -public protocol NCViewerMediaViewDelegate: AnyObject { - func didOpenDetail() - func didCloseDetail() -} - -class NCViewerMedia: UIViewController { - @IBOutlet weak var detailViewTopConstraint: NSLayoutConstraint! - @IBOutlet weak var imageViewTopConstraint: NSLayoutConstraint! - @IBOutlet weak var imageViewBottomConstraint: NSLayoutConstraint! - @IBOutlet weak var scrollView: UIScrollView! - @IBOutlet weak var imageVideoContainer: UIImageView! - @IBOutlet weak var statusViewImage: UIImageView! - @IBOutlet weak var statusLabel: UILabel! - @IBOutlet weak var detailView: NCViewerMediaDetailView! - - private let player = VLCMediaPlayer() - private let appDelegate = (UIApplication.shared.delegate as? AppDelegate)! - let utilityFileSystem = NCUtilityFileSystem() - let utility = NCUtility() - let global = NCGlobal.shared - let database = NCManageDatabase.shared - let networking = NCNetworking.shared - weak var viewerMediaPage: NCViewerMediaPage? - var playerToolBar: NCPlayerToolBar? - var ncplayer: NCPlayer? - var image: UIImage? { - didSet { - if metadata.isImage { - analyzeCurrentImage() - } - } - } - var metadata: tableMetadata = tableMetadata() - var index: Int = 0 - var doubleTapGestureRecognizer: UITapGestureRecognizer = UITapGestureRecognizer() - var imageViewConstraint: CGFloat = 0 - var isDetailViewInitializze: Bool = false - weak var delegate: NCViewerMediaViewDelegate? - - private var allowOpeningDetails = true - private var tipView: EasyTipView? - - var sceneIdentifier: String { - (self.tabBarController as? NCMainTabBarController)?.sceneIdentifier ?? "" - } - - internal var windowScene: UIWindowScene? { - SceneManager.shared.getWindowScene(controller: self.tabBarController as? NCMainTabBarController) - } - - // MARK: - View Life Cycle - - required init?(coder aDecoder: NSCoder) { - super.init(coder: aDecoder) - - doubleTapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(didDoubleTapWith(gestureRecognizer:))) - doubleTapGestureRecognizer.numberOfTapsRequired = 2 - } - - deinit { - print("deinit NCViewerMedia") - NotificationCenter.default.removeObserver(self, name: NSNotification.Name(rawValue: global.notificationCenterOpenMediaDetail), object: nil) - } - - override func viewDidLoad() { - super.viewDidLoad() - - scrollView.delegate = self - scrollView.maximumZoomScale = 4 - scrollView.minimumZoomScale = 1 - - view.addGestureRecognizer(doubleTapGestureRecognizer) - - if self.database.getMetadataLivePhoto(metadata: metadata) != nil { - statusViewImage.image = utility.loadImage(named: "livephoto", colors: [NCBrandColor.shared.iconImageColor2]) - statusLabel.text = "LIVE" - } else { - statusViewImage.image = nil - statusLabel.text = "" - } - - if metadata.isAudioOrVideo { - playerToolBar = Bundle.main.loadNibNamed("NCPlayerToolBar", owner: self, options: nil)?.first as? NCPlayerToolBar - if let playerToolBar = playerToolBar { - view.addSubview(playerToolBar) - playerToolBar.translatesAutoresizingMaskIntoConstraints = false - playerToolBar.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor).isActive = true - playerToolBar.bottomAnchor.constraint(equalTo: view.bottomAnchor).isActive = true - playerToolBar.leadingAnchor.constraint(equalTo: view.leadingAnchor).isActive = true - playerToolBar.trailingAnchor.constraint(equalTo: view.trailingAnchor).isActive = true - } - - self.ncplayer = NCPlayer(imageVideoContainer: self.imageVideoContainer, playerToolBar: self.playerToolBar, metadata: self.metadata, viewerMediaPage: self.viewerMediaPage) - } - - detailViewTopConstraint.constant = 0 - detailView.hide() - - self.image = nil - self.imageVideoContainer.image = nil - - Task {@MainActor in - await loadImage() - } - } - - override func viewWillAppear(_ animated: Bool) { - super.viewWillAppear(animated) - - if #available(iOS 18.0, *) { - tabBarController?.setTabBarHidden(true, animated: true) - } else { - tabBarController?.tabBar.isHidden = true - } - - viewerMediaPage?.navigationItem.setBidiSafeTitle(metadata.fileNameView) - - if metadata.isImage, let viewerMediaPage = self.viewerMediaPage { - if viewerMediaPage.modifiedOcId.contains(metadata.ocId) { - viewerMediaPage.modifiedOcId.removeAll(where: { $0 == metadata.ocId }) - Task {@MainActor in - await loadImage() - } - } - } - } - - override func viewDidAppear(_ animated: Bool) { - super.viewDidAppear(animated) - - Task { - await NCNetworking.shared.transferDispatcher.addDelegate(self) - } - - viewerMediaPage?.clearCommandCenter() - - if metadata.isAudioOrVideo { - if let ncplayer = self.ncplayer { - if ncplayer.url == nil { - NCActivityIndicator.shared.startActivity(backgroundView: self.view, style: .medium) - self.networking.getVideoUrl(metadata: metadata) { url, autoplay, error in - NCActivityIndicator.shared.stop() - if error == .success, let url = url { - ncplayer.openAVPlayer(url: url, autoplay: autoplay) - } else { - Task { @MainActor in - guard let metadata = await self.database.setMetadataSessionInWaitDownloadAsync(ocId: self.metadata.ocId, - session: self.networking.sessionDownload, - selector: "") else { - return - } - var downloadRequest: DownloadRequest? - let (banner, token) = showHudBanner(windowScene: self.windowScene, - title: "_download_in_progress_", - stage: .button) { - if let request = downloadRequest { - request.cancel() - } - } - - let results = await self.networking.downloadFile(metadata: metadata) { request in - downloadRequest = request - } progressHandler: { progress in - Task {@MainActor in - banner?.update( - payload: LucidBannerPayload.Update(progress: progress.fractionCompleted), - for: token - ) - } - } - - if let banner { - banner.dismiss() - } - - if results.nkError == .success { - if self.utilityFileSystem.fileProviderStorageExists(self.metadata) { - let url = URL(fileURLWithPath: self.utilityFileSystem.getDirectoryProviderStorageOcId(self.metadata.ocId, fileName: self.metadata.fileNameView, userId: self.metadata.userId, urlBase: self.metadata.urlBase)) - ncplayer.openAVPlayer(url: url, autoplay: autoplay) - } - } - } - } - } - } else { - var position: Float = 0 - if let result = self.database.getVideo(metadata: metadata), let resultPosition = result.position { - position = resultPosition - } - ncplayer.restartAVPlayer(position: position, pauseAfterPlay: true) - } - } - } else if metadata.isImage { - DispatchQueue.main.asyncAfter(deadline: .now() + 1) { - self.showTip() - } - } - - NotificationCenter.default.addObserver(self, selector: #selector(openDetail(_:)), name: NSNotification.Name(rawValue: global.notificationCenterOpenMediaDetail), object: nil) - } - - override func viewWillDisappear(_ animated: Bool) { - super.viewWillDisappear(animated) - - dismissTip() - } - - override func viewDidDisappear(_ animated: Bool) { - super.viewDidDisappear(animated) - - Task { - await NCNetworking.shared.transferDispatcher.removeDelegate(self) - } - - if let ncplayer, ncplayer.isPlaying() { - ncplayer.playerPause() - } - } - - override func viewWillTransition(to size: CGSize, with coordinator: UIViewControllerTransitionCoordinator) { - super.viewWillTransition(to: size, with: coordinator) - - let wasShownDetail = detailView.isShown - - if UIDevice.current.orientation.isValidInterfaceOrientation { - if wasShownDetail { - closeDetail(animate: false) - } - dismissTip() - - coordinator.animate(alongsideTransition: { _ in - // back to the original size - if self.scrollView.zoomScale != self.scrollView.minimumZoomScale { - self.scrollView.zoom(to: CGRect(x: 0, y: 0, width: self.scrollView.bounds.width, height: self.scrollView.bounds.height), animated: false) - self.view.layoutIfNeeded() - } - }, completion: { _ in - if wasShownDetail { - self.openDetail(animate: true) - } - }) - } - } - - // MARK: - Image - - @MainActor - func loadImage() async { - guard let metadata = self.database.getMetadataFromOcId(metadata.ocId) else { return } - self.metadata = metadata - let fileNamePath = utilityFileSystem.getDirectoryProviderStorageOcId(metadata.ocId, - fileName: metadata.fileNameView, - userId: metadata.userId, - urlBase: metadata.urlBase) - let fileNameExtension = (metadata.fileNameView as NSString).pathExtension.uppercased() - - if metadata.isLivePhoto, - self.networking.isOnline, - let metadata = self.database.getMetadataLivePhoto(metadata: metadata), - !utilityFileSystem.fileProviderStorageExists(metadata) { - Task { - if let metadata = await self.database.setMetadataSessionInWaitDownloadAsync(ocId: metadata.ocId, - session: self.networking.sessionDownload, - selector: "") { - await self.networking.downloadFile(metadata: metadata) - } - } - } - - if metadata.isImage, fileNameExtension == "GIF" || fileNameExtension == "SVG", !utilityFileSystem.fileProviderStorageExists(metadata) { - await downloadImage() - } - - if metadata.isVideo && !metadata.hasPreview { - utility.createImageFileFrom(metadata: metadata) - let image = utility.getImage(ocId: metadata.ocId, etag: metadata.etag, ext: global.previewExt1024, userId: metadata.userId, urlBase: metadata.urlBase) - self.image = image - self.imageVideoContainer.image = self.image - return - } else if metadata.isAudio { - let image = utility.loadImage(named: "waveform", colors: [NCBrandColor.shared.iconImageColor2]) - self.image = image - self.imageVideoContainer.image = self.image - return - } else if metadata.isImage { - if fileNameExtension == "GIF" { - if !NCUtility().existsImage(ocId: metadata.ocId, etag: metadata.etag, ext: global.previewExt1024, userId: metadata.userId, urlBase: metadata.urlBase) { - utility.createImageFileFrom(metadata: metadata) - } - if let image = UIImage.animatedImage(withAnimatedGIFURL: URL(fileURLWithPath: fileNamePath)) { - self.image = image - self.imageVideoContainer.image = self.image - } else { - self.image = self.utility.loadImage(named: "photo.badge.arrow.down", colors: [NCBrandColor.shared.iconImageColor2]) - self.imageVideoContainer.image = self.image - } - return - } else if fileNameExtension == "SVG" { - do { - let fileNamePathPNG = utilityFileSystem.replaceExtension(fileNamePath: fileNamePath, with: "png") - if FileManager.default.fileExists(atPath: fileNamePathPNG) { - let data = try Data(contentsOf: URL(fileURLWithPath: fileNamePathPNG)) - self.image = UIImage(data: data) - self.imageVideoContainer.image = self.image - } else { - let svgData = try Data(contentsOf: URL(fileURLWithPath: fileNamePath)) - if let image = try await NCSVGRenderer().renderSVGToUIImage(svgData: svgData, size: CGSize(width: 1024, height: 1024)), - let data = image.pngData() { - self.image = image - self.imageVideoContainer.image = self.image - try data.write(to: URL(fileURLWithPath: fileNamePathPNG)) - utility.createImageFileFrom(data: data, metadata: metadata) - } - } - return - } catch { - print("Unsupported image format: \(error.localizedDescription)") - self.image = self.utility.loadImage(named: "photo", colors: [NCBrandColor.shared.iconImageColor2]) - self.imageVideoContainer.image = self.image - } - return - } else if let image = UIImage(contentsOfFile: fileNamePath) { - self.image = image - self.imageVideoContainer.image = self.image - return - } - } - - if let image = UIImage(contentsOfFile: utilityFileSystem.getDirectoryProviderStorageImageOcId(metadata.ocId, - etag: metadata.etag, - ext: global.previewExt1024, - userId: metadata.userId, - urlBase: metadata.urlBase)) { - self.image = image - self.imageVideoContainer.image = self.image - } else { - NextcloudKit.shared.downloadPreview(fileId: metadata.fileId, - etag: metadata.etag, - account: metadata.account, - options: NKRequestOptions(queue: .main)) { task in - Task { - let identifier = await NCNetworking.shared.networkingTasks.createIdentifier(account: metadata.account, - path: metadata.fileId, - name: "DownloadPreview") - await NCNetworking.shared.networkingTasks.track(identifier: identifier, task: task) - } - } completion: { _, _, _, _, responseData, error in - if error == .success, let data = responseData?.data { - let image = UIImage(data: data) - self.image = image - self.imageVideoContainer.image = self.image - } else { - self.image = self.utility.loadImage(named: "photo", colors: [NCBrandColor.shared.iconImageColor2]) - self.imageVideoContainer.image = self.image - } - } - } - } - - private func downloadImage(withSelector selector: String = "") async { - if let metadata = await self.database.setMetadataSessionInWaitDownloadAsync(ocId: metadata.ocId, - session: self.networking.sessionDownload, - selector: selector) { - await self.networking.downloadFile(metadata: metadata) { _ in - self.allowOpeningDetails = false - } taskHandler: { _ in } - self.allowOpeningDetails = true - } - } - - // MARK: - Live Photo - - func playLivePhoto(filePath: String) { - updateViewConstraints() - statusViewImage.isHidden = true - statusLabel.isHidden = true - - player.media = VLCMedia(url: URL(fileURLWithPath: filePath)) - player.drawable = imageVideoContainer - player.play() - } - - func stopLivePhoto() { - player.stop() - - statusViewImage.isHidden = false - statusLabel.isHidden = false - } - - // MARK: - Gesture - - @objc func didDoubleTapWith(gestureRecognizer: UITapGestureRecognizer) { - guard metadata.isImage, !detailView.isShown else { return } - let pointInView = gestureRecognizer.location(in: self.imageVideoContainer) - var newZoomScale = self.scrollView.maximumZoomScale - - if self.scrollView.zoomScale >= newZoomScale || abs(self.scrollView.zoomScale - newZoomScale) <= 0.01 { - newZoomScale = self.scrollView.minimumZoomScale - } - - let width = self.scrollView.bounds.width / newZoomScale - let height = self.scrollView.bounds.height / newZoomScale - let originX = pointInView.x - (width / 2.0) - let originY = pointInView.y - (height / 2.0) - let rectToZoomTo = CGRect(x: originX, y: originY, width: width, height: height) - self.scrollView.zoom(to: rectToZoomTo, animated: true) - } - - @objc func didPanWith(gestureRecognizer: UIPanGestureRecognizer) { - guard metadata.isImage else { return } - let currentLocation = gestureRecognizer.translation(in: self.view) - - switch gestureRecognizer.state { - case .ended: - if detailView.isShown { - self.imageViewTopConstraint.constant = -imageViewConstraint - self.imageViewBottomConstraint.constant = imageViewConstraint - } else { - self.imageViewTopConstraint.constant = 0 - self.imageViewBottomConstraint.constant = 0 - } - - case .changed: - imageViewTopConstraint.constant = (currentLocation.y - imageViewConstraint) - imageViewBottomConstraint.constant = -(currentLocation.y - imageViewConstraint) - - // DISMISS VIEW - if detailView.isHidden && (currentLocation.y > 20) { - - viewerMediaPage?.navigationController?.popViewController(animated: true) - gestureRecognizer.state = .ended - } - - // CLOSE DETAIL - if !detailView.isHidden && (currentLocation.y > 20) { - - self.closeDetail() - gestureRecognizer.state = .ended - } - - // OPEN DETAIL - if detailView.isHidden && (currentLocation.y < -20) { - - self.openDetail() - gestureRecognizer.state = .ended - } - - default: - break - } - } -} - -extension NCViewerMedia { - @objc func openDetail(_ notification: NSNotification) { - if let userInfo = notification.userInfo as NSDictionary?, let ocId = userInfo["ocId"] as? String, ocId == metadata.ocId { - allowOpeningDetails = true - openDetail() - } - } - - func toggleDetail() { - detailView.isShown ? closeDetail() : openDetail() - } - - private func openDetail(animate: Bool = true) { - if !allowOpeningDetails { return } - - delegate?.didOpenDetail() - self.dismissTip() - - UIView.animate(withDuration: 0.3) { - self.scrollView.setZoomScale(1.0, animated: false) - - self.statusLabel.isHidden = true - self.statusViewImage.isHidden = true - } - - self.utility.getExif(metadata: self.metadata) { exif in - self.view.layoutIfNeeded() - - self.showDetailView(exif: exif) - - if let image = self.imageVideoContainer.image { - let ratioW = self.imageVideoContainer.frame.width / image.size.width - let ratioH = self.imageVideoContainer.frame.height / image.size.height - let ratio = min(ratioW, ratioH) - let imageHeight = image.size.height * ratio - var imageContainerHeight = self.imageVideoContainer.frame.height * ratio - let height = max(imageHeight, imageContainerHeight) - self.imageViewConstraint = self.detailView.frame.height - ((self.view.frame.height - height) / 2) + self.view.safeAreaInsets.bottom - - if self.imageViewConstraint < 0 { self.imageViewConstraint = 0 } - - self.imageViewConstraint = min(self.imageViewConstraint, self.detailView.frame.height + 30) - imageContainerHeight = self.imageViewConstraint.truncatingRemainder(dividingBy: 1000) - } - - UIView.animate(withDuration: animate ? 0.3 : 0) { - self.imageViewTopConstraint.constant = -self.imageViewConstraint - self.imageViewBottomConstraint.constant = self.imageViewConstraint - self.detailViewTopConstraint.constant = self.detailView.frame.height - self.view.layoutIfNeeded() - } - - self.scrollView.pinchGestureRecognizer?.isEnabled = false - } - } - - func closeDetail(animate: Bool = true) { - delegate?.didCloseDetail() - self.detailView.hide() - imageViewConstraint = 0 - - statusLabel.isHidden = false - statusViewImage.isHidden = false - - UIView.animate(withDuration: animate ? 0.3 : 0) { - self.imageViewTopConstraint.constant = 0 - self.imageViewBottomConstraint.constant = 0 - self.detailViewTopConstraint.constant = 0 - self.view.layoutIfNeeded() - } - - scrollView.pinchGestureRecognizer?.isEnabled = true - } - - private func showDetailView(exif: ExifData) { - self.detailView.show( - metadata: self.metadata, - image: self.image, - exif: exif, - ncplayer: self.ncplayer, - delegate: self) - } - - func reloadDetail() { - if self.detailView.isShown { - utility.getExif(metadata: metadata) { exif in - self.showDetailView(exif: exif) - } - } - } -} - -extension NCViewerMedia: UIScrollViewDelegate { - func viewForZooming(in scrollView: UIScrollView) -> UIView? { - return imageVideoContainer - } - - func scrollViewDidZoom(_ scrollView: UIScrollView) { - if scrollView.zoomScale > 1 { - if let image = imageVideoContainer.image { - let ratioW = imageVideoContainer.frame.width / image.size.width - let ratioH = imageVideoContainer.frame.height / image.size.height - let ratio = ratioW < ratioH ? ratioW : ratioH - let newWidth = image.size.width * ratio - let newHeight = image.size.height * ratio - let conditionLeft = newWidth * scrollView.zoomScale > imageVideoContainer.frame.width - let left = 0.5 * (conditionLeft ? newWidth - imageVideoContainer.frame.width : (scrollView.frame.width - scrollView.contentSize.width)) - let conditioTop = newHeight * scrollView.zoomScale > imageVideoContainer.frame.height - - let top = 0.5 * (conditioTop ? newHeight - imageVideoContainer.frame.height : (scrollView.frame.height - scrollView.contentSize.height)) - - scrollView.contentInset = UIEdgeInsets(top: top, left: left, bottom: top, right: left) - } - } else { - scrollView.contentInset = .zero - } - } -} - -extension NCViewerMedia: NCViewerMediaDetailViewDelegate { - func downloadFullResolution() { - Task { - await downloadImage(withSelector: global.selectorOpenDetail) - } - } -} - -extension NCViewerMedia: EasyTipViewDelegate { - func showTip() { - if !self.database.tipExists(global.tipMediaDetailView) { - var preferences = EasyTipView.Preferences() - preferences.drawing.foregroundColor = .white - preferences.drawing.backgroundColor = .lightGray - preferences.drawing.textAlignment = .left - preferences.drawing.arrowPosition = .bottom - preferences.drawing.cornerRadius = 10 - - preferences.animating.dismissTransform = CGAffineTransform(translationX: 0, y: -15) - preferences.animating.showInitialTransform = CGAffineTransform(translationX: 0, y: -15) - preferences.animating.showInitialAlpha = 0 - preferences.animating.showDuration = 0.5 - preferences.animating.dismissDuration = 0 - - if tipView == nil, let view = detailView { - tipView = EasyTipView(text: NSLocalizedString("_tip_open_mediadetail_", comment: ""), preferences: preferences, delegate: self) - tipView?.show(forView: view) - } - } - } - - func easyTipViewDidTap(_ tipView: EasyTipView) { - self.database.addTip(global.tipMediaDetailView) - } - - func easyTipViewDidDismiss(_ tipView: EasyTipView) { } - - func dismissTip() { - if !self.database.tipExists(global.tipMediaDetailView) { - self.database.addTip(global.tipMediaDetailView) - } - tipView?.dismiss() - tipView = nil - } -} - -extension NCViewerMedia: NCTransferDelegate { - func transferReloadData(serverUrl: String?) { } - - func transferReloadDataSource(serverUrl: String?, requestData: Bool, status: Int?) { } - - func transferProgressDidUpdate(progress: Float, totalBytes: Int64, totalBytesExpected: Int64, fileName: String, serverUrl: String) { } - - func transferChange(status: String, - account: String, - fileName: String, - serverUrl: String, - selector: String?, - ocId: String, - destination: String?, - error: NKError) { - if status == self.global.networkingStatusDownloaded { - DispatchQueue.main.async { - self.closeDetail() - } - } - } -} diff --git a/iOSClient/Viewer/NCViewerMedia/NCViewerMediaDetailView.swift b/iOSClient/Viewer/NCViewerMedia/NCViewerMediaDetailView.swift deleted file mode 100644 index 0cf1201651..0000000000 --- a/iOSClient/Viewer/NCViewerMedia/NCViewerMediaDetailView.swift +++ /dev/null @@ -1,233 +0,0 @@ -// SPDX-FileCopyrightText: Nextcloud GmbH -// SPDX-FileCopyrightText: 2020 Marino Faggiana -// SPDX-License-Identifier: GPL-3.0-or-later - -import UIKit -import MapKit -import NextcloudKit - -public protocol NCViewerMediaDetailViewDelegate: AnyObject { - func downloadFullResolution() -} - -class NCViewerMediaDetailView: UIView { - @IBOutlet weak var mapContainer: UIView! - @IBOutlet weak var outerMapContainer: UIView! - @IBOutlet weak var dayLabel: UILabel! - @IBOutlet weak var dateLabel: UILabel! - @IBOutlet weak var noDateLabel: UILabel! - @IBOutlet weak var timeLabel: UILabel! - @IBOutlet weak var nameLabel: UILabel! - @IBOutlet weak var modelLabel: UILabel! - @IBOutlet weak var deviceContainer: UIView! - @IBOutlet weak var outerContainer: UIView! - @IBOutlet weak var lensLabel: UILabel! - @IBOutlet weak var megaPixelLabel: UILabel! - @IBOutlet weak var megaPixelLabelDivider: UILabel! - @IBOutlet weak var resolutionLabel: UILabel! - @IBOutlet weak var resolutionLabelDivider: UILabel! - @IBOutlet weak var sizeLabel: UILabel! - @IBOutlet weak var extensionLabel: UILabel! - @IBOutlet weak var livePhotoImageView: UIImageView! - @IBOutlet weak var isoLabel: UILabel! - @IBOutlet weak var lensSizeLabel: UILabel! - @IBOutlet weak var exposureValueLabel: UILabel! - @IBOutlet weak var apertureLabel: UILabel! - @IBOutlet weak var shutterSpeedLabel: UILabel! - @IBOutlet weak var locationLabel: UILabel! - @IBOutlet weak var downloadImageButton: UIButton! - @IBOutlet weak var downloadImageLabel: UILabel! - @IBOutlet weak var downloadImageButtonContainer: UIStackView! - @IBOutlet weak var dateContainer: UIView! - @IBOutlet weak var lensInfoStackViewLeadingConstraint: NSLayoutConstraint! - @IBOutlet weak var lensInfoStackViewTrailingConstraint: NSLayoutConstraint! - @IBOutlet weak var lensInfoLeadingFakePadding: UILabel! - @IBOutlet weak var lensInfoTrailingFakePadding: UILabel! - - private var metadata: tableMetadata? - private var mapView: MKMapView? - private var ncplayer: NCPlayer? - weak var delegate: NCViewerMediaDetailViewDelegate? - let utilityFileSystem = NCUtilityFileSystem() - - private var exif: ExifData? - - var isShown: Bool { - return !self.isHidden - } - - deinit { - print("deinit NCViewerMediaDetailView") - - self.mapView?.removeFromSuperview() - self.mapView = nil - } - - func show(metadata: tableMetadata, - image: UIImage?, - exif: ExifData, - ncplayer: NCPlayer?, - delegate: NCViewerMediaDetailViewDelegate?) { - - self.metadata = metadata - self.exif = exif - self.ncplayer = ncplayer - self.delegate = delegate - - outerMapContainer.isHidden = true - downloadImageButtonContainer.isHidden = true - - if let latitude = exif.latitude, let longitude = exif.longitude, NCNetworking.shared.isOnline { - // We hide the map view on phones in landscape (aka compact height), since there is too little space to fit all of it. - mapContainer.isHidden = traitCollection.verticalSizeClass == .compact - - outerMapContainer.isHidden = false - let annotation = MKPointAnnotation() - annotation.coordinate = CLLocationCoordinate2D(latitude: latitude, longitude: longitude) - let region = MKCoordinateRegion(center: annotation.coordinate, latitudinalMeters: 500, longitudinalMeters: 500) - - if mapView == nil, mapView?.region.center.latitude != latitude, mapView?.region.center.longitude != longitude { - let mapView = MKMapView() - self.mapView = mapView - mapContainer.subviews.forEach { $0.removeFromSuperview() } - self.mapContainer.addSubview(mapView) - mapView.translatesAutoresizingMaskIntoConstraints = false - NSLayoutConstraint.activate([ - mapView.topAnchor.constraint(equalTo: self.mapContainer.topAnchor), - mapView.bottomAnchor.constraint(equalTo: self.mapContainer.bottomAnchor), - mapView.leadingAnchor.constraint(equalTo: self.mapContainer.leadingAnchor), - mapView.trailingAnchor.constraint(equalTo: self.mapContainer.trailingAnchor) - ]) - - mapView.isZoomEnabled = true - mapView.isScrollEnabled = false - mapView.isUserInteractionEnabled = false - mapView.addAnnotation(annotation) - - mapView.setRegion(region, animated: false) - } - } - - if let make = exif.make, let model = exif.model, let lensModel = exif.lensModel { - modelLabel.text = "\(make) \(model)" - lensLabel.text = lensModel - .replacingOccurrences(of: make, with: "") - .replacingOccurrences(of: model, with: "") - .replacingOccurrences(of: "f/", with: "ƒ").trimmingCharacters(in: .whitespacesAndNewlines).firstUppercased - } else { - modelLabel.text = NSLocalizedString("_no_camera_information_", comment: "") - lensLabel.text = NSLocalizedString("_no_lens_information_", comment: "") - } - - nameLabel.text = (metadata.fileNameView as NSString).deletingPathExtension - sizeLabel.text = utilityFileSystem.transformedSize(metadata.size) - - if let shutterSpeedApex = exif.shutterSpeedApex { - prepareLensInfoViewsForData() - shutterSpeedLabel.text = "1/\(Int(pow(2, shutterSpeedApex))) s" - } - - if let iso = exif.iso { - prepareLensInfoViewsForData() - isoLabel.text = "ISO \(iso)" - } - - if let apertureValue = exif.apertureValue { - apertureLabel.text = "ƒ\(apertureValue)" - } - - if let exposureValue = exif.exposureValue { - exposureValueLabel.text = "\(exposureValue) ev" - } - - if let lensLength = exif.lensLength { - lensSizeLabel.text = "\(lensLength) mm" - } - - if let date = exif.date { - dateContainer.isHidden = false - noDateLabel.isHidden = true - - let formatter = DateFormatter() - - formatter.dateFormat = "EEEE" - let dayString = formatter.string(from: date as Date) - dayLabel.text = dayString - - formatter.dateFormat = "d MMM yyyy" - let dateString = formatter.string(from: date as Date) - dateLabel.text = dateString - - formatter.dateFormat = "HH:mm" - let timeString = formatter.string(from: date as Date) - timeLabel.text = timeString - } else { - noDateLabel.text = NSLocalizedString("_no_date_information_", comment: "") - } - - if let height = exif.height, let width = exif.width { - megaPixelLabel.isHidden = false - megaPixelLabelDivider.isHidden = false - resolutionLabel.isHidden = false - resolutionLabelDivider.isHidden = false - - resolutionLabel.text = "\(width) x \(height)" - - let megaPixels: Double = Double(width * height) / 1000000 - megaPixelLabel.text = megaPixels < 1 ? String(format: "%.1f MP", megaPixels) : "\(Int(megaPixels)) MP" - } - - extensionLabel.text = metadata.fileExtension.uppercased() - - if exif.location?.isEmpty == false { - locationLabel.text = exif.location - } - - if metadata.isLivePhoto { - livePhotoImageView.isHidden = false - } - - if metadata.isImage && !utilityFileSystem.fileProviderStorageExists(metadata) && metadata.session.isEmpty { - downloadImageButton.setTitle(NSLocalizedString("_try_download_full_resolution_", comment: ""), for: .normal) - downloadImageLabel.text = NSLocalizedString("_full_resolution_image_info_", comment: "") - downloadImageButtonContainer.isHidden = false - } - - self.isHidden = false - layoutIfNeeded() - } - - func hide() { - self.isHidden = true - } - - private func prepareLensInfoViewsForData() { - lensInfoLeadingFakePadding.isHidden = true - lensInfoTrailingFakePadding.isHidden = true - lensInfoStackViewLeadingConstraint.constant = 5 - lensInfoStackViewTrailingConstraint.constant = 5 - } - - // MARK: - Action - - @IBAction func touchLocation(_ sender: Any) { - guard let latitude = exif?.latitude, let longitude = exif?.longitude else { return } - - let latitudeDeg: CLLocationDegrees = latitude - let longitudeDeg: CLLocationDegrees = longitude - - let coordinates = CLLocationCoordinate2DMake(latitudeDeg, longitudeDeg) - let placemark = MKPlacemark(coordinate: coordinates, addressDictionary: nil) - let mapItem = MKMapItem(placemark: placemark) - - if let location = exif?.location { - mapItem.name = location - } - - mapItem.openInMaps() - } - - @IBAction func touchDownload(_ sender: Any) { - delegate?.downloadFullResolution() - } -} diff --git a/iOSClient/Viewer/NCViewerMedia/NCViewerMediaPage.storyboard b/iOSClient/Viewer/NCViewerMedia/NCViewerMediaPage.storyboard deleted file mode 100644 index 0e982c9edd..0000000000 --- a/iOSClient/Viewer/NCViewerMedia/NCViewerMediaPage.storyboard +++ /dev/null @@ -1,599 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/iOSClient/Viewer/NCViewerMedia/NCViewerMediaPage.swift b/iOSClient/Viewer/NCViewerMedia/NCViewerMediaPage.swift deleted file mode 100644 index ada4b6cab2..0000000000 --- a/iOSClient/Viewer/NCViewerMedia/NCViewerMediaPage.swift +++ /dev/null @@ -1,658 +0,0 @@ -// SPDX-FileCopyrightText: Nextcloud GmbH -// SPDX-FileCopyrightText: 2020 Marino Faggiana -// SPDX-License-Identifier: GPL-3.0-or-later - -import UIKit -import NextcloudKit -import MediaPlayer - -enum ScreenMode { - case full, normal -} - -var viewerMediaScreenMode: ScreenMode = .normal - -class NCViewerMediaPage: UIViewController { - @IBOutlet weak var progressView: UIProgressView! - - // Parameters - var ocIds: [String] = [] - var currentIndex: Int = 0 - var delegateViewController: UIViewController? - - var modifiedOcId: [String] = [] - var nextIndex: Int? - var panGestureRecognizer: UIPanGestureRecognizer! - var singleTapGestureRecognizer: UITapGestureRecognizer! - var longtapGestureRecognizer: UILongPressGestureRecognizer! - var playCommand: Any? - var pauseCommand: Any? - var skipForwardCommand: Any? - var skipBackwardCommand: Any? - var nextTrackCommand: Any? - var previousTrackCommand: Any? - let utilityFileSystem = NCUtilityFileSystem() - let global = NCGlobal.shared - let database = NCManageDatabase.shared - - // This prevents the scroll views to scroll when you drag and drop files/images/subjects (from this or other apps) - // https://forums.developer.apple.com/forums/thread/89396 and https://forums.developer.apple.com/forums/thread/115736 - var preventScrollOnDragAndDrop = true - - var timerAutoHide: Timer? - private var timerAutoHideSeconds: Double = 4 - - private lazy var moreNavigationItem = UIBarButtonItem( - image: NCImageCache.shared.getImageButtonMore(), - primaryAction: nil, - menu: UIMenu(title: "", children: [ - UIDeferredMenuElement.uncached { [self] completion in - if let menu = NCContextMenuViewer(metadata: currentViewController.metadata, controller: self.tabBarController as? NCMainTabBarController, webView: false, sender: self).viewMenu() { - completion(menu.children) - } - } - ])) - - private lazy var imageDetailNavigationItem = UIBarButtonItem(image: NCUtility().loadImage(named: "info.circle", colors: [NCBrandColor.shared.iconImageColor]), style: .plain, target: self, action: #selector(toggleDetail(_:))) - - // swiftlint:disable force_cast - var pageViewController: UIPageViewController { - return self.children[0] as! UIPageViewController - } - - var currentViewController: NCViewerMedia { - return self.pageViewController.viewControllers![0] as! NCViewerMedia - } - // swiftlint:enable force_cast - - private var hideStatusBar: Bool = false { - didSet { - setNeedsStatusBarAppearanceUpdate() - } - } - - var sceneIdentifier: String { - (self.tabBarController as? NCMainTabBarController)?.sceneIdentifier ?? "" - } - - // MARK: - View Life Cycle - - override init(nibName nibNameOrNil: String?, bundle nibBundleOrNil: Bundle?) { - super.init(nibName: nibNameOrNil, bundle: nibBundleOrNil) - - viewerMediaScreenMode = .normal - } - - required init?(coder: NSCoder) { - super.init(coder: coder) - - viewerMediaScreenMode = .normal - } - - override func viewDidLoad() { - super.viewDidLoad() - - let metadata = database.getMetadataFromOcId(ocIds[currentIndex])! - var items: [UIBarButtonItem] = [] - - singleTapGestureRecognizer = UITapGestureRecognizer(target: self, action: #selector(didSingleTapWith(gestureRecognizer:))) - panGestureRecognizer = UIPanGestureRecognizer(target: self, action: #selector(didPanWith(gestureRecognizer:))) - longtapGestureRecognizer = UILongPressGestureRecognizer() - longtapGestureRecognizer.delaysTouchesBegan = true - longtapGestureRecognizer.minimumPressDuration = 0.3 - longtapGestureRecognizer.delegate = self - longtapGestureRecognizer.addTarget(self, action: #selector(didLongpressGestureEvent(gestureRecognizer:))) - - pageViewController.delegate = self - pageViewController.dataSource = self - pageViewController.view.addGestureRecognizer(panGestureRecognizer) - pageViewController.view.addGestureRecognizer(singleTapGestureRecognizer) - pageViewController.view.addGestureRecognizer(longtapGestureRecognizer) - - progressView.tintColor = NCBrandColor.shared.getElement(account: metadata.account) - progressView.trackTintColor = .clear - progressView.progress = 0 - - let viewerMedia = getViewerMedia(index: currentIndex, metadata: metadata) - pageViewController.setViewControllers([viewerMedia], direction: .forward, animated: true, completion: nil) - - NotificationCenter.default.addObserver(self, selector: #selector(pageViewController.enableSwipeGesture), name: NSNotification.Name(rawValue: NCGlobal.shared.notificationCenterEnableSwipeGesture), object: nil) - NotificationCenter.default.addObserver(self, selector: #selector(pageViewController.disableSwipeGesture), name: NSNotification.Name(rawValue: NCGlobal.shared.notificationCenterDisableSwipeGesture), object: nil) - NotificationCenter.default.addObserver(self, selector: #selector(applicationDidBecomeActive(_:)), name: UIApplication.didBecomeActiveNotification, object: nil) - - if currentViewController.metadata.isImage { - items.append(imageDetailNavigationItem) - } - items.append(moreNavigationItem) - - let group = UIBarButtonItemGroup( - barButtonItems: items, - representativeItem: nil - ) - navigationItem.trailingItemGroups = [group] - - for view in self.pageViewController.view.subviews { - if let scrollView = view as? UIScrollView { - scrollView.delegate = self - } - } - } - - deinit { - timerAutoHide?.invalidate() - timerAutoHide = nil - - NotificationCenter.default.removeObserver(self, name: NSNotification.Name(rawValue: NCGlobal.shared.notificationCenterEnableSwipeGesture), object: nil) - NotificationCenter.default.removeObserver(self, name: NSNotification.Name(rawValue: NCGlobal.shared.notificationCenterDisableSwipeGesture), object: nil) - - NotificationCenter.default.removeObserver(self, name: UIApplication.didBecomeActiveNotification, object: nil) - } - - override func viewWillAppear(_ animated: Bool) { - super.viewWillAppear(animated) - - changeScreenMode(mode: viewerMediaScreenMode) - - if #available(iOS 18.0, *) { - self.tabBarController?.setTabBarHidden(true, animated: true) - } else { - self.tabBarController?.tabBar.isHidden = true - } - } - - override func viewDidAppear(_ animated: Bool) { - super.viewDidAppear(animated) - - Task { - await NCNetworking.shared.transferDispatcher.addDelegate(self) - } - - startTimerAutoHide() - } - - override func viewWillDisappear(_ animated: Bool) { - super.viewWillDisappear(animated) - - changeScreenMode(mode: .normal) - - if #available(iOS 18.0, *) { - self.tabBarController?.setTabBarHidden(false, animated: true) - } else { - self.tabBarController?.tabBar.isHidden = false - } - } - - override func viewDidDisappear(_ animated: Bool) { - super.viewDidDisappear(animated) - - Task { - await NCNetworking.shared.transferDispatcher.removeDelegate(self) - } - - currentViewController.ncplayer?.playerStop() - timerAutoHide?.invalidate() - clearCommandCenter() - } - - override var preferredStatusBarStyle: UIStatusBarStyle { - if viewerMediaScreenMode == .normal { - return .default - } else { - return .lightContent - } - } - - override var prefersHomeIndicatorAutoHidden: Bool { - return viewerMediaScreenMode == .full - } - - override var prefersStatusBarHidden: Bool { - return hideStatusBar - } - - func getViewerMedia(index: Int, metadata: tableMetadata) -> NCViewerMedia { - // swiftlint:disable force_cast - let viewerMedia = UIStoryboard(name: "NCViewerMediaPage", bundle: nil).instantiateViewController(withIdentifier: "NCViewerMedia") as! NCViewerMedia - // swiftlint:enable force_cast - - viewerMedia.index = index - viewerMedia.metadata = metadata - viewerMedia.viewerMediaPage = self - viewerMedia.delegate = self - - singleTapGestureRecognizer.require(toFail: viewerMedia.doubleTapGestureRecognizer) - - return viewerMedia - } - - @objc private func toggleDetail(_ sender: Any?) { - currentViewController.toggleDetail() - } - - func changeScreenMode(mode: ScreenMode) { - let metadata = currentViewController.metadata - let fullscreen = currentViewController.playerToolBar?.isFullscreen ?? false - - if mode == .normal { - - if fullscreen { - navigationController?.setNavigationBarHidden(true, animated: true) - hideStatusBar = true - progressView.isHidden = true - } else { - navigationController?.setNavigationBarHidden(false, animated: true) - hideStatusBar = false - progressView.isHidden = false - } - - if metadata.isAudioOrVideo { - navigationController?.setNavigationBarAppearance(textColor: .white, backgroundColor: .black) - currentViewController.playerToolBar?.show() - view.backgroundColor = .black - moreNavigationItem.image = NCImageCache.shared.getImageButtonMore(colors: [.white]) - } else { - navigationController?.setNavigationBarAppearance() - view.backgroundColor = .systemBackground - moreNavigationItem.image = NCImageCache.shared.getImageButtonMore() - } - - } else if !currentViewController.detailView.isShown { - - navigationController?.setNavigationBarHidden(true, animated: true) - hideStatusBar = true - progressView.isHidden = true - - if metadata.isVideo { - currentViewController.playerToolBar?.hide() - } - - view.backgroundColor = .black - } - - if fullscreen { - pageViewController.disableSwipeGesture() - } else { - pageViewController.enableSwipeGesture() - } - - viewerMediaScreenMode = mode - print("Screen mode: \(viewerMediaScreenMode)") - - startTimerAutoHide() - setNeedsStatusBarAppearanceUpdate() - setNeedsUpdateOfHomeIndicatorAutoHidden() - currentViewController.reloadDetail() - } - - @objc func startTimerAutoHide() { - timerAutoHide?.invalidate() - timerAutoHide = Timer.scheduledTimer(timeInterval: timerAutoHideSeconds, target: self, selector: #selector(autoHide), userInfo: nil, repeats: true) - } - - @objc func autoHide() { - let metadata = currentViewController.metadata - if metadata.isVideo, viewerMediaScreenMode == .normal { - changeScreenMode(mode: .full) - } - } - - // MARK: - NotificationCenter - - @objc func applicationDidBecomeActive(_ notification: NSNotification) { - progressView.progress = 0 - changeScreenMode(mode: .normal) - } - - // MARK: - Command Center - - func updateCommandCenter(ncplayer: NCPlayer, title: String) { - var nowPlayingInfo = [String: Any]() - - UIApplication.shared.beginReceivingRemoteControlEvents() - - // Add handler for Play Command - MPRemoteCommandCenter.shared().playCommand.isEnabled = true - playCommand = MPRemoteCommandCenter.shared().playCommand.addTarget { _ in - - if !ncplayer.isPlaying() { - ncplayer.playerPlay() - return .success - } - return .commandFailed - } - - // Add handler for Pause Command - MPRemoteCommandCenter.shared().pauseCommand.isEnabled = true - pauseCommand = MPRemoteCommandCenter.shared().pauseCommand.addTarget { _ in - - if ncplayer.isPlaying() { - ncplayer.playerPause() - return .success - } - return .commandFailed - } - - // >> - MPRemoteCommandCenter.shared().skipForwardCommand.isEnabled = true - skipForwardCommand = MPRemoteCommandCenter.shared().skipForwardCommand.addTarget { event in - - let seconds = Int32((event as? MPSkipIntervalCommandEvent)?.interval ?? 0) - ncplayer.player.jumpForward(seconds) - return.success - } - - // << - MPRemoteCommandCenter.shared().skipBackwardCommand.isEnabled = true - skipBackwardCommand = MPRemoteCommandCenter.shared().skipBackwardCommand.addTarget { event in - - let seconds = Int32((event as? MPSkipIntervalCommandEvent)?.interval ?? 0) - ncplayer.player.jumpBackward(seconds) - return.success - } - - nowPlayingInfo[MPMediaItemPropertyTitle] = title - if let image = currentViewController.image { - nowPlayingInfo[MPMediaItemPropertyArtwork] = MPMediaItemArtwork(boundsSize: image.size) { _ in - return image - } - } - MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo - } - - func clearCommandCenter() { - - UIApplication.shared.endReceivingRemoteControlEvents() - MPNowPlayingInfoCenter.default().nowPlayingInfo = nil - - MPRemoteCommandCenter.shared().playCommand.isEnabled = false - MPRemoteCommandCenter.shared().pauseCommand.isEnabled = false - MPRemoteCommandCenter.shared().skipForwardCommand.isEnabled = false - MPRemoteCommandCenter.shared().skipBackwardCommand.isEnabled = false - MPRemoteCommandCenter.shared().nextTrackCommand.isEnabled = false - MPRemoteCommandCenter.shared().previousTrackCommand.isEnabled = false - - if let playCommand = playCommand { - MPRemoteCommandCenter.shared().playCommand.removeTarget(playCommand) - self.playCommand = nil - } - if let pauseCommand = pauseCommand { - MPRemoteCommandCenter.shared().pauseCommand.removeTarget(pauseCommand) - self.pauseCommand = nil - } - if let skipForwardCommand = skipForwardCommand { - MPRemoteCommandCenter.shared().skipForwardCommand.removeTarget(skipForwardCommand) - self.skipForwardCommand = nil - } - if let skipBackwardCommand = skipBackwardCommand { - MPRemoteCommandCenter.shared().skipBackwardCommand.removeTarget(skipBackwardCommand) - self.skipBackwardCommand = nil - } - if let nextTrackCommand = nextTrackCommand { - MPRemoteCommandCenter.shared().nextTrackCommand.removeTarget(nextTrackCommand) - self.nextTrackCommand = nil - } - if let previousTrackCommand = previousTrackCommand { - MPRemoteCommandCenter.shared().previousTrackCommand.removeTarget(previousTrackCommand) - self.previousTrackCommand = nil - } - } -} - -// MARK: - UIPageViewController Delegate Datasource - -extension NCViewerMediaPage: UIPageViewControllerDelegate, UIPageViewControllerDataSource { - - func pageViewController(_ pageViewController: UIPageViewController, viewControllerBefore viewController: UIViewController) -> UIViewController? { - guard currentIndex > 0, - let metadata = database.getMetadataFromOcId(ocIds[currentIndex - 1]) else { return nil } - - let viewerMedia = getViewerMedia(index: currentIndex - 1, metadata: metadata) - return viewerMedia - } - - func pageViewController(_ pageViewController: UIPageViewController, viewControllerAfter viewController: UIViewController) -> UIViewController? { - guard currentIndex < ocIds.count - 1, - let metadata = database.getMetadataFromOcId(ocIds[currentIndex + 1]) else { return nil } - - let viewerMedia = getViewerMedia(index: currentIndex + 1, metadata: metadata) - return viewerMedia - } - - // START TRANSITION - func pageViewController(_ pageViewController: UIPageViewController, willTransitionTo pendingViewControllers: [UIViewController]) { - - guard let nextViewController = pendingViewControllers.first as? NCViewerMedia else { - return - } - var items: [UIBarButtonItem] = [] - - nextIndex = nextViewController.index - - if nextViewController.metadata.isImage { - items.append(imageDetailNavigationItem) - } - items.append(moreNavigationItem) - - let group = UIBarButtonItemGroup( - barButtonItems: items, - representativeItem: nil - ) - navigationItem.trailingItemGroups = [group] - - if nextViewController.detailView.isShown { - changeScreenMode(mode: .normal) - } - } - - // END TRANSITION - func pageViewController(_ pageViewController: UIPageViewController, didFinishAnimating finished: Bool, previousViewControllers: [UIViewController], transitionCompleted completed: Bool) { - - if completed && nextIndex != nil { - previousViewControllers.forEach { viewController in - let viewerMedia = viewController as? NCViewerMedia - viewerMedia?.ncplayer?.playerStop() - viewerMedia?.closeDetail() - } - currentIndex = nextIndex! - } - - changeScreenMode(mode: viewerMediaScreenMode) - startTimerAutoHide() - - self.nextIndex = nil - } -} - -// MARK: - UIGestureRecognizerDelegate - -extension NCViewerMediaPage: UIGestureRecognizerDelegate { - - func gestureRecognizerShouldBegin(_ gestureRecognizer: UIGestureRecognizer) -> Bool { - if let gestureRecognizer = gestureRecognizer as? UIPanGestureRecognizer { - let velocity = gestureRecognizer.velocity(in: self.view) - - var velocityCheck: Bool = false - - if UIDevice.current.orientation.isLandscape { - velocityCheck = velocity.x < 0 - } else { - velocityCheck = velocity.y < 0 - } - if velocityCheck { - return false - } - } - - return true - } - - func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool { - - if otherGestureRecognizer == currentViewController.scrollView.panGestureRecognizer { - if self.currentViewController.scrollView.contentOffset.y == 0 { - return true - } - } - - return false - } - - @objc func didPanWith(gestureRecognizer: UIPanGestureRecognizer) { - currentViewController.didPanWith(gestureRecognizer: gestureRecognizer) - } - - @objc func didSingleTapWith(gestureRecognizer: UITapGestureRecognizer) { - if currentViewController.detailView.isShown { return } - - if viewerMediaScreenMode == .full { - changeScreenMode(mode: .normal) - } else { - changeScreenMode(mode: .full) - } - } - - // MARK: - Live Photo - @objc func didLongpressGestureEvent(gestureRecognizer: UITapGestureRecognizer) { - if !currentViewController.metadata.isLivePhoto || currentViewController.detailView.isShown { return } - - if gestureRecognizer.state == .began { - if let metadataLive = NCManageDatabase.shared.getMetadataLivePhoto(metadata: currentViewController.metadata), - utilityFileSystem.fileProviderStorageExists(metadataLive) { - AudioServicesPlaySystemSound(1519) // peek feedback - currentViewController.playLivePhoto(filePath: utilityFileSystem.getDirectoryProviderStorageOcId( - metadataLive.ocId, - fileName: metadataLive.fileName, - userId: metadataLive.userId, - urlBase: metadataLive.urlBase)) - } - } else if gestureRecognizer.state == .ended { - currentViewController.stopLivePhoto() - } - } -} - -extension UIPageViewController { - @objc func enableSwipeGesture() { - for view in self.view.subviews { - if let subView = view as? UIScrollView { - subView.isScrollEnabled = true - } - } - } - - @objc func disableSwipeGesture() { - for view in self.view.subviews { - if let subView = view as? UIScrollView { - subView.isScrollEnabled = false - } - } - } -} - -extension NCViewerMediaPage: NCViewerMediaViewDelegate { - func didOpenDetail() { - changeScreenMode(mode: .normal) - imageDetailNavigationItem.image = NCUtility().loadImage(named: "info.circle.fill") - } - - func didCloseDetail() { - imageDetailNavigationItem.image = NCUtility().loadImage(named: "info.circle") - } -} - -extension NCViewerMediaPage: UIScrollViewDelegate { - - func scrollViewWillBeginDragging(_ scrollView: UIScrollView) { - preventScrollOnDragAndDrop = false - } - - func scrollViewDidScroll(_ scrollView: UIScrollView) { - if preventScrollOnDragAndDrop { - scrollView.setContentOffset(CGPoint(x: view.frame.width + 10, y: 0), animated: false) - } - } - - func scrollViewDidEndDragging(_ scrollView: UIScrollView, willDecelerate decelerate: Bool) { - if !decelerate { - preventScrollOnDragAndDrop = true - } - } - - func scrollViewDidEndDecelerating(_ scrollView: UIScrollView) { - preventScrollOnDragAndDrop = true - } -} - -extension NCViewerMediaPage: NCTransferDelegate { - func transferReloadData(serverUrl: String?) { } - - func transferReloadDataSource(serverUrl: String?, requestData: Bool, status: Int?) { } - - func transferChange(status: String, - account: String, - fileName: String, - serverUrl: String, - selector: String?, - ocId: String, - destination: String?, - error: NKError) { - Task {@MainActor in - switch status { - // DELETE - case NCGlobal.shared.networkingStatusDelete: - if error == .success, - ocId == self.currentViewController.metadata.ocId { - if let ncplayer = self.currentViewController.ncplayer, ncplayer.isPlaying() { - ncplayer.playerPause() - } - self.navigationController?.popViewController(animated: true) - } - // DOWNLOAD - case self.global.networkingStatusDownloaded: - guard ocId == self.currentViewController.metadata.ocId, - let metadata = await NCManageDatabase.shared.getMetadataFromOcIdAsync(ocId) else { - return - } - self.progressView.progress = 0 - - if metadata.isAudioOrVideo, let ncplayer = self.currentViewController.ncplayer { - let url = URL(fileURLWithPath: self.utilityFileSystem.getDirectoryProviderStorageOcId(metadata.ocId, - fileName: metadata.fileNameView, - userId: metadata.userId, - urlBase: metadata.urlBase)) - if ncplayer.isPlaying() { - ncplayer.playerPause() - DispatchQueue.main.asyncAfter(deadline: .now() + 0.3) { - ncplayer.openAVPlayer(url: url) - ncplayer.playerPlay() - } - } else { - ncplayer.openAVPlayer(url: url) - } - } else if metadata.isImage { - await self.currentViewController.loadImage() - } - // UPLOAD - case self.global.networkingStatusUploaded: - guard error == .success else { return } - if self.currentViewController.metadata.ocId == ocId { - await self.currentViewController.loadImage() - } else { - self.modifiedOcId.append(ocId) - } - default: - break - } - } - } - - func transferProgressDidUpdate(progress: Float, totalBytes: Int64, totalBytesExpected: Int64, fileName: String, serverUrl: String) { - DispatchQueue.main.async { - if progress == 1 { - self.progressView.progress = 0 - } else { - self.progressView.progress = progress - } - } - } -} diff --git a/iOSClient/Viewer/NCViewerMedia/Views/NCMediaViewerDetailView.swift b/iOSClient/Viewer/NCViewerMedia/Views/NCMediaViewerDetailView.swift new file mode 100644 index 0000000000..ed4c2b2359 --- /dev/null +++ b/iOSClient/Viewer/NCViewerMedia/Views/NCMediaViewerDetailView.swift @@ -0,0 +1,475 @@ +// 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(.headline) + .lineLimit(1) + + Spacer(minLength: 8) + + if !metadata.fileExtension.isEmpty { + detailBadge(metadata.fileExtension.uppercased()) + } + } + .padding(.horizontal, 16) + .padding(.top, 14) + .padding(.bottom, 8) + .background(.secondary.opacity(0.10)) + + VStack(alignment: .leading, spacing: 10) { + Text(lensText) + .font(.subheadline) + .foregroundStyle(.secondary) + .lineLimit(2) + + FlowingDetailValues(values: primaryMediaValues) + } + .padding(.horizontal, 16) + .padding(.vertical, 14) + + if !exifStripValues.isEmpty { + Divider() + + HStack(spacing: 0) { + ForEach(Array(exifStripValues.enumerated()), id: \.offset) { index, value in + Text(value) + .font(.subheadline) + .foregroundStyle(.secondary) + .lineLimit(1) + .frame(maxWidth: .infinity) + + if index < exifStripValues.count - 1 { + Divider() + .frame(height: 22) + } + } + } + .padding(.horizontal, 8) + .padding(.vertical, 12) + } + } + .background(.secondary.opacity(0.08)) + .clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous)) + } + + // MARK: - Sections + + @ViewBuilder + private var dateSection: some View { + if let date = exif.date as Date? { + VStack(alignment: .leading, spacing: 4) { + Text(dayString(from: date)) + .font(.headline) + + HStack(spacing: 8) { + Text(dateString(from: date)) + Text(timeString(from: date)) + } + .font(.subheadline) + .foregroundStyle(.secondary) + } + } else { + Text(NSLocalizedString("_no_date_information_", comment: "")) + .font(.headline) + .foregroundStyle(.secondary) + } + } + + private var fileSection: some View { + VStack(alignment: .leading, spacing: 8) { + Text(fileNameWithoutExtension) + .font(.title3.weight(.semibold)) + .lineLimit(2) + + HStack(spacing: 8) { + if let megapixelsText { + detailBadge(megapixelsText) + } + + if let resolutionText { + detailBadge(resolutionText) + } + + detailBadge(utilityFileSystem.transformedSize(metadata.size)) + + if !metadata.fileExtension.isEmpty { + detailBadge(metadata.fileExtension.uppercased()) + } + + if metadata.isLivePhoto { + Image(systemName: "livephoto") + .font(.subheadline) + .foregroundStyle(.secondary) + } + } + } + } + + private var cameraSection: some View { + VStack(alignment: .leading, spacing: 4) { + Text(cameraText) + .font(.headline) + + Text(lensText) + .font(.subheadline) + .foregroundStyle(.secondary) + } + } + + @ViewBuilder + private var lensSection: some View { + let values = lensValues + + if !values.isEmpty { + LazyVGrid( + columns: [ + GridItem(.adaptive(minimum: 90), spacing: 8) + ], + alignment: .leading, + spacing: 8 + ) { + ForEach(values, id: \.self) { value in + detailBadge(value) + } + } + } + } + + @ViewBuilder + private var exposureSection: some View { + let values = exposureValues + + if !values.isEmpty { + VStack(alignment: .leading, spacing: 8) { + Text("EXIF") + .font(.headline) + + LazyVGrid( + columns: [ + GridItem(.adaptive(minimum: 90), spacing: 8) + ], + alignment: .leading, + spacing: 8 + ) { + ForEach(values, id: \.self) { value in + detailBadge(value) + } + } + } + } + } + + @ViewBuilder + private var locationSection: some View { + if let latitude = exif.latitude, + let longitude = exif.longitude, + NCNetworking.shared.isOnline { + let coordinate = CLLocationCoordinate2D( + latitude: latitude, + longitude: longitude + ) + + VStack(alignment: .leading, spacing: 10) { + if let location = exif.location, !location.isEmpty { + Button { + openMaps( + coordinate: coordinate, + name: location + ) + } label: { + HStack(spacing: 8) { + Image(systemName: "mappin.and.ellipse") + Text(location) + .lineLimit(2) + } + } + .buttonStyle(.plain) + .foregroundStyle(.primary) + } + + 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) + .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(.vertical, 2) + } + + // MARK: - Computed Values + + private var primaryMediaValues: [String] { + var values: [String] = [] + + if let megapixelsText { + values.append(megapixelsText) + } + + if let resolutionText { + values.append(resolutionText) + } + + values.append(utilityFileSystem.transformedSize(metadata.size)) + + if metadata.isLivePhoto { + values.append("LIVE") + } + + return values + } + + private var exifStripValues: [String] { + var values: [String] = [] + + if let iso = exif.iso { + values.append("ISO \(iso)") + } + + if let lensLength = exif.lensLength { + values.append("\(lensLength) mm") + } + + if let exposureValue = exif.exposureValue { + values.append("\(exposureValue) ev") + } + + if let apertureValue = exif.apertureValue { + values.append("ƒ\(apertureValue)") + } + + if let shutterSpeedApex = exif.shutterSpeedApex { + values.append("1/\(Int(pow(2, shutterSpeedApex))) s") + } + + return values + } + + private var fileNameWithoutExtension: String { + (metadata.fileNameView as NSString).deletingPathExtension + } + + 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) + } + } + } + } +} 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..254b254a49 --- /dev/null +++ b/iOSClient/Viewer/NCViewerMedia/Views/NCMediaViewerPageView.swift @@ -0,0 +1,420 @@ +// 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, + canGoPrevious: canGoPrevious, + canGoNext: canGoNext, + shouldAutoPlay: effectiveShouldAutoPlay, + onPrevious: goToPreviousPage, + onNext: goToNextPage, + onAutoPlayConsumed: consumeAutoPlayIfNeeded + ) + .background(Color.black) + } else { + metadataMissingView + } + } + + @ViewBuilder + private func downloadingStateView( + previewURL: URL?, + _ progress: Double? + ) -> 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..ba4a3729ec --- /dev/null +++ b/iOSClient/Viewer/NCViewerMedia/Views/NCMediaViewerPagingView.swift @@ -0,0 +1,760 @@ +// 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.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.initialSelectedIndex + + guard index >= 0, + index < model.numberOfPages else { + return + } + + collectionView.scrollToItem( + at: IndexPath(item: index, section: 0), + at: .centeredHorizontally, + animated: animated + ) + + didScrollToInitialIndex = true + lastVisibleIndex = index + updateCollectionBackground(for: index) + updateVisibleMetadataTitle(for: index) + refreshVisibleCells() + } + + func scrollToCurrentIndex(animated: Bool) { + scrollToIndex( + model.selectedIndex, + animated: animated + ) + } + + private func scrollToIndex( + _ index: Int, + animated: Bool + ) { + guard model.numberOfPages > 0 else { + return + } + + guard let collectionView else { + return + } + + collectionView.layoutIfNeeded() + + guard index >= 0, + index < model.numberOfPages else { + return + } + + collectionView.scrollToItem( + at: IndexPath(item: index, section: 0), + at: .centeredHorizontally, + animated: animated + ) + + lastVisibleIndex = index + updateCollectionBackground(for: index) + updateVisibleMetadataTitle(for: index) + refreshVisibleCells() + } + + // MARK: - Visible Cell Refresh + + 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 + } + + guard let collectionView 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() + + collectionView.scrollToItem( + at: IndexPath(item: targetIndex, section: 0), + at: .centeredHorizontally, + 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/NCViewerPDF/NCViewerPDF.swift b/iOSClient/Viewer/NCViewerPDF/NCViewerPDF.swift index 89e961ce8f..4c087cb51f 100644 --- a/iOSClient/Viewer/NCViewerPDF/NCViewerPDF.swift +++ b/iOSClient/Viewer/NCViewerPDF/NCViewerPDF.swift @@ -68,7 +68,11 @@ class NCViewerPDF: UIViewController, NCViewerPDFSearchDelegate { UIDeferredMenuElement.uncached { [self] completion in guard let metadata = self.metadata else { return } - if let menu = NCContextMenuViewer(metadata: metadata, controller: self.tabBarController as? NCMainTabBarController, webView: false, sender: self).viewMenu() { + if let menu = NCContextMenuViewer(metadata: metadata, + controller: self.tabBarController as? NCMainTabBarController, + viewController: self.tabBarController, + webView: false, + sender: self).viewMenu() { completion(menu.children) } } diff --git a/iOSClient/Viewer/NCViewerRichdocument/NCViewerRichDocument.swift b/iOSClient/Viewer/NCViewerRichdocument/NCViewerRichDocument.swift index d26410c757..0f7e6590c2 100644 --- a/iOSClient/Viewer/NCViewerRichdocument/NCViewerRichDocument.swift +++ b/iOSClient/Viewer/NCViewerRichdocument/NCViewerRichDocument.swift @@ -46,7 +46,11 @@ class NCViewerRichDocument: UIViewController, WKNavigationDelegate, WKScriptMess primaryAction: nil, menu: UIMenu(title: "", children: [ UIDeferredMenuElement.uncached { [self] completion in - if let menu = NCContextMenuViewer(metadata: self.metadata, controller: self.tabBarController as? NCMainTabBarController, webView: true, sender: self).viewMenu() { + if let menu = NCContextMenuViewer(metadata: self.metadata, + controller: self.tabBarController as? NCMainTabBarController, + viewController: self.tabBarController, + webView: true, + sender: self).viewMenu() { completion(menu.children) } } @@ -182,6 +186,7 @@ class NCViewerRichDocument: UIViewController, WKNavigationDelegate, WKScriptMess if message.body as? String == "share" { NCCreate().createShare(controller: self.controller, + viewController: self.controller, metadata: metadata, page: .sharing) }