987 lines
42 KiB
Swift
987 lines
42 KiB
Swift
//
|
|
// 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<iTunesTrack> {
|
|
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<SearchResult>()
|
|
var selectedAlbums = Set<Album>()
|
|
var selectedTracks = Set<Track>()
|
|
var selectedITunesTracks = Set<iTunesTrack>()
|
|
|
|
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
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
dispatch_sync(dispatch_get_main_queue()) {
|
|
if Preferences.sharedPreferences.removeSavedItems {
|
|
for item in items {
|
|
if let album = item as? Album {
|
|
for track in album.tracks {
|
|
track.associatedTracks.removeAll()
|
|
}
|
|
if !Preferences.sharedPreferences.keepSavedAlbums {
|
|
self.albumCollection.removeAlbum(album)
|
|
}
|
|
} else if let track = item as? Track {
|
|
track.associatedTracks.removeAll()
|
|
} else if let track = item as? iTunesTrack {
|
|
if let parentTrack = self.parentForTrack(track) {
|
|
parentTrack.associatedTracks.removeElement(track)
|
|
}
|
|
}
|
|
}
|
|
self.outlineView.reloadData()
|
|
}
|
|
}
|
|
}
|
|
|
|
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<Album>()
|
|
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<Void>) {
|
|
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<Void>) {
|
|
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 {
|
|
if sectionOfRow(row) == .Albums {
|
|
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<iTunesTrack>()
|
|
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
|
|
}
|
|
|
|
}
|
|
|