VLCDragAndDropManager.swift 23 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511
  1. /*****************************************************************************
  2. * VLCDragAndDropManager.swift
  3. * VLC for iOS
  4. *****************************************************************************
  5. * Copyright (c) 2017 VideoLAN. All rights reserved.
  6. * $Id$
  7. *
  8. * Authors: Carola Nitz <caro # videolan.org>
  9. *
  10. * Refer to the COPYING file of the official project for license.
  11. *****************************************************************************/
  12. import MobileCoreServices
  13. import UIKit
  14. @available(iOS 11.0, *)
  15. struct DropError: Error {
  16. enum ErrorKind {
  17. case moveFileToDocuments
  18. case loadFileRepresentationFailed
  19. }
  20. let kind: ErrorKind
  21. }
  22. @available(iOS 11.0, *)
  23. protocol VLCDragAndDropManagerDelegate: NSObjectProtocol {
  24. func dragAndDropManagerRequestsFile(manager: NSObject, atIndexPath indexPath: IndexPath) -> Any?
  25. func dragAndDropManagerInsertItem(manager: NSObject, item: NSManagedObject, atIndexPath indexPath: IndexPath)
  26. func dragAndDropManagerDeleteItem(manager: NSObject, atIndexPath indexPath: IndexPath)
  27. func dragAndDropManagerRemoveFileFromFolder(manager: NSObject, file: NSManagedObject)
  28. func dragAndDropManagerCurrentSelection(manager: NSObject) -> AnyObject?
  29. }
  30. @available(iOS 11.0, *)
  31. class VLCDragAndDropManager<T>: NSObject, UICollectionViewDragDelegate, UITableViewDragDelegate, UICollectionViewDropDelegate, UITableViewDropDelegate, UIDropInteractionDelegate {
  32. let utiTypeIdentifiers: [String] = VLCDragAndDropManager.supportedTypeIdentifiers()
  33. var subcategory: VLCMediaSubcategoryModel<T>
  34. /// Returns the supported type identifiers that VLC can process.
  35. /// It fetches the identifiers in LSItemContentTypes from all the CFBundleDocumentTypes in the info.plist.
  36. /// Video, Audio and Subtitle formats
  37. ///
  38. /// - Returns: Array of UTITypeIdentifiers
  39. private class func supportedTypeIdentifiers() -> [String] {
  40. var typeIdentifiers: [String] = []
  41. if let documents = Bundle.main.infoDictionary?["CFBundleDocumentTypes"] as? [[String: Any]] {
  42. for item in documents {
  43. if let value = item["LSItemContentTypes"] as? [String] {
  44. typeIdentifiers.append(contentsOf: value)
  45. }
  46. }
  47. }
  48. return typeIdentifiers
  49. }
  50. @available(*, unavailable, message: "use init(category:)")
  51. override init() {
  52. fatalError()
  53. }
  54. init(subcategory: VLCMediaSubcategoryModel<T>) {
  55. self.subcategory = subcategory
  56. super.init()
  57. }
  58. // MARK: - TableView
  59. func tableView(_ tableView: UITableView, canHandle session: UIDropSession) -> Bool {
  60. return canHandleDropSession(session: session)
  61. }
  62. func tableView(_ tableView: UITableView, itemsForAddingTo session: UIDragSession, at indexPath: IndexPath, point: CGPoint) -> [UIDragItem] {
  63. return dragItems(forIndexPath: indexPath)
  64. }
  65. func tableView(_ tableView: UITableView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath) -> [UIDragItem] {
  66. return dragItems(forIndexPath: indexPath)
  67. }
  68. func tableView(_ tableView: UITableView, dropSessionDidUpdate session: UIDropSession, withDestinationIndexPath destinationIndexPath: IndexPath?) -> UITableViewDropProposal {
  69. let operation = dropOperation(hasActiveDrag: tableView.hasActiveDrag, firstSessionItem: session.items.first, withDestinationIndexPath: destinationIndexPath)
  70. return UITableViewDropProposal(operation: operation, intent: .insertIntoDestinationIndexPath)
  71. }
  72. func tableView(_ tableView: UITableView, performDropWith coordinator: UITableViewDropCoordinator) {
  73. let section = tableView.numberOfSections - 1
  74. let row = tableView.numberOfRows(inSection: section)
  75. let destinationPath = coordinator.destinationIndexPath ?? IndexPath(row: row, section: section)
  76. for item in coordinator.items {
  77. let itemProvider = item.dragItem.itemProvider
  78. // we're not gonna handle moving of folders
  79. if let sourceItem = item.dragItem.localObject, fileIsCollection(file: sourceItem as AnyObject) {
  80. continue
  81. }
  82. if fileIsFolder(atIndexPath: destinationPath) { // handle dropping onto a folder
  83. addDragItem(tableView: tableView, dragItem: item, toFolderAt: destinationPath)
  84. continue
  85. }
  86. if item.sourceIndexPath != nil { // element within VLC
  87. moveItem(tableView: tableView, item: item, toIndexPath: destinationPath)
  88. continue
  89. }
  90. // Element dragging from another App
  91. let placeholder = UITableViewDropPlaceholder(insertionIndexPath: destinationPath, reuseIdentifier: VLCPlaylistTableViewCell.cellIdentifier(), rowHeight: VLCPlaylistTableViewCell.heightOfCell())
  92. let placeholderContext = coordinator.drop(item.dragItem, to: placeholder)
  93. createFileWith(itemProvider: itemProvider) {
  94. [weak self] file, error in
  95. guard let strongSelf = self else { return }
  96. if let file = file {
  97. placeholderContext.commitInsertion() {
  98. insertionIndexPath in
  99. strongSelf.subcategory.dragAndDropManagerInsertItem(manager: strongSelf, item: file, atIndexPath: insertionIndexPath)
  100. }
  101. }
  102. if let error = error as? DropError {
  103. strongSelf.handleError(error: error, itemProvider: item.dragItem.itemProvider)
  104. placeholderContext.deletePlaceholder()
  105. }
  106. }
  107. }
  108. }
  109. private func inFolder() -> Bool {
  110. return subcategory.dragAndDropManagerCurrentSelection(manager: self) as? MLLabel != nil
  111. }
  112. private func moveItem(tableView: UITableView, item: UITableViewDropItem, toIndexPath destinationPath: IndexPath) {
  113. if let mlFile = item.dragItem.localObject as? MLFile, !mlFile.labels.isEmpty && !inFolder() {
  114. tableView.performBatchUpdates({
  115. tableView.insertRows(at: [destinationPath], with: .automatic)
  116. subcategory.dragAndDropManagerInsertItem(manager: self, item: mlFile, atIndexPath: destinationPath)
  117. subcategory.dragAndDropManagerRemoveFileFromFolder(manager: self, file: mlFile)
  118. }, completion: nil)
  119. }
  120. }
  121. private func addDragItem(tableView: UITableView, dragItem item: UITableViewDropItem, toFolderAt index: IndexPath) {
  122. if let sourcepath = item.sourceIndexPath { // local file that just needs to be moved
  123. tableView.performBatchUpdates({
  124. if let file = subcategory.dragAndDropManagerRequestsFile(manager: self, atIndexPath: sourcepath) as? MLFile {
  125. tableView.deleteRows(at: [sourcepath], with: .automatic)
  126. addFile(file: file, toFolderAt: index)
  127. subcategory.dragAndDropManagerDeleteItem(manager: self, atIndexPath: sourcepath)
  128. }
  129. }, completion: nil)
  130. return
  131. }
  132. // file from other app
  133. createFileWith(itemProvider: item.dragItem.itemProvider) {
  134. [weak self] file, error in
  135. if let strongSelf = self, let file = file {
  136. strongSelf.addFile(file: file, toFolderAt: index)
  137. }
  138. }
  139. }
  140. // MARK: - Collectionview
  141. func collectionView(_ collectionView: UICollectionView, canHandle session: UIDropSession) -> Bool {
  142. return canHandleDropSession(session: session)
  143. }
  144. func collectionView(_ collectionView: UICollectionView, itemsForBeginning session: UIDragSession, at indexPath: IndexPath) -> [UIDragItem] {
  145. return dragItems(forIndexPath: indexPath)
  146. }
  147. func collectionView(_ collectionView: UICollectionView, itemsForAddingTo session: UIDragSession, at indexPath: IndexPath, point: CGPoint) -> [UIDragItem] {
  148. return dragItems(forIndexPath: indexPath)
  149. }
  150. func collectionView(_ collectionView: UICollectionView, dropSessionDidUpdate session: UIDropSession, withDestinationIndexPath destinationIndexPath: IndexPath?) -> UICollectionViewDropProposal {
  151. let operation = dropOperation(hasActiveDrag: collectionView.hasActiveDrag, firstSessionItem: session.items.first, withDestinationIndexPath: destinationIndexPath)
  152. return UICollectionViewDropProposal(operation: operation, intent: .insertIntoDestinationIndexPath)
  153. }
  154. func collectionView(_ collectionView: UICollectionView, performDropWith coordinator: UICollectionViewDropCoordinator) {
  155. let section = collectionView.numberOfSections - 1
  156. let row = collectionView.numberOfItems(inSection: section)
  157. let destinationPath = coordinator.destinationIndexPath ?? IndexPath(row: row, section: section)
  158. for item in coordinator.items {
  159. if let sourceItem = item.dragItem.localObject, fileIsCollection(file: sourceItem as AnyObject) { // We're not handling moving of Collection
  160. continue
  161. }
  162. if fileIsFolder(atIndexPath: destinationPath) { // handle dropping onto a folder
  163. addDragItem(collectionView: collectionView, dragItem: item, toFolderAt: destinationPath)
  164. continue
  165. }
  166. if item.sourceIndexPath != nil { // element within VLC
  167. moveItem(collectionView: collectionView, item: item, toIndexPath: destinationPath)
  168. continue
  169. }
  170. // Element from another App
  171. let placeholder = UICollectionViewDropPlaceholder(insertionIndexPath: destinationPath, reuseIdentifier: VLCPlaylistCollectionViewCell.cellIdentifier())
  172. let placeholderContext = coordinator.drop(item.dragItem, to: placeholder)
  173. createFileWith(itemProvider: item.dragItem.itemProvider) {
  174. [weak self] file, error in
  175. guard let strongSelf = self else { return }
  176. if let file = file {
  177. placeholderContext.commitInsertion() {
  178. insertionIndexPath in
  179. strongSelf.subcategory.dragAndDropManagerInsertItem(manager: strongSelf, item: file, atIndexPath: insertionIndexPath)
  180. }
  181. }
  182. if let error = error as? DropError {
  183. strongSelf.handleError(error: error, itemProvider: item.dragItem.itemProvider)
  184. placeholderContext.deletePlaceholder()
  185. }
  186. }
  187. }
  188. }
  189. private func moveItem(collectionView: UICollectionView, item: UICollectionViewDropItem, toIndexPath destinationPath: IndexPath) {
  190. if let mlFile = item.dragItem.localObject as? MLFile, !mlFile.labels.isEmpty && !inFolder() {
  191. collectionView.performBatchUpdates({
  192. collectionView.insertItems(at: [destinationPath])
  193. subcategory.dragAndDropManagerInsertItem(manager: self, item: mlFile, atIndexPath: destinationPath)
  194. subcategory.dragAndDropManagerRemoveFileFromFolder(manager: self, file: mlFile)
  195. }, completion: nil)
  196. }
  197. }
  198. private func addDragItem(collectionView: UICollectionView, dragItem item: UICollectionViewDropItem, toFolderAt index: IndexPath) {
  199. if let sourcepath = item.sourceIndexPath {
  200. // local file that just needs to be moved
  201. collectionView.performBatchUpdates({
  202. if let file = subcategory.dragAndDropManagerRequestsFile(manager: self, atIndexPath: sourcepath) as? MLFile {
  203. collectionView.deleteItems(at: [sourcepath])
  204. addFile(file: file, toFolderAt: index)
  205. subcategory.dragAndDropManagerDeleteItem(manager: self, atIndexPath: sourcepath)
  206. }
  207. }, completion: nil)
  208. } else {
  209. // file from other app
  210. createFileWith(itemProvider: item.dragItem.itemProvider) {
  211. [weak self] file, error in
  212. if let strongSelf = self, let file = file {
  213. strongSelf.addFile(file: file, toFolderAt: index)
  214. }
  215. }
  216. }
  217. }
  218. // MARK: - DropInteractionDelegate for EmptyView
  219. func dropInteraction(_ interaction: UIDropInteraction, canHandle session: UIDropSession) -> Bool {
  220. return canHandleDropSession(session: session)
  221. }
  222. func dropInteraction(_ interaction: UIDropInteraction, sessionDidUpdate session: UIDropSession) -> UIDropProposal {
  223. return UIDropProposal(operation: .copy)
  224. }
  225. func dropInteraction(_ interaction: UIDropInteraction, performDrop session: UIDropSession) {
  226. for item in session.items {
  227. createFileWith(itemProvider: item.itemProvider) {
  228. [weak self] _, error in
  229. if let error = error as? DropError {
  230. self?.handleError(error: error, itemProvider: item.itemProvider)
  231. }
  232. // no need to handle the file case since the libraryVC updates itself after getting a file
  233. }
  234. }
  235. }
  236. // MARK: - Shared Methods
  237. // Checks if the session has items conforming to typeidentifiers
  238. private func canHandleDropSession(session: UIDropSession) -> Bool {
  239. if session.localDragSession != nil {
  240. return true
  241. }
  242. return session.hasItemsConforming(toTypeIdentifiers: utiTypeIdentifiers)
  243. }
  244. /// Returns a drop operation type
  245. ///
  246. /// - Parameters:
  247. /// - hasActiveDrag: State if the drag started within the app
  248. /// - item: UIDragItem from session
  249. /// - Returns: UIDropOperation
  250. private func dropOperation(hasActiveDrag: Bool, firstSessionItem item: AnyObject?, withDestinationIndexPath destinationIndexPath: IndexPath?) -> UIDropOperation {
  251. let inAlbum = subcategory.dragAndDropManagerCurrentSelection(manager: self) as? MLAlbum != nil
  252. let inShow = subcategory.dragAndDropManagerCurrentSelection(manager: self) as? MLShow != nil
  253. // you can move files into a folder or copy from anothr app into a folder
  254. if fileIsFolder(atIndexPath: destinationIndexPath) {
  255. // no dragging entire shows and albums into folders
  256. if let dragItem = item, let mlFile = dragItem.localObject as? MLFile, mlFile.isAlbumTrack() || mlFile.isShowEpisode() {
  257. return .forbidden
  258. }
  259. return hasActiveDrag ? .move : .copy
  260. }
  261. // you can't reorder
  262. if inFolder() {
  263. return hasActiveDrag ? .forbidden : .copy
  264. }
  265. // you can't reorder in or drag into an Album or Show
  266. if inAlbum || inShow {
  267. return .cancel
  268. }
  269. // we're dragging a file out of a folder
  270. if let dragItem = item, let mlFile = dragItem.localObject as? MLFile, !mlFile.labels.isEmpty {
  271. return .copy
  272. }
  273. // no reorder from another app into the top layer
  274. return hasActiveDrag ? .forbidden : .copy
  275. }
  276. /// show an Alert when dropping failed
  277. ///
  278. /// - Parameters:
  279. /// - error: the type of error that happend
  280. /// - itemProvider: the itemProvider to retrieve the suggestedName
  281. private func handleError(error: DropError, itemProvider: NSItemProvider) {
  282. let message: String
  283. let filename = itemProvider.suggestedName ?? NSLocalizedString("THIS_FILE", comment: "")
  284. switch error.kind {
  285. case .loadFileRepresentationFailed:
  286. message = String(format: NSLocalizedString("NOT_SUPPORTED_FILETYPE", comment: ""), filename)
  287. case .moveFileToDocuments:
  288. message = String(format: NSLocalizedString("FILE_EXISTS", comment: ""), filename)
  289. }
  290. let alert = UIAlertController(title: NSLocalizedString("ERROR", comment: ""), message: message, preferredStyle: .alert)
  291. alert.addAction(UIAlertAction(title: NSLocalizedString("OK", comment: ""), style: .default, handler: nil))
  292. UIApplication.shared.keyWindow?.rootViewController?.present(alert, animated: true, completion: nil)
  293. }
  294. private func fileIsFolder(atIndexPath indexPath: IndexPath?) -> Bool {
  295. if let indexPath = indexPath {
  296. let file = subcategory.dragAndDropManagerRequestsFile(manager: self, atIndexPath: indexPath)
  297. return file as? MLLabel != nil
  298. }
  299. return false
  300. }
  301. private func fileIsCollection(file: Any?) -> Bool {
  302. let isFolder = file as? MLLabel != nil
  303. let isAlbum = file as? MLAlbum != nil
  304. let isShow = file as? MLShow != nil
  305. return isFolder || isAlbum || isShow
  306. }
  307. private func fileIsCollection(atIndexPath indexPath: IndexPath?) -> Bool {
  308. if let indexPath = indexPath {
  309. if let file = subcategory.dragAndDropManagerRequestsFile(manager: self, atIndexPath: indexPath) {
  310. return fileIsCollection(file:file)
  311. }
  312. }
  313. return false
  314. }
  315. // creating dragItems for the file at indexpath
  316. private func dragItems(forIndexPath indexPath: IndexPath) -> [UIDragItem] {
  317. if let file = subcategory.dragAndDropManagerRequestsFile(manager: self, atIndexPath: indexPath) {
  318. if fileIsCollection(atIndexPath: indexPath) {
  319. return dragItemsforCollection(file: file)
  320. }
  321. return dragItem(fromFile: file)
  322. }
  323. assert(false, "we can't generate a dragfile if the delegate can't return a file ")
  324. return []
  325. }
  326. /// Iterates over the items of a collection to create dragitems.
  327. /// Since we're not storing collections as folders we have to provide single files
  328. ///
  329. /// - Parameter file: Can be of type MLAlbum, MLLabel or MLShow
  330. /// - Returns: An array of UIDragItems
  331. private func dragItemsforCollection(file: Any) -> [UIDragItem] {
  332. var dragItems = [UIDragItem]()
  333. var set = Set<AnyHashable>()
  334. if let folder = file as? MLLabel {
  335. set = folder.files
  336. } else if let album = file as? MLAlbum {
  337. for track in album.tracks {
  338. if let mlfile = (track as? MLAlbumTrack)?.files.first {
  339. _ = set.insert(mlfile)
  340. }
  341. }
  342. } else if let show = file as? MLShow {
  343. for episode in show.episodes {
  344. if let mlfile = (episode as? MLShowEpisode)?.files {
  345. set = set.union(mlfile)
  346. }
  347. }
  348. } else {
  349. assert(false, "can't get dragitems from a file that is not a collection")
  350. }
  351. for convertibleFile in set {
  352. if let mlfile = convertibleFile as? MLFile, let item = dragItem(fromFile: mlfile).first {
  353. dragItems.append(item)
  354. }
  355. }
  356. return dragItems
  357. }
  358. //Provides an item for other applications
  359. private func dragItem(fromFile file: Any) -> [UIDragItem] {
  360. guard let file = mlFile(from: file as AnyObject), let path = file.url else {
  361. assert(false, "can't create a dragitem if there is no file or the file has no url")
  362. return []
  363. }
  364. let data = try? Data(contentsOf: path, options: .mappedIfSafe)
  365. let itemProvider = NSItemProvider()
  366. itemProvider.suggestedName = path.lastPathComponent
  367. // maybe use UTTypeForFileURL
  368. if let identifiers = try? path.resourceValues(forKeys: [.typeIdentifierKey]), let identifier = identifiers.typeIdentifier {
  369. // here we can show progress
  370. itemProvider.registerDataRepresentation(forTypeIdentifier: identifier, visibility: .all) { completion -> Progress? in
  371. completion(data, nil)
  372. return nil
  373. }
  374. let dragitem = UIDragItem(itemProvider: itemProvider)
  375. dragitem.localObject = file
  376. return [dragitem]
  377. }
  378. assert(false, "we can't provide a typeidentifier")
  379. return []
  380. }
  381. private func mlFile(from file: AnyObject) -> MLFile? {
  382. if let episode = file as? MLShowEpisode, let convertedfile = episode.files.first as? MLFile {
  383. return convertedfile
  384. }
  385. if let track = file as? MLAlbumTrack, let convertedfile = track.files.first as? MLFile {
  386. return convertedfile
  387. }
  388. if let convertedfile = file as? MLFile {
  389. return convertedfile
  390. }
  391. return nil
  392. }
  393. private func addFile(file: MLFile, toFolderAt folderIndex: IndexPath) {
  394. let label = subcategory.dragAndDropManagerRequestsFile(manager: self, atIndexPath: folderIndex) as! MLLabel
  395. DispatchQueue.main.async {
  396. _ = label.files.insert(file)
  397. file.labels = [label]
  398. file.folderTrackNumber = NSNumber(integerLiteral: label.files.count - 1)
  399. }
  400. }
  401. /// try to create a file from the dropped item
  402. ///
  403. /// - Parameters:
  404. /// - itemProvider: itemprovider which is used to load the files from
  405. /// - completion: callback with the successfully created file or error if it failed
  406. private func createFileWith(itemProvider: NSItemProvider, completion: @escaping ((MLFile?, Error?) -> Void)) {
  407. itemProvider.loadFileRepresentation(forTypeIdentifier: kUTTypeData as String) {
  408. [weak self] (url, error) in
  409. guard let strongSelf = self else { return }
  410. guard let url = url else {
  411. DispatchQueue.main.async {
  412. completion(nil, DropError(kind: .loadFileRepresentationFailed))
  413. }
  414. return
  415. }
  416. // returns nil for local session but this should also not be called for a local session
  417. guard let destinationURL = strongSelf.moveFileToDocuments(fromURL: url) else {
  418. DispatchQueue.main.async {
  419. completion(nil, DropError(kind: .moveFileToDocuments))
  420. }
  421. return
  422. }
  423. DispatchQueue.global(qos: .background).async {
  424. let sharedlib = MLMediaLibrary.sharedMediaLibrary() as? MLMediaLibrary
  425. sharedlib?.addFilePaths([destinationURL.path])
  426. if let file = MLFile.file(for: destinationURL).first as? MLFile {
  427. DispatchQueue.main.async {
  428. // we dragged into a folder
  429. if let selection = strongSelf.subcategory.dragAndDropManagerCurrentSelection(manager: strongSelf) as? MLLabel {
  430. file.labels = [selection]
  431. }
  432. completion(file, nil)
  433. }
  434. }
  435. }
  436. }
  437. }
  438. private func moveFileToDocuments(fromURL filepath: URL?) -> URL? {
  439. let searchPaths = NSSearchPathForDirectoriesInDomains(.documentDirectory, .userDomainMask, true)
  440. let newDirectoryPath = searchPaths.first
  441. guard let directoryPath = newDirectoryPath, let url = filepath else {
  442. return nil
  443. }
  444. let destinationURL = URL(fileURLWithPath: "\(directoryPath)" + "/" + "\(url.lastPathComponent)")
  445. do {
  446. try FileManager.default.moveItem(at: url, to: destinationURL)
  447. } catch let error {
  448. print(error.localizedDescription)
  449. return nil
  450. }
  451. return destinationURL
  452. }
  453. }