Reorganized model for 'Albums' section
This commit is contained in:
committed by
Kim Wittenburg
parent
80f177807d
commit
5704d0c0e5
185
TagTunes/AlbumCollection.swift
Normal file
185
TagTunes/AlbumCollection.swift
Normal file
@@ -0,0 +1,185 @@
|
||||
//
|
||||
// AlbumCollection.swift
|
||||
// TagTunes
|
||||
//
|
||||
// Created by Kim Wittenburg on 08.09.15.
|
||||
// Copyright © 2015 Kim Wittenburg. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// Manages a collection of albums. Managing includes support for deferred
|
||||
/// loading of an album's tracks as well as error support.
|
||||
public class AlbumCollection: CollectionType {
|
||||
|
||||
// MARK: Types
|
||||
|
||||
private enum AlbumState {
|
||||
|
||||
case Normal
|
||||
|
||||
case Error(NSError)
|
||||
|
||||
case Loading(NSURLSessionTask)
|
||||
|
||||
}
|
||||
|
||||
// MARK: Constants
|
||||
|
||||
/// Posted when an album is added to a collection. This notification is only
|
||||
/// posted if the album collection actually changed.
|
||||
public static let AlbumAddedNotificationName = "AlbumAddedNotificationName"
|
||||
|
||||
/// Posted when an album is removed from a collection. This notification is
|
||||
/// only posted if the album collection actually changed.
|
||||
public static let AlbumRemovedNotificationName = "AlbumRemovedNotificationName"
|
||||
|
||||
/// Posted when an album collection finished loading the tracks for an album.
|
||||
/// Receiving this notification does not mean that the tracks were actually
|
||||
/// loaded successfully. It just means that the network connection
|
||||
/// terminated. Use `errorForAlbum` to determine if an error occured while
|
||||
/// the tracks have been loaded.
|
||||
///
|
||||
/// - note: Since the actual `Album` instance in the album collection may
|
||||
/// change during loading its tracks it is preferred that you use the
|
||||
/// `AlbumIndexKey` of the notification to determine which album finished
|
||||
/// loading its tracks. You can however use the `AlbumKey` as well to access
|
||||
/// the `Album` instance that is currently present in the collection at the
|
||||
/// respective index.
|
||||
public static let AlbumFinishedLoadingNotificationName = "AlbumFinishedLoadingNotificationName"
|
||||
|
||||
/// Key in the `userInfo` dictionary of a notification. The associated value
|
||||
/// is an `Int` indicating the index of the album that is affected.
|
||||
public static let AlbumIndexKey = "AlbumIndexKey"
|
||||
|
||||
/// Key in the `userInfo` dictionary of a notification. The associated value
|
||||
/// is the `Album` instance that is affected.
|
||||
public static let AlbumKey = "AlbumKey"
|
||||
|
||||
// MARK: Properties
|
||||
|
||||
/// Access the userlying array of albums.
|
||||
public private(set) var albums = [Album]()
|
||||
|
||||
private var albumStates = [Album: AlbumState]()
|
||||
|
||||
/// The URL session used to load tracks for albums.
|
||||
private let urlSession = NSURLSession(configuration: NSURLSessionConfiguration.defaultSessionConfiguration(), delegate: nil, delegateQueue: NSOperationQueue.mainQueue())
|
||||
|
||||
// MARK: Collection Type
|
||||
|
||||
public init() {}
|
||||
|
||||
public var startIndex: Int {
|
||||
return albums.startIndex
|
||||
}
|
||||
|
||||
public var endIndex: Int {
|
||||
return albums.endIndex
|
||||
}
|
||||
|
||||
public subscript (position: Int) -> Album {
|
||||
return albums[position]
|
||||
}
|
||||
|
||||
// MARK: Album Collection
|
||||
|
||||
/// Adds the specified album, if not already present, and begins to load its
|
||||
/// tracks.
|
||||
///
|
||||
/// - parameters:
|
||||
/// - album: The album to be added.
|
||||
/// - flag: Specify `false` if the album collection should not begin to
|
||||
/// load the album's tracks immediately.
|
||||
public func addAlbum(album: Album, beginLoading flag: Bool = true) {
|
||||
if !albums.contains(album) {
|
||||
albums.append(album)
|
||||
if flag {
|
||||
beginLoadingTracksForAlbum(album)
|
||||
}
|
||||
NSNotificationCenter.defaultCenter().postNotificationName(AlbumCollection.AlbumAddedNotificationName, object: self, userInfo: [AlbumCollection.AlbumIndexKey: albums.count-1])
|
||||
}
|
||||
}
|
||||
|
||||
/// Removes the specified album from the collection if it is present.
|
||||
public func removeAlbum(album: Album) {
|
||||
if let index = albums.indexOf(album) {
|
||||
removeAlbumAtIndex(index)
|
||||
}
|
||||
}
|
||||
|
||||
/// Removes the album at the specified index from the collection.
|
||||
///
|
||||
/// - requires: The specified index must be in the collection's range.
|
||||
public func removeAlbumAtIndex(index: Int) {
|
||||
let album = self[index]
|
||||
setAlbumState(nil, forAlbum: album)
|
||||
albums.removeAtIndex(index)
|
||||
NSNotificationCenter.defaultCenter().postNotificationName(AlbumCollection.AlbumRemovedNotificationName, object: self, userInfo: [AlbumCollection.AlbumIndexKey: index])
|
||||
}
|
||||
|
||||
/// Begins to load the tracks for the specified album. If there is already a
|
||||
/// request for the specified album it is cancelled. When the tracks for the
|
||||
/// specified album have been loaded or an error occured, a
|
||||
/// `AlbumFinishedLoadingNotification` is posted.
|
||||
public func beginLoadingTracksForAlbum(album: Album) {
|
||||
let url = iTunesAPI.createAlbumLookupURLForId(album.id)
|
||||
let task = urlSession.dataTaskWithURL(url) { (data, response, error) -> Void in
|
||||
var albumIndex = self.albums.indexOf(album)!
|
||||
defer {
|
||||
NSNotificationCenter.defaultCenter().postNotificationName(AlbumCollection.AlbumFinishedLoadingNotificationName, object: self, userInfo: [AlbumCollection.AlbumIndexKey: albumIndex])
|
||||
}
|
||||
guard error == nil else {
|
||||
if error!.code != NSUserCancelledError {
|
||||
self.albumStates[album] = .Error(error!)
|
||||
}
|
||||
return
|
||||
}
|
||||
do {
|
||||
let newAlbum = try iTunesAPI.parseAPIData(data!)[0]
|
||||
albumIndex = self.albums.indexOf(album)!
|
||||
self.albums.removeAtIndex(albumIndex)
|
||||
self.albums.insert(newAlbum, atIndex: albumIndex)
|
||||
self.setAlbumState(.Normal, forAlbum: album)
|
||||
} catch let error as NSError {
|
||||
self.setAlbumState(.Error(error), forAlbum: album)
|
||||
} catch _ {
|
||||
// Will never happen
|
||||
}
|
||||
}
|
||||
setAlbumState(.Loading(task), forAlbum: album)
|
||||
task.resume()
|
||||
}
|
||||
|
||||
/// Cancels the request to load the tracks for the specified album and sets
|
||||
/// the error for the album to a `NSUserCancelledError` in the
|
||||
/// `NSCocoaErrorDomain`.
|
||||
public func cancelLoadingTracksForAlbum(album: Album) {
|
||||
let error = NSError(domain: NSCocoaErrorDomain, code: NSUserCancelledError, userInfo: nil)
|
||||
setAlbumState(.Error(error), forAlbum: album)
|
||||
}
|
||||
|
||||
/// Sets the state for the specified album. If the previous state was
|
||||
/// `Loading` the associated task is cancelled.
|
||||
private func setAlbumState(state: AlbumState?, forAlbum album: Album) {
|
||||
if case let .Some(.Loading(task)) = albumStates[album] {
|
||||
task.cancel()
|
||||
}
|
||||
albumStates[album] = state
|
||||
}
|
||||
|
||||
public func isAlbumLoading(album: Album) -> Bool {
|
||||
if case .Some(.Loading) = albumStates[album] {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
public func errorForAlbum(album: Album) -> NSError? {
|
||||
if case let .Some(.Error(error)) = albumStates[album] {
|
||||
return error
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
}
|
||||
@@ -71,32 +71,35 @@ internal class MainViewController: NSViewController {
|
||||
/// 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: View Life Cycle
|
||||
|
||||
private var observerObject: NSObjectProtocol?
|
||||
|
||||
override internal func viewDidLoad() {
|
||||
super.viewDidLoad()
|
||||
NSNotificationCenter.defaultCenter().addObserverForName(AlbumCollection.AlbumFinishedLoadingNotificationName, object: albumCollection, queue: NSOperationQueue.mainQueue(), usingBlock: albumCollectionDidFinishLoadingTracks)
|
||||
outlineView.setDraggingSourceOperationMask(.Move, forLocal: true)
|
||||
outlineView.registerForDraggedTypes([OutlineViewConstants.pasteboardType])
|
||||
}
|
||||
|
||||
deinit {
|
||||
if let observer = observerObject {
|
||||
NSNotificationCenter.defaultCenter().removeObserver(observer)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: Outline View Content
|
||||
|
||||
internal private(set) var searchResults = [SearchResult]()
|
||||
|
||||
internal private(set) var albums = [Album]()
|
||||
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(albums.flatMap({ $0.tracks.flatMap { $0.associatedTracks } }))
|
||||
return Set(unsortedTracks).union(albumCollection.flatMap({ $0.tracks.flatMap { $0.associatedTracks } }))
|
||||
}
|
||||
|
||||
/// Returns all contents of the outline view.
|
||||
@@ -117,11 +120,11 @@ internal class MainViewController: NSViewController {
|
||||
} else {
|
||||
contents.append(OutlineViewConstants.Items.noResultsItem)
|
||||
}
|
||||
if !albums.isEmpty {
|
||||
if !albumCollection.isEmpty {
|
||||
contents.append(OutlineViewConstants.Items.albumsHeaderItem)
|
||||
}
|
||||
}
|
||||
contents.appendContentsOf(albums as [AnyObject])
|
||||
contents.appendContentsOf(albumCollection.albums as [AnyObject])
|
||||
if !unsortedTracks.isEmpty {
|
||||
contents.append(OutlineViewConstants.Items.unsortedTracksHeaderItem)
|
||||
contents.appendContentsOf(unsortedTracks as [AnyObject])
|
||||
@@ -159,15 +162,16 @@ internal class MainViewController: NSViewController {
|
||||
return nil
|
||||
}
|
||||
|
||||
// TODO: Can this algorithm be improved?
|
||||
/// 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) {
|
||||
if let album = item as? Album where albumCollection.contains(album) {
|
||||
return .Albums
|
||||
} else if let track = item as? Track where albums.contains(track.album) {
|
||||
} else if let track = item as? Track where albumCollection.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) {
|
||||
if let parentTrack = outlineView.parentForItem(track) as? Track where albumCollection.contains(parentTrack.album) {
|
||||
return .Albums
|
||||
} else {
|
||||
return unsortedTracks.contains(track) ? .UnsortedTracks : nil
|
||||
@@ -181,11 +185,6 @@ internal class MainViewController: NSViewController {
|
||||
}
|
||||
}
|
||||
|
||||
/// 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
|
||||
@@ -246,51 +245,24 @@ internal class MainViewController: NSViewController {
|
||||
showsSearch = false
|
||||
}
|
||||
var albumAlreadyPresent = false
|
||||
for album in albums {
|
||||
for album in albumCollection {
|
||||
if album == searchResult {
|
||||
albumAlreadyPresent = true
|
||||
}
|
||||
}
|
||||
if !albumAlreadyPresent {
|
||||
albums.append(beginLoadingTracksForSearchResult(searchResult))
|
||||
let album = Album(searchResult: searchResult)
|
||||
albumCollection.addAlbum(album, beginLoading: true)
|
||||
}
|
||||
outlineView.reloadData()
|
||||
}
|
||||
|
||||
private func albumCollectionDidFinishLoadingTracks(notification: NSNotification) {
|
||||
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 where error == nil {
|
||||
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
|
||||
trackErrors[album] = NSError(domain: NSCocoaErrorDomain, code: NSUserCancelledError, userInfo: 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
|
||||
@@ -434,9 +406,7 @@ internal class MainViewController: NSViewController {
|
||||
for (row, item) in items {
|
||||
if sectionOfRow(row)! != .SearchResults {
|
||||
if let album = item as? Album {
|
||||
cancelLoadingTracksForAlbum(album)
|
||||
trackErrors[album] = nil
|
||||
albums.removeElement(album)
|
||||
albumCollection.removeAlbum(album)
|
||||
} else if let track = item as? Track {
|
||||
track.associatedTracks = []
|
||||
} else if let track = item as? iTunesTrack {
|
||||
@@ -465,7 +435,7 @@ internal class MainViewController: NSViewController {
|
||||
if let theError = item as? NSError {
|
||||
error = theError
|
||||
} else if let album = item as? Album {
|
||||
if let theError = trackErrors[album] {
|
||||
if let theError = albumCollection.errorForAlbum(album) {
|
||||
error = theError
|
||||
} else {
|
||||
return
|
||||
@@ -506,8 +476,9 @@ extension MainViewController {
|
||||
if error == searchError {
|
||||
|
||||
} else {
|
||||
for (album, trackError) in trackErrors {
|
||||
if error == trackError {
|
||||
for album in albumCollection {
|
||||
let albumError = albumCollection.errorForAlbum(album)
|
||||
if error == albumError {
|
||||
|
||||
}
|
||||
}
|
||||
@@ -671,7 +642,7 @@ extension MainViewController: NSOutlineViewDataSource, NSOutlineViewDelegate {
|
||||
view = AlbumTableCellView()
|
||||
view?.identifier = OutlineViewConstants.ViewIdentifiers.albumTableCellViewIdentifier
|
||||
}
|
||||
view?.setupForAlbum(album, loading: isAlbumLoading(album), error: trackErrors[album])
|
||||
view?.setupForAlbum(album, loading: albumCollection.isAlbumLoading(album), error: albumCollection.errorForAlbum(album))
|
||||
view?.errorButton?.target = self
|
||||
view?.errorButton?.action = "showErrorDetails:"
|
||||
return view
|
||||
@@ -684,7 +655,7 @@ extension MainViewController: NSOutlineViewDataSource, NSOutlineViewDelegate {
|
||||
}
|
||||
view?.button.target = self
|
||||
view?.button.action = "selectSearchResult:"
|
||||
let selectable = albums.filter { $0.id == searchResult.id }.isEmpty
|
||||
let selectable = albumCollection.filter { $0.id == searchResult.id }.isEmpty
|
||||
view?.setupForSearchResult(searchResult, selectable: selectable)
|
||||
return view
|
||||
}
|
||||
@@ -751,7 +722,7 @@ extension MainViewController: NSOutlineViewDataSource, NSOutlineViewDelegate {
|
||||
if sectionOfRow(row) == .SearchResults {
|
||||
return .None
|
||||
}
|
||||
if let album = item as? Album where isAlbumLoading(album) || trackErrors[album] != nil {
|
||||
if let album = item as? Album where albumCollection.isAlbumLoading(album) || albumCollection.errorForAlbum(album) != nil {
|
||||
return .None
|
||||
}
|
||||
return .Every
|
||||
|
||||
Reference in New Issue
Block a user