// // 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" } private struct KVOContexts { static var preferencesContext = "KVOPreferencesContext" } 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: NSURLSessionTask? private var searchTerm: String? /// 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? // MARK: View Life Cycle // Proxy objects that act as `NSNotificationCenter` observers. private var observerProxies = [NSObjectProtocol]() override internal func viewDidLoad() { super.viewDidLoad() let startedLoadingTracksObserver = NSNotificationCenter.defaultCenter().addObserverForName(AlbumCollection.Notifications.albumStartedLoading, object: albumCollection, queue: NSOperationQueue.mainQueue(), usingBlock: albumCollectionDidBeginLoadingTracks) let finishedLoadingTracksObserver = NSNotificationCenter.defaultCenter().addObserverForName(AlbumCollection.Notifications.albumFinishedLoading, object: albumCollection, queue: NSOperationQueue.mainQueue(), usingBlock: albumCollectionDidFinishLoadingTracks) observerProxies.append(startedLoadingTracksObserver) observerProxies.append(finishedLoadingTracksObserver) Preferences.sharedPreferences.addObserver(self, forKeyPath: "useCensoredNames", options: [], context: &KVOContexts.preferencesContext) Preferences.sharedPreferences.addObserver(self, forKeyPath: "caseSensitive", options: [], context: &KVOContexts.preferencesContext) outlineView.setDraggingSourceOperationMask(.Move, forLocal: true) outlineView.registerForDraggedTypes([OutlineViewConstants.pasteboardType]) } deinit { for observer in observerProxies { NSNotificationCenter.defaultCenter().removeObserver(observer) } } // MARK: Outline View Content internal private(set) var searchResults = [SearchResult]() internal let albumCollection = AlbumCollection() 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(albumCollection.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.appendContentsOf(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 !albumCollection.isEmpty { contents.append(OutlineViewConstants.Items.albumsHeaderItem) } } contents.appendContentsOf(albumCollection.albums as [AnyObject]) if !unsortedTracks.isEmpty { contents.append(OutlineViewConstants.Items.unsortedTracksHeaderItem) contents.appendContentsOf(unsortedTracks as [AnyObject]) } return contents } /// Returns all selected items. This property removes duplicate items from /// the returned array (for example a track is not included if the whole /// album the track belongs to is included itself). /// /// This value is not cached. If you need to access this value often, you /// should consider caching it yourself in a local variable. The order in /// which the selected objects occur in the returned array is random. /// /// - returns: An array of `SearchResult`s, `Album`s, `Track`s and /// `iTunesTrack`s. private var selectedItems: [AnyObject] { var selectedSearchResults = Set() var selectedAlbums = Set() var selectedTracks = Set() var selectedITunesTracks = Set() for row in outlineView.selectedRowIndexes { let item = outlineView.itemAtRow(row) if let searchResult = item as? SearchResult { selectedSearchResults.insert(searchResult) } else if let album = item as? Album { selectedAlbums.insert(album) } else if let track = item as? Track { selectedTracks.insert(track) } else if let track = item as? iTunesTrack { selectedITunesTracks.insert(track) } } for album in selectedAlbums { for track in album.tracks { for iTunesTrack in track.associatedTracks { selectedITunesTracks.remove(iTunesTrack) } selectedTracks.remove(track) } } for track in selectedTracks { for iTunesTrack in track.associatedTracks { selectedITunesTracks.remove(iTunesTrack) } } var selectedItems = [AnyObject]() selectedItems.appendContentsOf(Array(selectedSearchResults) as [AnyObject]) selectedItems.appendContentsOf(Array(selectedAlbums) as [AnyObject]) selectedItems.appendContentsOf(Array(selectedTracks) as [AnyObject]) selectedItems.appendContentsOf(Array(selectedITunesTracks) as [AnyObject]) return selectedItems } internal func parentForTrack(track: iTunesTrack) -> Track? { return outlineView.parentForItem(track) as? Track } internal func containsAlbumForSearchResult(searchResult: SearchResult) -> Bool { for album in albumCollection { if album.id == searchResult.id { return true } } return false } /// 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 } // MARK: Searching /// Starts a search for the specified search term. Calling this method internal func beginSearchForTerm(term: String) { cancelSearch() if let url = iTunesAPI.createAlbumSearchURLForTerm(term) { showsSearch = true searchTerm = term searchTask = urlSession.dataTaskWithURL(url, completionHandler: processSearchResults) searchTask?.resume() } else { showsSearch = false } outlineView.reloadData() } /// Cancels the current search (if there is one). This also hides the search /// results. internal func cancelSearch() { searchTask?.cancel() searchResults.removeAll() showsSearch = false outlineView.reloadData() } /// Processes the data returned from a network request into the /// `searchResults`. private func processSearchResults(data: NSData?, response: NSURLResponse?, var error: NSError?) { searchTask = nil if let theData = data where error == nil { do { let searchResults = try iTunesAPI.parseAPIData(theData).map { SearchResult(representedAlbum: $0) } searchTerm = nil self.searchResults = searchResults } catch let theError as NSError { error = theError } } if let theError = error { searchErrorOccured(theError) } showsSearch = true outlineView.reloadData() } /// Called when an error occurs during searching. private func searchErrorOccured(error: NSError) { searchError = error } /// 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 } if !containsAlbumForSearchResult(searchResult) { let album = Album(searchResult: searchResult) albumCollection.addAlbum(album, beginLoading: true) } outlineView.reloadData() } // MARK: Saving private func saveItems(items: [AnyObject]) { let numberOfTracks = numberOfTracksInItems(items) 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 let save: (parentTrack: Track, tracks: [iTunesTrack]) -> Bool = { parentTrack, tracks in for track in tracks { if progress.cancelled { return false } parentTrack.saveToTrack(track) ++progress.completedUnitCount NSProgress.currentProgress()?.localizedAdditionalDescription = progress.localizedAdditionalDescription } return !progress.cancelled } for item in items { if let album = item as? Album { for parentTrack in album.tracks { if !save(parentTrack: parentTrack, tracks: parentTrack.associatedTracks) { return } } } else if let track = item as? Track { if !save(parentTrack: track, tracks: track.associatedTracks) { return } } else if let track = item as? iTunesTrack { if let parentTrack = parentForTrack(track) { if !save(parentTrack: parentTrack, tracks: [track]) { return } } } } } private func numberOfTracksInItems(items: [AnyObject]) -> Int { return items.reduce(0) { (count: Int, item: AnyObject) -> Int in if let album = item as? Album { return count + album.tracks.reduce(0) { $0 + $1.associatedTracks.count } } else if let track = item as? Track { return count + track.associatedTracks.count } else if let track = item as? iTunesTrack { return parentForTrack(track) == nil ? count : count + 1 } else { return count } } } private func saveArtworksForItems(items: [AnyObject]) { var albums = Set() for item in items { if let searchResult = item as? SearchResult { albums.insert(Album(searchResult: searchResult)) } else if let album = item as? Album { albums.insert(album) } else if let track = item as? Track { albums.insert(track.album) } else if let track = item as? iTunesTrack { if let parentTrack = parentForTrack(track) { albums.insert(parentTrack.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 NSThread.sleepForTimeInterval(2) for album in albums { if progress.cancelled { return } 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() 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: Notifications private func albumCollectionDidBeginLoadingTracks(notification: NSNotification) { outlineView.reloadData() } private func albumCollectionDidFinishLoadingTracks(notification: NSNotification) { outlineView.reloadData() } override internal func observeValueForKeyPath(keyPath: String?, ofObject object: AnyObject?, change: [String : AnyObject]?, context: UnsafeMutablePointer) { if context == &KVOContexts.preferencesContext { outlineView.reloadData() } } // 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.appendContentsOf(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?) { let selectedItems = self.selectedItems.filter { !($0 is SearchResult) } let progress = NSProgress(totalUnitCount: 100) let progressAlert = ProgressAlert(progress: progress) progressAlert.dismissesWhenCancelled = false progressAlert.beginSheetModalForWindow(self.view.window!) { response 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.saveItems(selectedItems) progress.resignCurrent() if progress.cancelled { progressAlert.dismissWithResponse(NSModalResponseAbort) return } if Preferences.sharedPreferences.saveArtwork { progress.becomeCurrentWithPendingUnitCount(10) self.saveArtworksForItems(selectedItems) progress.resignCurrent() if progress.cancelled { progressAlert.dismissWithResponse(NSModalResponseAbort) } } } } /// Saves the artworks of the selected items to the folder specified in the /// preferences. If there is no folder specified this method prompts the user /// to select one. @IBAction internal func saveArtworks(sender: AnyObject?) { if Preferences.sharedPreferences.artworkTarget == nil { let alert = NSAlert() alert.messageText = NSLocalizedString("There is no folder set to save artworks to.", comment: "Error message informing the user that there is no directory set in the preferences that can be used to save artworks to.") alert.informativeText = NSLocalizedString("You must select a folder to save artworks to. The folder can be changed in the preferences.", comment: "Informative text for the 'no folder to save artworks to' error.") alert.addButtonWithTitle(NSLocalizedString("Use Downloads Folder", comment: "Button title offering the user to automatically use the downloads directory instead of manually choosing a directory.")) alert.addButtonWithTitle(NSLocalizedString("Chose Folder…", comment: "Button title prompting the user to choose a folder.")) alert.addButtonWithTitle(NSLocalizedString("Cancel", comment: "Button title")) alert.alertStyle = .WarningAlertStyle alert.beginSheetModalForWindow(view.window!) { response in switch response { case NSAlertFirstButtonReturn: let downloadsFolder = NSURL.fileURLWithPath(NSFileManager.defaultManager().URLsForDirectory(.DownloadsDirectory, inDomains: .UserDomainMask)[0].filePathURL!.path!, isDirectory: true) Preferences.sharedPreferences.artworkTarget = downloadsFolder self.performSaveArtworks() case NSAlertSecondButtonReturn: let openPanel = NSOpenPanel() openPanel.canChooseDirectories = true openPanel.canChooseFiles = false openPanel.canCreateDirectories = true openPanel.prompt = NSLocalizedString("Choose…", comment: "Button title in an open dialog prompting the user to choose a directory") openPanel.beginSheetModalForWindow(self.view.window!) { response in if response == NSFileHandlingPanelOKButton { Preferences.sharedPreferences.artworkTarget = openPanel.URL!.filePathURL! self.performSaveArtworks() } } case NSAlertThirdButtonReturn: fallthrough default: return } } } else { performSaveArtworks() } } /// Actually performs the action for `saveArtworks`. private func performSaveArtworks() { let progress = NSProgress(totalUnitCount: 100) let progressAlert = ProgressAlert(progress: progress) progressAlert.dismissesWhenCancelled = false progressAlert.beginSheetModalForWindow(self.view.window!) { response in self.outlineView.reloadData() } dispatch_async(dispatch_get_global_queue(DISPATCH_QUEUE_PRIORITY_DEFAULT, 0)) { progress.becomeCurrentWithPendingUnitCount(100) self.saveArtworksForItems(self.selectedItems) progress.resignCurrent() if progress.cancelled { progressAlert.dismissWithResponse(NSModalResponseAbort) } } } /// Removes the selected items from the outline view. @IBAction internal func removeSelectedItems(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 { albumCollection.removeAlbum(album) } else if let track = item as? Track { track.associatedTracks = [] } else if let track = item as? iTunesTrack { if let parentTrack = parentForTrack(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 = albumCollection.errorForAlbum(album) { error = theError } else { return } } else { return } } else { return } presentError(error, modalForWindow: view.window!, delegate: nil, didPresentSelector: nil, contextInfo: nil) } } // MARK: - Error Handling extension MainViewController { override internal func willPresentError(error: NSError) -> NSError { let recoveryOptions = [ NSLocalizedString("OK", comment: "Button title"), NSLocalizedString("Try Again", comment: "Button title for error alerts offering the user to try again.") ] return DescriptiveError(underlyingError: error, userInfo: [NSRecoveryAttempterErrorKey: self, NSLocalizedRecoveryOptionsErrorKey: recoveryOptions]) } override internal func attemptRecoveryFromError(error: NSError, optionIndex recoveryOptionIndex: Int, delegate: AnyObject?, didRecoverSelector: Selector, contextInfo: UnsafeMutablePointer) { let didRecover = attemptRecoveryFromError(error, optionIndex: recoveryOptionIndex) delegate?.performSelector(didRecoverSelector, withObject: didRecover, withObject: contextInfo as! AnyObject) } override internal func attemptRecoveryFromError(error: NSError, optionIndex recoveryOptionIndex: Int) -> Bool { if recoveryOptionIndex == 0 { return true } if let term = searchTerm where error == searchError || error.userInfo[NSUnderlyingErrorKey] === searchError { beginSearchForTerm(term) return true } else { for album in albumCollection { let albumError = albumCollection.errorForAlbum(album) if error == albumError || error.userInfo[NSUnderlyingErrorKey] === albumError { albumCollection.beginLoadingTracksForAlbum(album) return true } } } return false } } // MARK: - User Interface Validations extension MainViewController: NSUserInterfaceValidations { internal func validateUserInterfaceItem(anItem: NSValidatedUserInterfaceItem) -> Bool { if anItem.action() == "performSave:" { return canSave() } else if anItem.action() == "saveArtworks:" { return canSaveArtworks() } else if anItem.action() == "addITunesSelection:" { return canAddITunesSelection() } else if anItem.action() == "removeSelectedItems:" { return canRemoveSelectedItems() } return false } private func canSave() -> Bool { for row in outlineView.selectedRowIndexes where sectionOfRow(row) == .Albums { let item = outlineView.itemAtRow(row) if let album = item as? Album { if !album.saved { return true } } else if let track = item as? Track { if !track.saved { return true } } else if let track = item as? iTunesTrack { if parentForTrack(track)?.saved == false { return true } } } return false } private func canSaveArtworks() -> Bool { for row in outlineView.selectedRowIndexes { if sectionOfRow(row) != .UnsortedTracks { return true } } return false } private func canAddITunesSelection() -> Bool { return iTunes.running && !(iTunes.selection.get() as! [AnyObject]).isEmpty } private func canRemoveSelectedItems() -> Bool { for row in outlineView.selectedRowIndexes { if sectionOfRow(row)! != .SearchResults { return true } } return false } } // MARK: - Outline View extension MainViewController: NSOutlineViewDataSource, NSOutlineViewDelegate { internal 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 } } internal 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 "" } } internal func outlineView(outlineView: NSOutlineView, isItemExpandable item: AnyObject) -> Bool { return self.outlineView(outlineView, numberOfChildrenOfItem: item) > 0 } internal func outlineView(outlineView: NSOutlineView, isGroupItem item: AnyObject) -> Bool { return Section.isHeaderItem(item) } internal 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) } internal 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 } } internal 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 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 = !containsAlbumForSearchResult(searchResult) view?.setupForSearchResult(searchResult, selectable: selectable) 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: albumCollection.isAlbumLoading(album), error: albumCollection.errorForAlbum(album)) view?.errorButton?.target = self view?.errorButton?.action = "showErrorDetails:" 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 } internal 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 } internal 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 albumCollection.isAlbumLoading(album) || albumCollection.errorForAlbum(album) != nil { return .None } return .Every } internal 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 = parentForTrack(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.appendContentsOf(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.appendContentsOf(draggedTracks) } outlineView.reloadData() return true } }