// // ViewController.swift // TagTunes // // Created by Kim Wittenburg on 28.08.15. // Copyright © 2015 Kim Wittenburg. All rights reserved. // import Cocoa import AppKitPlus internal class MainViewController: NSViewController { // MARK: Types private struct OutlineViewConstants { struct ViewIdentifiers { static let simpleTableCellViewIdentifier = "SimpleTableCellViewIdentifier" static let centeredTableCellViewIdentifier = "CenteredTableCellViewIdentifier" static let albumTableCellViewIdentifier = "AlbumTableCellViewIdentifier" static let trackTableCellViewIdentifier = "TrackTableCellViewIdentifier" } struct Items { static let loadingItem: AnyObject = "LoadingItem" static let noResultsItem: AnyObject = "NoResultsItem" static let searchResultsHeaderItem: AnyObject = MainViewController.Section.SearchResults.rawValue static let albumsHeaderItem: AnyObject = MainViewController.Section.Albums.rawValue static let unsortedTracksHeaderItem: AnyObject = MainViewController.Section.UnsortedTracks.rawValue } static let pasteboardType = "public.item.tagtunes" } internal enum Section: String { case SearchResults = "SearchResults" case Albums = "Albums" case UnsortedTracks = "UnsortedTracks" static func isHeaderItem(item: AnyObject) -> Bool { if let itemAsString = item as? String { return Section(rawValue: itemAsString) != nil } else { return false } } } // MARK: IBOutlets @IBOutlet private weak var outlineView: NSOutlineView! // MARK: Properties /// Used for searching and loading search results private let urlSession = NSURLSession(configuration: NSURLSessionConfiguration.defaultSessionConfiguration(), delegate: nil, delegateQueue: NSOperationQueue.mainQueue()) /// The URL task currently loading the search results private var searchTask: NSURLSessionDataTask? /// If `true` the search section is displayed at the top of the /// `outlineView`. internal var showsSearch: Bool = false /// `true` if there is currently a search in progress. internal var searching: Bool { return searchTask != nil } /// The error that occured during searching, if any. internal private(set) var searchError: NSError? /// The URL tasks currently loading the tracks for the respective albums. private var trackTasks = [Album: NSURLSessionDataTask]() /// Errors that occured during loading the tracks for the respective album. private var trackErrors = [Album: NSError]() // MARK: Overrides override internal func viewDidLoad() { super.viewDidLoad() outlineView.setDraggingSourceOperationMask(.Move, forLocal: true) outlineView.registerForDraggedTypes([OutlineViewConstants.pasteboardType]) } // MARK: Outline View Content internal private(set) var searchResults = [SearchResult]() internal private(set) var albums = [Album]() internal private(set) var unsortedTracks = [iTunesTrack]() /// Returns all `iTunesTrack` objects that are somewhere down the outline /// view. private var allITunesTracks: Set { return Set(unsortedTracks).union(albums.flatMap({ $0.tracks.flatMap { $0.associatedTracks } })) } /// Returns all contents of the outline view. /// /// This property is regenerated every time it is queried. If you need to /// access it a lot of times it is recommended to chache it into a local /// variable. private var outlineViewContents: [AnyObject] { var contents = [AnyObject]() if showsSearch { contents.append(OutlineViewConstants.Items.searchResultsHeaderItem) if !searchResults.isEmpty { contents.extend(searchResults as [AnyObject]) } else if searching { contents.append(OutlineViewConstants.Items.loadingItem) } else if let error = searchError { contents.append(error) } else { contents.append(OutlineViewConstants.Items.noResultsItem) } if !albums.isEmpty { contents.append(OutlineViewConstants.Items.albumsHeaderItem) } } contents.extend(albums as [AnyObject]) if !unsortedTracks.isEmpty { contents.append(OutlineViewConstants.Items.unsortedTracksHeaderItem) contents.extend(unsortedTracks as [AnyObject]) } return contents } /// Returns the section of the specified row or `nil` if the row is not a /// valid row. internal func sectionOfRow(row: Int) -> Section? { if row < 0 { return nil } var relativeRow = row if showsSearch { let searchRelatedItemCount = 1 + (searchResults.isEmpty ? 1 : searchResults.count) if relativeRow < searchRelatedItemCount { return .SearchResults } else { relativeRow -= searchRelatedItemCount } } var maxRow = outlineView.numberOfRows if !unsortedTracks.isEmpty { maxRow -= unsortedTracks.count + 1 } if relativeRow < maxRow { return .Albums } else { relativeRow -= maxRow } if relativeRow < unsortedTracks.count + 1 { return .UnsortedTracks } return nil } /// Returns the section the specified item resides in or `nil` if the item is /// not part of the outline view's contents. internal func sectionOfItem(item: AnyObject) -> Section? { if let album = item as? Album where albums.contains(album) { return .Albums } else if let track = item as? Track where albums.contains(track.album) { return .Albums } else if let track = item as? iTunesTrack { if let parentTrack = outlineView.parentForItem(track) as? Track where albums.contains(parentTrack.album) { return .Albums } else { return unsortedTracks.contains(track) ? .UnsortedTracks : nil } } else if item === OutlineViewConstants.Items.loadingItem || item === OutlineViewConstants.Items.noResultsItem || item is NSError { return .SearchResults } else if let string = item as? String { return Section(rawValue: string) } else { return nil } } /// Returns `true` if the specified `album` is currently loading its tracks. internal func isAlbumLoading(album: Album) -> Bool { return trackTasks[album] != nil } // MARK: Searching /// Starts a search for the specified search term. Calling this method internal func beginSearchForTerm(term: String) { searchTask?.cancel() searchResults.removeAll() if let url = iTunesAPI.createAlbumSearchURLForTerm(term) { showsSearch = true searchTask = urlSession.dataTaskWithURL(url, completionHandler: processSearchResults) searchTask?.resume() } else { showsSearch = false } outlineView.reloadData() } /// Processes the data returned from a network request into the /// `searchResults`. private func processSearchResults(data: NSData?, response: NSURLResponse?, error: NSError?) { searchTask = nil if let theError = error { searchError = theError } else if let theData = data { do { let searchResults = try iTunesAPI.parseAPIData(theData).map { SearchResult(representedAlbum: $0) } self.searchResults = searchResults } catch let error as NSError { searchError = error } } showsSearch = true outlineView.reloadData() } /// Adds the search result at the specified `row` to the albums section and /// begins loading its tracks. internal func selectSearchResultAtRow(row: Int) { guard sectionOfRow(row) == .SearchResults else { return } let searchResult = outlineView.itemAtRow(row) as! SearchResult if !Preferences.sharedPreferences.keepSearchResults { searchResults.removeAll() showsSearch = false } var albumAlreadyPresent = false for album in albums { if album == searchResult { albumAlreadyPresent = true } } if !albumAlreadyPresent { albums.append(beginLoadingTracksForSearchResult(searchResult)) } outlineView.reloadData() } // MARK: Albums private func beginLoadingTracksForSearchResult(searchResult: SearchResult) -> Album { let album = Album(searchResult: searchResult) let url = iTunesAPI.createAlbumLookupURLForId(album.id) let task = urlSession.dataTaskWithURL(url) { (data, response, var error) -> Void in self.trackTasks[album] = nil do { if let theData = data { let newAlbum = try iTunesAPI.parseAPIData(theData)[0] let index = self.albums.indexOf(album)! self.albums.removeAtIndex(index) self.albums.insert(newAlbum, atIndex: index) } } catch let theError as NSError { error = theError } catch _ { // Will never happen } self.trackErrors[album] = error self.outlineView.reloadData() } trackTasks[album] = task task.resume() return album } func cancelLoadingTracksForAlbum(album: Album) { trackTasks[album]?.cancel() trackTasks[album] = nil } private func saveTracks(tracks: [Track: [iTunesTrack]]) { let numberOfTracks = tracks.reduce(0) { (count: Int, element: (key: Track, value: [iTunesTrack])) -> Int in return count + element.value.count } let progress = NSProgress(totalUnitCount: Int64(numberOfTracks)) NSProgress.currentProgress()?.localizedDescription = NSLocalizedString("Saving tracks…", comment: "Alert message indicating that the selected tracks are currently being saved") NSProgress.currentProgress()?.localizedAdditionalDescription = progress.localizedAdditionalDescription for (parentTrack, targetTracks) in tracks { for track in targetTracks { parentTrack.saveToTrack(track) ++progress.completedUnitCount NSProgress.currentProgress()?.localizedAdditionalDescription = progress.localizedAdditionalDescription } } } private func saveArtworks(tracks: [Track: [iTunesTrack]]) { var albums = Set() for (track, _) in tracks { albums.insert(track.album) } let progress = NSProgress(totalUnitCount: Int64(albums.count)) var errorCount = 0 NSProgress.currentProgress()?.localizedDescription = NSLocalizedString("Saving artworks…", comment: "Alert message indicating that the artworks for the selected tracks are currently being saved") NSProgress.currentProgress()?.localizedAdditionalDescription = progress.localizedAdditionalDescription for album in albums { do { try album.saveArtwork() } catch _ { ++errorCount } ++progress.completedUnitCount NSProgress.currentProgress()?.localizedAdditionalDescription = progress.localizedAdditionalDescription } if errorCount > 0 { dispatch_sync(dispatch_get_main_queue()) { let alert = NSAlert() if errorCount == 1 { alert.messageText = NSLocalizedString("1 artwork could not be saved.", comment: "Error message indicating that one of the artworks could not be saved.") } else { alert.messageText = String(format: NSLocalizedString("%d artworks could not be saved.", comment: "Error message indicating that n artworks could not be saved."), errorCount) } alert.informativeText = NSLocalizedString("Please check your privileges for the folder you set in the preferences and try again.", comment: "Informative text for 'artwork(s) could not be saved' errors") alert.alertStyle = .WarningAlertStyle alert.addButtonWithTitle("OK") alert.beginSheetModalForWindow(self.view.window!, completionHandler: nil) } } } // MARK: Actions /// Adds the current iTunes selection @IBAction internal func addITunesSelection(sender: AnyObject?) { if !iTunes.running { let alert = NSAlert() alert.messageText = NSLocalizedString("iTunes is not running", comment: "Error message informing the user that iTunes is not currently running.") alert.informativeText = NSLocalizedString("Please launch iTunes and try again.", comment: "Informative text for the 'iTunes is not running' error") alert.addButtonWithTitle(NSLocalizedString("OK", comment: "Button title")) alert.beginSheetModalForWindow(view.window!, completionHandler: nil) } else if let selection = iTunes.selection.get() as? [iTunesTrack] { let newTracks = Set(selection).subtract(allITunesTracks) unsortedTracks.extend(newTracks) outlineView.reloadData() } } /// Begins to search for the `sender`'s `stringValue`. @IBAction internal func performSearch(sender: AnyObject?) { if let searchTerm = sender?.stringValue { beginSearchForTerm(searchTerm) } } /// Selects the search result associated with the `sender`'s row (as /// determined by `NSOutlineView.rowForView`) and adds it to the list of /// albums. @IBAction private func selectSearchResult(sender: AnyObject?) { if let view = sender as? NSView { let row = outlineView.rowForView(view) selectSearchResultAtRow(row) } } /// Saves the selected items to iTunes. The saving process will be reported /// to the user in a progress sheet. @IBAction internal func performSave(sender: AnyObject?) { var itemsToBeSaved = [Track: [iTunesTrack]]() for row in outlineView.selectedRowIndexes where sectionOfRow(row) == .Albums { let item = outlineView.itemAtRow(row) if let album = item as? Album { for track in album.tracks where !track.associatedTracks.isEmpty { itemsToBeSaved[track] = track.associatedTracks } } else if let track = item as? Track { itemsToBeSaved[track] = track.associatedTracks } else if let track = item as? iTunesTrack { if let parentTrack = outlineView.parentForItem(track) as? Track { if itemsToBeSaved[parentTrack] != nil { itemsToBeSaved[parentTrack]?.append(track) } else { itemsToBeSaved[parentTrack] = [track] } } } } guard !itemsToBeSaved.isEmpty else { return } let progress = NSProgress(totalUnitCount: 100) progress.beginProgressSheetModalForWindow(self.view.window!) { reponse in self.outlineView.reloadData() } dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0)) { if Preferences.sharedPreferences.saveArtwork { progress.becomeCurrentWithPendingUnitCount(90) } else { progress.becomeCurrentWithPendingUnitCount(100) } self.saveTracks(itemsToBeSaved) progress.resignCurrent() if Preferences.sharedPreferences.saveArtwork { progress.becomeCurrentWithPendingUnitCount(10) self.saveArtworks(itemsToBeSaved) progress.resignCurrent() } } } /// Removes the selected items from the outline view. @IBAction internal func delete(sender: AnyObject?) { let items = outlineView.selectedRowIndexes.map { ($0, outlineView.itemAtRow($0)) } for (row, item) in items { if sectionOfRow(row)! != .SearchResults { if let album = item as? Album { cancelLoadingTracksForAlbum(album) albums.removeElement(album) } else if let track = item as? Track { track.associatedTracks = [] } else if let track = item as? iTunesTrack { if let parentTrack = outlineView.parentForItem(track) as? Track { parentTrack.associatedTracks.removeElement(track) } else { unsortedTracks.removeElement(track) } } } } outlineView.reloadData() } /// Action that should be triggered from a view inside the outline view. If /// `sender` is not an `NSError` instance the item at the row associated with /// the `sender` (as determined by `NSOutlineView.rowForView`) should be a /// `NSError` or `Album` instance for this method to work correctly. @IBAction private func showErrorDetails(sender: AnyObject?) { var error: NSError if let theError = sender as? NSError { error = theError } else if let view = sender as? NSView { let row = outlineView.rowForView(view) let item = outlineView.itemAtRow(row) if let theError = item as? NSError { error = theError } else if let album = item as? Album { if let theError = trackErrors[album] { error = theError } else { return } } else { return } } else { return } presentError(error, modalForWindow: view.window!, delegate: nil, didPresentSelector: nil, contextInfo: nil) } } // MARK: - User Interface Validations extension MainViewController: NSUserInterfaceValidations { func validateUserInterfaceItem(anItem: NSValidatedUserInterfaceItem) -> Bool { if anItem.action() == "performSave:" { for row in outlineView.selectedRowIndexes { return sectionOfRow(row) == .Albums } } else if anItem.action() == "addITunesSelection:" { guard iTunes.running else { return false } return !(iTunes.selection.get() as! [AnyObject]).isEmpty } else if anItem.action() == "delete:" { for row in outlineView.selectedRowIndexes { if sectionOfRow(row) != .SearchResults { return true } } } return false } } // MARK: - Outline View extension MainViewController: NSOutlineViewDataSource, NSOutlineViewDelegate { func outlineView(outlineView: NSOutlineView, numberOfChildrenOfItem item: AnyObject?) -> Int { if item == nil { return outlineViewContents.count } else if let album = item as? Album { return album.tracks.count } else if let track = item as? Track { return track.associatedTracks.count } else { return 0 } } func outlineView(outlineView: NSOutlineView, child index: Int, ofItem item: AnyObject?) -> AnyObject { if item == nil { return outlineViewContents[index] } else if let album = item as? Album { return album.tracks[index] } else if let track = item as? Track { return track.associatedTracks[index] } else { return "" } } func outlineView(outlineView: NSOutlineView, isItemExpandable item: AnyObject) -> Bool { return self.outlineView(outlineView, numberOfChildrenOfItem: item) > 0 } func outlineView(outlineView: NSOutlineView, isGroupItem item: AnyObject) -> Bool { return Section.isHeaderItem(item) } func outlineView(outlineView: NSOutlineView, shouldSelectItem item: AnyObject) -> Bool { return !(self.outlineView(outlineView, isGroupItem: item) || item === OutlineViewConstants.Items.loadingItem || item === OutlineViewConstants.Items.noResultsItem || item is NSError) } func outlineView(outlineView: NSOutlineView, heightOfRowByItem item: AnyObject) -> CGFloat { if item is Album || item is SearchResult { return 39 } else if let track = item as? Track { return track.album.hasSameArtistNameAsTracks ? 24 : 31 } else if item === OutlineViewConstants.Items.loadingItem { return 39 } else if item === OutlineViewConstants.Items.noResultsItem || item is NSError { return 32 } else if Section.isHeaderItem(item) { return 24 } else { return 17 } } func outlineView(outlineView: NSOutlineView, viewForTableColumn tableColumn: NSTableColumn?, item: AnyObject) -> NSView? { if item === OutlineViewConstants.Items.searchResultsHeaderItem { var view = outlineView.makeViewWithIdentifier(OutlineViewConstants.ViewIdentifiers.simpleTableCellViewIdentifier, owner: nil) as? AdvancedTableCellView if view == nil { view = AdvancedTableCellView() view?.identifier = OutlineViewConstants.ViewIdentifiers.simpleTableCellViewIdentifier } view?.style = .Simple view?.textField?.font = NSFont.boldSystemFontOfSize(0) view?.textField?.textColor = NSColor.disabledControlTextColor() view?.textField?.stringValue = NSLocalizedString("Search Results", comment: "Header name for the seach results section") return view } if item === OutlineViewConstants.Items.albumsHeaderItem { var view = outlineView.makeViewWithIdentifier(OutlineViewConstants.ViewIdentifiers.simpleTableCellViewIdentifier, owner: nil) as? AdvancedTableCellView if view == nil { view = AdvancedTableCellView() view?.identifier = OutlineViewConstants.ViewIdentifiers.simpleTableCellViewIdentifier } view?.style = .Simple view?.textField?.font = NSFont.boldSystemFontOfSize(0) view?.textField?.textColor = NSColor.disabledControlTextColor() view?.textField?.stringValue = NSLocalizedString("Albums", comment: "Header name for the albums section") return view } if item === OutlineViewConstants.Items.unsortedTracksHeaderItem { var view = outlineView.makeViewWithIdentifier(OutlineViewConstants.ViewIdentifiers.simpleTableCellViewIdentifier, owner: nil) as? AdvancedTableCellView if view == nil { view = AdvancedTableCellView() view?.identifier = OutlineViewConstants.ViewIdentifiers.simpleTableCellViewIdentifier } view?.style = .Simple view?.textField?.font = NSFont.boldSystemFontOfSize(0) view?.textField?.textColor = NSColor.disabledControlTextColor() view?.textField?.stringValue = NSLocalizedString("Unsorted Tracks", comment: "Header name for the unsorted tracks section") return view } if item === OutlineViewConstants.Items.loadingItem { var view = outlineView.makeViewWithIdentifier(OutlineViewConstants.ViewIdentifiers.centeredTableCellViewIdentifier, owner: nil) as? CenteredTableCellView if view == nil { view = CenteredTableCellView() view?.identifier = OutlineViewConstants.ViewIdentifiers.centeredTableCellViewIdentifier } view?.setupForLoading() return view } if item === OutlineViewConstants.Items.noResultsItem { var view = outlineView.makeViewWithIdentifier(OutlineViewConstants.ViewIdentifiers.centeredTableCellViewIdentifier, owner: nil) as? CenteredTableCellView if view == nil { view = CenteredTableCellView() view?.identifier = OutlineViewConstants.ViewIdentifiers.centeredTableCellViewIdentifier } view?.setupForMessage(NSLocalizedString("No Results", comment: "Message informing the user that the search didn't return any results")) return view } if let error = item as? NSError { var view = outlineView.makeViewWithIdentifier(OutlineViewConstants.ViewIdentifiers.centeredTableCellViewIdentifier, owner: nil) as? CenteredTableCellView if view == nil { view = CenteredTableCellView() view?.identifier = OutlineViewConstants.ViewIdentifiers.centeredTableCellViewIdentifier } view?.button?.target = self view?.button?.action = "showErrorDetails:" view?.setupForError(error, errorMessage: NSLocalizedString("Failed to load results", comment: "Error message informing the user that an error occured during searching.")) return view } if let album = item as? Album { var view = outlineView.makeViewWithIdentifier(OutlineViewConstants.ViewIdentifiers.albumTableCellViewIdentifier, owner: nil) as? AlbumTableCellView if view == nil { view = AlbumTableCellView() view?.identifier = OutlineViewConstants.ViewIdentifiers.albumTableCellViewIdentifier } view?.setupForAlbum(album, loading: isAlbumLoading(album), error: trackErrors[album]) view?.errorButton?.target = self view?.errorButton?.action = "showErrorDetails:" return view } if let searchResult = item as? SearchResult { var view = outlineView.makeViewWithIdentifier(OutlineViewConstants.ViewIdentifiers.albumTableCellViewIdentifier, owner: nil) as? AlbumTableCellView if view == nil { view = AlbumTableCellView() view?.identifier = OutlineViewConstants.ViewIdentifiers.albumTableCellViewIdentifier } view?.button.target = self view?.button.action = "selectSearchResult:" let selectable = albums.filter { $0.id == searchResult.id }.isEmpty view?.setupForSearchResult(searchResult, selectable: selectable) return view } if let track = item as? Track { var view = outlineView.makeViewWithIdentifier(OutlineViewConstants.ViewIdentifiers.trackTableCellViewIdentifier, owner: nil) as? TrackTableCellView if view == nil { view = TrackTableCellView() view?.identifier = OutlineViewConstants.ViewIdentifiers.trackTableCellViewIdentifier } view?.setupForTrack(track) return view } if let track = item as? iTunesTrack { var view = outlineView.makeViewWithIdentifier(OutlineViewConstants.ViewIdentifiers.simpleTableCellViewIdentifier, owner: nil) as? AdvancedTableCellView if view == nil { view = AdvancedTableCellView() view?.identifier = OutlineViewConstants.ViewIdentifiers.simpleTableCellViewIdentifier } view?.style = .Simple view?.textField?.font = NSFont.systemFontOfSize(0) view?.textField?.textColor = NSColor.textColor() view?.textField?.stringValue = track.name return view } return nil } func outlineView(outlineView: NSOutlineView, writeItems items: [AnyObject], toPasteboard pasteboard: NSPasteboard) -> Bool { var rows = [Int]() var containsValidItems = false for item in items { let row = outlineView.rowForItem(item) rows.append(row) if sectionOfRow(row) != .SearchResults { containsValidItems = true } } if !containsValidItems { return false } let data = NSKeyedArchiver.archivedDataWithRootObject(rows) pasteboard.declareTypes([OutlineViewConstants.pasteboardType], owner: nil) pasteboard.setData(data, forType: OutlineViewConstants.pasteboardType) return true } func outlineView(outlineView: NSOutlineView, validateDrop info: NSDraggingInfo, proposedItem item: AnyObject?, proposedChildIndex index: Int) -> NSDragOperation { let firstUnsortedRow = outlineViewContents.count - (unsortedTracks.isEmpty ? 0 : unsortedTracks.count+1) // Drop in the 'unsorted' section if item == nil && index >= firstUnsortedRow || item === OutlineViewConstants.Items.unsortedTracksHeaderItem { outlineView.setDropItem(nil, dropChildIndex: outlineViewContents.count) return .Every } // Drop on iTunesTrack item or between items if index != NSOutlineViewDropOnItemIndex || item is iTunesTrack { return .None } // Drop on header row if item != nil && self.outlineView(outlineView, isGroupItem: item!) { return .None } // Drop in 'search results' section let row = outlineView.rowForItem(item) if sectionOfRow(row) == .SearchResults { return .None } if let album = item as? Album where isAlbumLoading(album) || trackErrors[album] != nil { return .None } return .Every } func outlineView(outlineView: NSOutlineView, acceptDrop info: NSDraggingInfo, item: AnyObject?, childIndex index: Int) -> Bool { guard let data = info.draggingPasteboard().dataForType(OutlineViewConstants.pasteboardType), draggedRows = NSKeyedUnarchiver.unarchiveObjectWithData(data) as? [Int] else { return false } // Get the dragged tracks and remove them from their previous location var draggedTracks = Set() for row in draggedRows { if sectionOfRow(row) != .SearchResults { let item = outlineView.itemAtRow(row) if let album = item as? Album { for track in album.tracks { draggedTracks.unionInPlace(track.associatedTracks) track.associatedTracks.removeAll() } } else if let track = item as? Track { draggedTracks.unionInPlace(track.associatedTracks) track.associatedTracks.removeAll() } else if let track = item as? iTunesTrack { draggedTracks.insert(track) if let parentTrack = outlineView.parentForItem(track) as? Track { parentTrack.associatedTracks.removeElement(track) } else { unsortedTracks.removeElement(track) } } } } // Add the dragged tracks to the new target if let targetTrack = item as? Track { targetTrack.associatedTracks.extend(draggedTracks) } else if let targetAlbum = item as? Album { for draggedTrack in draggedTracks { var inserted = false for track in targetAlbum.tracks { if (draggedTrack.discNumber == track.discNumber || draggedTrack.discNumber == 0) && draggedTrack.trackNumber == track.trackNumber { track.associatedTracks.append(draggedTrack) inserted = true break } } if !inserted { unsortedTracks.append(draggedTrack) } } } else { unsortedTracks.extend(draggedTracks) } outlineView.reloadData() return true } }