From 5704d0c0e55d8ec2bb628b2e101bfea33805831d Mon Sep 17 00:00:00 2001 From: Kim Wittenburg Date: Fri, 11 Sep 2015 16:38:17 +0200 Subject: [PATCH] Reorganized model for 'Albums' section --- TagTunes/AlbumCollection.swift | 185 ++++++++++++++++++++++++++++++ TagTunes/MainViewController.swift | 93 ++++++--------- 2 files changed, 217 insertions(+), 61 deletions(-) create mode 100644 TagTunes/AlbumCollection.swift diff --git a/TagTunes/AlbumCollection.swift b/TagTunes/AlbumCollection.swift new file mode 100644 index 0000000..76f0b4e --- /dev/null +++ b/TagTunes/AlbumCollection.swift @@ -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 + } + +} diff --git a/TagTunes/MainViewController.swift b/TagTunes/MainViewController.swift index 19f2e83..e69eb6e 100644 --- a/TagTunes/MainViewController.swift +++ b/TagTunes/MainViewController.swift @@ -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 { - 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