~not/sonar

92cb85dbe6bc0c953f3c25c9fba64347610b542c — b123400 10 months ago 54355b7 + c09d908
Merge branch 'playlist'
M Sonar.xcodeproj/project.pbxproj => Sonar.xcodeproj/project.pbxproj +6 -0
@@ 22,6 22,7 @@
		CF3EA281273369E70010BBE4 /* PlayerHeaderMusicImageView.m in Sources */ = {isa = PBXBuildFile; fileRef = CF3EA280273369E70010BBE4 /* PlayerHeaderMusicImageView.m */; };
		CF4F0ECC2908FE4600776119 /* BaseModel.m in Sources */ = {isa = PBXBuildFile; fileRef = CF4F0ECB2908FE4600776119 /* BaseModel.m */; };
		CF53B1E6273577AD001DF9B9 /* Utilities.m in Sources */ = {isa = PBXBuildFile; fileRef = CF53B1E5273577AD001DF9B9 /* Utilities.m */; };
		CF5849B52A41891A00B494C7 /* BRSonicAPIResponsePlaylist.m in Sources */ = {isa = PBXBuildFile; fileRef = CF5849B42A41891A00B494C7 /* BRSonicAPIResponsePlaylist.m */; };
		CF8FF5302735370E0079C5A9 /* PlayerProgressSliderCell.m in Sources */ = {isa = PBXBuildFile; fileRef = CF8FF52F2735370E0079C5A9 /* PlayerProgressSliderCell.m */; };
		CFB55EA32781BD2E004F9C47 /* BRSonicAPIResponsePlaylists.m in Sources */ = {isa = PBXBuildFile; fileRef = CFB55EA22781BD2E004F9C47 /* BRSonicAPIResponsePlaylists.m */; };
		CFB55EA62781BDE3004F9C47 /* BRSonicAPIPlaylist.m in Sources */ = {isa = PBXBuildFile; fileRef = CFB55EA52781BDE3004F9C47 /* BRSonicAPIPlaylist.m */; };


@@ 81,6 82,8 @@
		CF4F0ECB2908FE4600776119 /* BaseModel.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = BaseModel.m; sourceTree = "<group>"; };
		CF53B1E4273577AD001DF9B9 /* Utilities.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = Utilities.h; sourceTree = "<group>"; };
		CF53B1E5273577AD001DF9B9 /* Utilities.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = Utilities.m; sourceTree = "<group>"; };
		CF5849B32A41891A00B494C7 /* BRSonicAPIResponsePlaylist.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = BRSonicAPIResponsePlaylist.h; sourceTree = "<group>"; };
		CF5849B42A41891A00B494C7 /* BRSonicAPIResponsePlaylist.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = BRSonicAPIResponsePlaylist.m; sourceTree = "<group>"; };
		CF8FF52E2735370E0079C5A9 /* PlayerProgressSliderCell.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = PlayerProgressSliderCell.h; sourceTree = "<group>"; };
		CF8FF52F2735370E0079C5A9 /* PlayerProgressSliderCell.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = PlayerProgressSliderCell.m; sourceTree = "<group>"; };
		CFB55EA12781BD2E004F9C47 /* BRSonicAPIResponsePlaylists.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = BRSonicAPIResponsePlaylists.h; sourceTree = "<group>"; };


@@ 293,6 296,8 @@
				CFB55EA22781BD2E004F9C47 /* BRSonicAPIResponsePlaylists.m */,
				CFB55EA72781C39B004F9C47 /* BRSonicAPIResponsePlaylistSongs.h */,
				CFB55EA82781C39B004F9C47 /* BRSonicAPIResponsePlaylistSongs.m */,
				CF5849B32A41891A00B494C7 /* BRSonicAPIResponsePlaylist.h */,
				CF5849B42A41891A00B494C7 /* BRSonicAPIResponsePlaylist.m */,
				CFE160E4279C386E00287C14 /* BRSonicAPIResponseStarred.h */,
				CFE160E5279C386E00287C14 /* BRSonicAPIResponseStarred.m */,
				CFD1C4D6273E958B009ED1CD /* BRSonicAPIArtist.h */,


@@ 447,6 452,7 @@
				CF252E89273E3D0700A0D26C /* PreferencesManager.m in Sources */,
				CF16C2B4273EB8BD00FE3454 /* MediaLoading.m in Sources */,
				CFEC3AF828EA7FBB004A4C6A /* PlayerViewModeState.m in Sources */,
				CF5849B52A41891A00B494C7 /* BRSonicAPIResponsePlaylist.m in Sources */,
				CF252E90273E61BE00A0D26C /* MediaDB.m in Sources */,
				CF3EA281273369E70010BBE4 /* PlayerHeaderMusicImageView.m in Sources */,
				CFD908CB2732CC2800FEFD53 /* PlayerHeaderView.m in Sources */,

M Sonar/API Client/BRSonicAPIClient.h => Sonar/API Client/BRSonicAPIClient.h +19 -0
@@ 11,6 11,7 @@
#import "BRSonicAPIResponseSongs.h"
#import "BRSonicAPIResponseAlbums.h"
#import "BRSonicAPIResponsePlaylists.h"
#import "BRSonicAPIResponsePlaylist.h"
#import "BRSonicAPIResponsePlaylistSongs.h"
#import "BRSonicAPIResponseStarred.h"



@@ 60,6 61,24 @@ NS_ASSUME_NONNULL_BEGIN
        withCredential:(BRSonicAPICredential *)credential
            completion:(void (^) (NSError * _Nullable error))callback;

- (void)addSong:(BRSonicAPISong *)song
     toPlaylist:(BRSonicAPIPlaylist *)playlist
 withCredential:(BRSonicAPICredential *)credential
     completion:(void (^) (NSError * _Nullable error))callback;

- (void)removeSong:(BRSonicAPISong *)song
      fromPlaylist:(BRSonicAPIPlaylist *)playlist
    withCredential:(BRSonicAPICredential *)credential
        completion:(void (^) (NSError * _Nullable error))callback;

- (void)createPlaylistOfName:(NSString *)name
              withCredential:(BRSonicAPICredential *)credential
                  completion:(void (^) (NSError * _Nullable error, BRSonicAPIPlaylist * _Nullable playlist))callback;

- (void)deletePlaylist:(BRSonicAPIPlaylist *)playlist
              withCredential:(BRSonicAPICredential *)credential
                  completion:(void (^) (NSError * _Nullable error))callback;

- (NSURL *)urlForCoverArtId:(NSString *)coverArtId withCredential:(BRSonicAPICredential *)credential;

- (NSURL *)urlForStreaming:(BRSonicAPISong *)song withCredential:(BRSonicAPICredential *)credential;

M Sonar/API Client/BRSonicAPIClient.m => Sonar/API Client/BRSonicAPIClient.m +121 -0
@@ 437,6 437,127 @@
    }] resume];
}

- (void)addSong:(BRSonicAPISong *)song
     toPlaylist:(BRSonicAPIPlaylist *)playlist
 withCredential:(BRSonicAPICredential *)credential
     completion:(void (^) (NSError * _Nullable error))callback {
    NSURLComponents *components = [NSURLComponents componentsWithURL:credential.host resolvingAgainstBaseURL:NO];
    [components setPath:@"/rest/updatePlaylist"];

    [components setQueryItems:
         [[credential baseUrlQueryItems] arrayByAddingObjectsFromArray:@[
            [NSURLQueryItem queryItemWithName:@"playlistId" value:playlist.id],
            [NSURLQueryItem queryItemWithName:@"songIdToAdd" value:song.id],
         ]]
    ];
    
    [[[NSURLSession sharedSession] dataTaskWithURL:components.URL
                                 completionHandler:^(NSData * _Nullable data, NSURLResponse * _Nullable response, NSError * _Nullable error) {
        if (error) {
            callback(error);
            return;
        }
        callback(nil);
    }] resume];
}

- (void)removeSong:(BRSonicAPISong *)song
      fromPlaylist:(BRSonicAPIPlaylist *)playlist
    withCredential:(BRSonicAPICredential *)credential
        completion:(void (^) (NSError * _Nullable error))callback {
    
    [self getPlaylistEntriesOfPlaylist:playlist
                        withCredential:credential
                            completion:^(NSError * _Nullable error, BRSonicAPIResponsePlaylistSongs * _Nullable response) {
        if (error != nil) {
            callback(error);
            return;
        }
        NSUInteger index = [response.playlistEntries indexOfObjectPassingTest:^BOOL(BRSonicAPISong * _Nonnull obj, NSUInteger idx, BOOL * _Nonnull stop) {
            if ([obj.id isEqualTo:song.id]) {
                *stop = YES;
                return YES;
            }
            return NO;
        }];
        if (index == NSNotFound) {
            NSError *error = [NSError errorWithDomain:@"net.b123400.sonic"
                                                 code:0
                                             userInfo:@{
                NSLocalizedDescriptionKey: NSLocalizedString(@"Item not found in playlist", @"Error when deletinng item from playlist")
            }];
            callback(error);
            return;
        }
        NSURLComponents *components = [NSURLComponents componentsWithURL:credential.host resolvingAgainstBaseURL:NO];
        [components setPath:@"/rest/updatePlaylist"];

        [components setQueryItems:
             [[credential baseUrlQueryItems] arrayByAddingObjectsFromArray:@[
                [NSURLQueryItem queryItemWithName:@"playlistId" value:playlist.id],
                [NSURLQueryItem queryItemWithName:@"songIndexToRemove" value:[NSString stringWithFormat:@"%lu", (unsigned long)index]],
             ]]
        ];
        
        [[[NSURLSession sharedSession] dataTaskWithURL:components.URL
                                     completionHandler:^(NSData * _Nullable data, NSURLResponse * _Nullable response, NSError * _Nullable error) {
            if (error) {
                callback(error);
                return;
            }
            callback(nil);
        }] resume];
    }];
}

- (void)createPlaylistOfName:(NSString *)name
              withCredential:(BRSonicAPICredential *)credential
                  completion:(void (^) (NSError * _Nullable error, BRSonicAPIPlaylist * _Nullable playlist))callback {
    NSURLComponents *components = [NSURLComponents componentsWithURL:credential.host resolvingAgainstBaseURL:NO];
    [components setPath:@"/rest/createPlaylist"];

    [components setQueryItems:
         [[credential baseUrlQueryItems] arrayByAddingObject:[NSURLQueryItem queryItemWithName:@"name" value:name]]
    ];
    
    [[[NSURLSession sharedSession] dataTaskWithURL:components.URL
                                 completionHandler:^(NSData * _Nullable data, NSURLResponse * _Nullable response, NSError * _Nullable error) {
        if (error) {
            callback(error, nil);
            return;
        }
        NSError *rError = nil;
        BRSonicAPIResponsePlaylist *xml = [[BRSonicAPIResponsePlaylist alloc] initWithData:data
                                                                                   options:NSXMLNodeOptionsNone
                                                                                     error:&rError];
        if (rError != nil) {
            callback(error, nil);
            return;
        }
        callback(nil, xml.playlist);
    }] resume];
}

- (void)deletePlaylist:(BRSonicAPIPlaylist *)playlist
              withCredential:(BRSonicAPICredential *)credential
            completion:(void (^) (NSError * _Nullable error))callback {
    NSURLComponents *components = [NSURLComponents componentsWithURL:credential.host resolvingAgainstBaseURL:NO];
    [components setPath:@"/rest/deletePlaylist"];

    [components setQueryItems:
         [[credential baseUrlQueryItems] arrayByAddingObject:[NSURLQueryItem queryItemWithName:@"id" value:playlist.id]]
    ];
    
    [[[NSURLSession sharedSession] dataTaskWithURL:components.URL
                                 completionHandler:^(NSData * _Nullable data, NSURLResponse * _Nullable response, NSError * _Nullable error) {
        if (error) {
            callback(error);
            return;
        }
        callback(nil);
    }] resume];
}

#pragma mark - URLs

- (NSURL *)urlForCoverArtId:(NSString *)coverArtId withCredential:(BRSonicAPICredential *)credential {

A Sonar/API Client/BRSonicAPIResponsePlaylist.h => Sonar/API Client/BRSonicAPIResponsePlaylist.h +19 -0
@@ 0,0 1,19 @@
//
//  BRSonicAPIResponsePlaylist.h
//  Sonar
//
//  Created by b123400 on 2023/06/20.
//

#import "BRSonicAPIResponse.h"
#import "BRSonicAPIPlaylist.h"

NS_ASSUME_NONNULL_BEGIN

@interface BRSonicAPIResponsePlaylist : BRSonicAPIResponse

- (BRSonicAPIPlaylist *)playlist;

@end

NS_ASSUME_NONNULL_END

A Sonar/API Client/BRSonicAPIResponsePlaylist.m => Sonar/API Client/BRSonicAPIResponsePlaylist.m +17 -0
@@ 0,0 1,17 @@
//
//  BRSonicAPIResponsePlaylist.m
//  Sonar
//
//  Created by b123400 on 2023/06/20.
//

#import "BRSonicAPIResponsePlaylist.h"

@implementation BRSonicAPIResponsePlaylist

- (BRSonicAPIPlaylist *)playlist {
    NSXMLElement *playlistElement = [[self.rootElement elementsForName:@"playlist"] firstObject];
    return [[BRSonicAPIPlaylist alloc] initWithXMLElement:playlistElement];
}

@end

M Sonar/Base.lproj/MainMenu.xib => Sonar/Base.lproj/MainMenu.xib +7 -0
@@ 414,6 414,13 @@
                                    </items>
                                </menu>
                            </menuItem>
                            <menuItem isSeparatorItem="YES" id="N3t-S8-8i9"/>
                            <menuItem title="New Playlist..." id="Av7-e5-Sb5">
                                <modifierMask key="keyEquivalentModifierMask"/>
                                <connections>
                                    <action selector="menubarNewPlaylistClicked:" target="-1" id="Y20-Wt-wkQ"/>
                                </connections>
                            </menuItem>
                        </items>
                    </menu>
                </menuItem>

M Sonar/MediaDB/MediaDB.h => Sonar/MediaDB/MediaDB.h +14 -0
@@ 64,6 64,20 @@ NS_ASSUME_NONNULL_BEGIN
- (void)unstarItem:(BaseModel *)item
        completion:(void (^) (NSError * _Nullable error, id updatedItem))callback;

- (void)addSong:(Song *)song
     toPlaylist:(Playlist *)playlist
     completion:(void (^) (NSError * _Nullable error, Playlist *updatedPlaylist))callback;

- (void)removeSong:(Song *)song
      fromPlaylist:(Playlist *)playlist
        completion:(void (^) (NSError * _Nullable error, Playlist *updatedPlaylist))callback;

- (void)createPlaylistWithName:(NSString *)name
                    completion:(void (^) (NSError * _Nullable error, Playlist *playlist))callback;

- (void)deletePlaylist:(Playlist *)playlist
                    completion:(void (^) (NSError * _Nullable error))callback;

@end

NS_ASSUME_NONNULL_END

M Sonar/MediaDB/MediaDB.m => Sonar/MediaDB/MediaDB.m +120 -16
@@ 52,6 52,7 @@
    // Call once for cache before we get results from API
    callback(nil, cached);
    if (!fetch) return;
    typeof(self) __weak weakSelf = self;
    [[BRSonicAPIClient shared] getArtistsWithCredential:c
                                             completion:^(NSError * _Nullable error, BRSonicAPIResponseArtists * _Nonnull response) {
        if (error) {


@@ 63,7 64,7 @@
            for (BRSonicAPIArtist *a in response.artists) {
                dict[a.id] = a;
            }
            self.artistById = dict;
            weakSelf.artistById = dict;
            NSArray<Artist*> *newArtists = self.localArtists;
            if (![cached isEqualTo:newArtists]) {
                callback(nil, newArtists);


@@ 88,6 89,7 @@
    // Call once for cache before we get results from API
    callback(nil, cached);
    if (!fetch) return;
    typeof(self) __weak weakSelf = self;
    [[BRSonicAPIClient shared] getAlbumsOfArtist:a.apiArtist
                                 withCredentials:c
                                      completion:^(NSError * _Nullable error, BRSonicAPIResponseAlbums * _Nullable response) {


@@ 97,7 99,7 @@
        }
        if (response) {
            for (BRSonicAPIAlbum *a in response.albums) {
                self.albumById[a.id] = a;
                weakSelf.albumById[a.id] = a;
            }
            NSArray<Album*> *newAlbums = [self localAlbumsOfArtist:a.apiArtist];
            if (![cached isEqualTo:newAlbums]) {


@@ 122,6 124,7 @@
    // Call once for cache before we get results from API
    callback(nil, cached);
    if (!fetch) return;
    typeof(self) __weak weakSelf = self;
    [[BRSonicAPIClient shared] getSongsOfAlbum:a.apiAlbum
                               withCredentials:c
                                    completion:^(NSError * _Nullable error, BRSonicAPIResponseSongs * _Nullable response) {


@@ 131,7 134,7 @@
        }
        if (response) {
            for (BRSonicAPISong *s in response.songs) {
                self.songById[s.id] = s;
                weakSelf.songById[s.id] = s;
            }
            NSArray<Song*> *newSongs = [self localSongsOfAlbum:a.apiAlbum];
            if (![cached isEqualTo:newSongs]) {


@@ 154,6 157,7 @@
    // Call once for cache before we get results from API
    callback(nil, cached);
    if (!fetch) return;
    typeof(self) __weak weakSelf = self;
    [[BRSonicAPIClient shared] getPlaylistsWithCredential:c
                                               completion:^(NSError * _Nullable error, BRSonicAPIResponsePlaylists * _Nullable response) {
        if (error) {


@@ 162,7 166,7 @@
        }
        if (response) {
            for (BRSonicAPIPlaylist *playlist in response.playlists) {
                self.playlistById[playlist.id] = playlist;
                weakSelf.playlistById[playlist.id] = playlist;
            }
            NSArray<Playlist*> *newPlaylists = self.localPlaylists;
            if (![cached isEqualTo:newPlaylists]) {


@@ 188,6 192,7 @@
    // Call once for cache before we get results from API
    callback(nil, cached);
    if (!fetch) return;
    typeof(self) __weak weakSelf = self;
    [[BRSonicAPIClient shared] getPlaylistEntriesOfPlaylist:a.apiPlaylist
                                               withCredential:c
                                                   completion:^(NSError * _Nullable error, BRSonicAPIResponsePlaylistSongs * _Nullable response) {


@@ 198,7 203,7 @@
        if (response) {
            NSMutableArray<BRSonicAPISong*> *songs = [NSMutableArray array];
            for (BRSonicAPISong *song in response.playlistEntries) {
                self.songById[song.id] = song;
                weakSelf.songById[song.id] = song;
                [songs addObject:song];
            }
            NSArray *songIds = [songs valueForKeyPath:@"id"];


@@ 223,6 228,7 @@
    // Call once for cache before we get results from API
    callback(nil, cached);
    if (!fetch) return;
    typeof(self) __weak weakSelf = self;
    [[BRSonicAPIClient shared] getAlbumsWithCredential:c
                                            completion:^(NSError * _Nullable error, NSArray<BRSonicAPIAlbum *> * _Nullable albums) {
        if (error) {


@@ 231,7 237,7 @@
        }
        if (albums) {
            for (BRSonicAPIAlbum *album in albums) {
                self.albumById[album.id] = album;
                weakSelf.albumById[album.id] = album;
            }
            NSArray<Album*> *newAlbums = [self localAlbums];
            if (![cached isEqualTo:newAlbums]) {


@@ 253,6 259,7 @@
    // Call once for cache before we get results from API
    callback(nil, cached);
    if (!fetch) return;
    typeof(self) __weak weakSelf = self;
    [[BRSonicAPIClient shared] getStarredsWithCredential:c
                                              completion:^(NSError * _Nullable error, BRSonicAPIResponseStarred * _Nonnull response) {
        if (error) {


@@ 262,11 269,11 @@
        if (response) {
            for (id item in response.items) {
                if ([item isKindOfClass:[BRSonicAPISong class]]) {
                    self.songById[[item id]] = item;
                    weakSelf.songById[[item id]] = item;
                } else if ([item isKindOfClass:[BRSonicAPIAlbum class]]) {
                    self.albumById[[item id]] = item;
                    weakSelf.albumById[[item id]] = item;
                } else if ([item isKindOfClass:[BRSonicAPIArtist class]]) {
                    self.artistById[[item id]] = item;
                    weakSelf.artistById[[item id]] = item;
                }
            }
            NSArray *newItems = [self localStarredItems];


@@ 310,6 317,99 @@
    }];
}

- (void)addSong:(Song *)song
     toPlaylist:(Playlist *)playlist
     completion:(void (^) (NSError * _Nullable error, Playlist *updatedPlaylist))callback {
    BRSonicAPICredential *c = [[PreferencesManager shared] credential];
    if (!c) return;
    NSArray<NSString *> *original = self.songIdsByPlaylistId[playlist.id];
    NSArray<NSString *> *newIdsArray = [original arrayByAddingObject:song.id];
    self.songIdsByPlaylistId[playlist.id] = newIdsArray;
    NSArray<Song*> *originalSongs = playlist.songs;
    playlist.songs = [playlist.songs arrayByAddingObject:song];
    callback(nil, playlist);
    typeof(self) __weak weakSelf = self;
    [[BRSonicAPIClient shared] addSong:song.apiSong
                            toPlaylist:playlist.apiPlaylist
                        withCredential:c
                            completion:^(NSError * _Nullable error) {
        if (error == nil) {
            return;
        }
        weakSelf.songIdsByPlaylistId[playlist.id] = original;
        playlist.songs = originalSongs;
        callback(error, playlist);
    }];
}

- (void)removeSong:(Song *)song
      fromPlaylist:(Playlist *)playlist
        completion:(void (^) (NSError * _Nullable error, Playlist *updatedPlaylist))callback {
    BRSonicAPICredential *c = [[PreferencesManager shared] credential];
    if (!c) return;
    NSArray<NSString *> *original = self.songIdsByPlaylistId[playlist.id];
    NSUInteger index = [original indexOfObject:song.id];
    if (index == NSNotFound) {
        callback([NSError errorWithDomain:@"net.b123400.sonar" code:0 userInfo:@{
            NSLocalizedDescriptionKey: NSLocalizedString(@"Song not found in playlist", @"Error when removing song from playlist"),
        }], nil);
        return;
    }
    NSMutableArray<NSString *> *newIdsArray = [original mutableCopy];
    [newIdsArray removeObjectAtIndex:index];
    self.songIdsByPlaylistId[playlist.id] = newIdsArray;
    NSArray<Song*> *originalSongs = playlist.songs;
    NSMutableArray<Song*> *newSongs = [playlist.songs mutableCopy];
    NSUInteger theIndex = [newSongs indexOfObject:song];
    [newSongs removeObject:song];
    playlist.songs = newSongs;
    callback(nil, playlist);
    typeof(self) __weak weakSelf = self;
    [[BRSonicAPIClient shared] removeSong:song.apiSong
                             fromPlaylist:playlist.apiPlaylist
                           withCredential:c
                               completion:^(NSError * _Nullable error) {
        if (error == nil) {
            return;
        }
        weakSelf.songIdsByPlaylistId[playlist.id] = original;
        playlist.songs = originalSongs;
        callback(error, playlist);
    }];
}

- (void)createPlaylistWithName:(NSString *)name
                    completion:(void (^) (NSError * _Nullable error, Playlist *playlist))callback {
    BRSonicAPICredential *c = [[PreferencesManager shared] credential];
    if (!c) return;
    [[BRSonicAPIClient shared] createPlaylistOfName:name
                                     withCredential:c
                                         completion:^(NSError * _Nullable error, BRSonicAPIPlaylist * _Nullable playlist) {
        if (error) {
            callback(error, nil);
            return;
        }
        self.playlistById[playlist.id] = playlist;
        callback(nil, [self localPlaylistWithId:playlist.id]);
    }];
}

- (void)deletePlaylist:(Playlist *)playlist
            completion:(void (^) (NSError * _Nullable error))callback {
    BRSonicAPICredential *c = [[PreferencesManager shared] credential];
    if (!c) return;
    typeof(self) __weak weakSelf = self;
    [[BRSonicAPIClient shared] deletePlaylist:playlist.apiPlaylist
                               withCredential:c
                                   completion:^(NSError * _Nullable error) {
        if (!error) {
            [weakSelf.playlistById removeObjectForKey:playlist.id];
            [weakSelf.songIdsByPlaylistId removeObjectForKey:playlist.id];
        }
        callback(error);
    }];
}

#pragma mark: - Local

- (NSArray<Artist*> *)localArtists {


@@ 324,19 424,23 @@
}

- (NSArray<Playlist*>*)localPlaylists {
    BRSonicAPICredential *c = [[PreferencesManager shared] credential];
    NSMutableArray<Playlist *> *arr = [NSMutableArray array];
    for (NSString *key in self.playlistById) {
        BRSonicAPIPlaylist *a = self.playlistById[key];
        Playlist *playlist = [[Playlist alloc] initWithAPIResponse:a
                                                     coverArtURL:[[BRSonicAPIClient shared] urlForCoverArtId:a.coverArt
                                                                                              withCredential:c]];
        playlist.songs = [self localSongsOfPlaylist:a];
        [arr addObject:playlist];
        [arr addObject:[self localPlaylistWithId:key]];
    }
    return arr;
}

- (Playlist*)localPlaylistWithId:(NSString *)playlistId {
    BRSonicAPICredential *c = [[PreferencesManager shared] credential];
    BRSonicAPIPlaylist *a = self.playlistById[playlistId];
    Playlist *playlist = [[Playlist alloc] initWithAPIResponse:a
                                                 coverArtURL:[[BRSonicAPIClient shared] urlForCoverArtId:a.coverArt
                                                                                          withCredential:c]];
    playlist.songs = [self localSongsOfPlaylist:a];
    return playlist;
}

- (NSArray<Album*>*)localAlbums {
    BRSonicAPICredential *c = [[PreferencesManager shared] credential];
    NSMutableArray<Album *> *arr = [NSMutableArray array];

M Sonar/Player/PlayerWindowController.m => Sonar/Player/PlayerWindowController.m +156 -6
@@ 60,6 60,7 @@ typedef enum : NSUInteger {

@property (strong) IBOutlet NSMenu *columnsMenu;
@property (strong) IBOutlet NSMenu *outlineViewMenu;
@property (weak) IBOutlet NSMenu *outlineViewPlaylistMenu;

@property (nonatomic, strong) NSMutableArray<BaseModel *> *outlineViewItems;



@@ 270,6 271,47 @@ typedef enum : NSUInteger {
    self.viewMode = PlayerWindowControllerViewModeAlbums;
}

- (IBAction)menubarNewPlaylistClicked:(id)sender {
    NSAlert *alert = [[NSAlert alloc] init];
    [alert setInformativeText:NSLocalizedString(@"Enter playlist name",@"Create playlist dialog")];
    [alert setMessageText:NSLocalizedString(@"Create playlist", @"Create playlist dialog")];
    [alert addButtonWithTitle:@"OK"];
    [alert addButtonWithTitle:@"Cancel"];
    NSTextField *textField = [[NSTextField alloc] initWithFrame:NSMakeRect(0, 0, 300, 24)];
    [textField setPlaceholderString:NSLocalizedString(@"Playlist name", @"")];
    [alert setAccessoryView:textField];
    typeof(self) __weak weakSelf = self;
    [alert beginSheetModalForWindow:self.window
                  completionHandler:^(NSModalResponse returnCode) {
        if (returnCode == NSAlertSecondButtonReturn || ![[textField stringValue] length]) return;
        NSLog(@"Create new playlist: %@", [textField stringValue]);
        [[MediaDB shared] createPlaylistWithName:[textField stringValue]
                                      completion:^(NSError * _Nullable error, Playlist * _Nonnull playlist) {
            if (error) {
                dispatch_async(dispatch_get_main_queue(), ^{
                    [weakSelf displayError:error];
                });
            }
            if (playlist) {
                [[MediaDB shared] listPlaylistsWithCompletion:^(NSError * _Nullable error, NSArray<Playlist *> * _Nullable playlists) {
                    dispatch_async(dispatch_get_main_queue(), ^{
                        [weakSelf storeCurrentViewModeState];
                        [weakSelf sortAndSetOutlineViewItems:playlists];
                        if (error) {
                            [weakSelf displayError:error];
                            return;
                        }
                        [weakSelf.outlineView reloadData];
                        [weakSelf restoreCurrentViewModeState];
                    });
                }];
            }
        }];
    }];
    [textField becomeFirstResponder];
}


#pragma mark - Mode switches

- (void)setViewMode:(PlayerWindowControllerViewMode)newMode {


@@ 979,18 1021,26 @@ typedef enum : NSUInteger {
    } else if (menu == self.outlineViewMenu) {
        NSString *menuItemId = [item identifier];
        NSInteger clickedRow = [self.outlineView clickedRow];
        if (clickedRow < 0) return NO;
        if (clickedRow < 0) {
            [item setEnabled:NO];
            return YES;
        }
        [item setEnabled:YES];

        id clickedItem = [self.outlineView itemAtRow:clickedRow];
        if ([menuItemId isEqualTo:@"star"]) {
            if ([clickedItem isKindOfClass:[Song class]] || [clickedItem isKindOfClass:[Album class]] || [clickedItem isKindOfClass:[Artist class]]) {
                [item setEnabled:YES];
                [item setHidden:NO];
                [item setState:[(Song*)clickedItem starred] == nil ? NSControlStateValueOff : NSControlStateValueOn];
                [item setTitle:[(Song*)clickedItem starred] == nil ? NSLocalizedString(@"Star", @"menu") : NSLocalizedString(@"Unstar", @"menu")];
            } else {
                [item setTitle:NSLocalizedString(@"Star", @"menu")];
                [item setState:NSControlStateValueOff];
                [item setEnabled:NO];
                [item setHidden:YES];
            }
        } else if ([menuItemId isEqualTo:@"removeFromPlaylist"]) {
            [item setHidden:self.viewMode != PlayerWindowControllerViewModePlaylists || ![clickedItem isKindOfClass:[Song class]]];
        } else if ([menuItemId isEqualTo:@"addToPlaylist"]) {
            [item setHidden:![clickedItem isKindOfClass:[Song class]]];
        } else if ([menuItemId isEqualTo:@"deletePlaylist"]) {
            [item setHidden:![clickedItem isKindOfClass:[Playlist class]]];
        }
        return YES;
    }


@@ 1026,7 1076,107 @@ typedef enum : NSUInteger {
    [self toggleItemStar:item];
}

- (IBAction)outlineViewMenuAddToPlaylistClicked:(NSMenuItem *)sender {
    Playlist *playlist = [sender representedObject];
    NSInteger clickedRow = [self.outlineView clickedRow];
    if (clickedRow == -1) return;
    id item = [self.outlineView itemAtRow:clickedRow];
    if (![item isKindOfClass:[Song class]]) return;
    typeof(self) __weak weakSelf = self;
    [[MediaDB shared] addSong:item
                   toPlaylist:playlist
                   completion:^(NSError * _Nullable error, Playlist * _Nonnull updatedPlaylist) {
        dispatch_async(dispatch_get_main_queue(), ^{
            if (error) {
                [weakSelf displayError:error];
            }
            if (updatedPlaylist) {
                // TODO: update playlist in outlineViewItems
                [weakSelf.outlineView reloadData];
            }
        });
    }];
}
- (IBAction)outlineViewMenuRemoveFromPlaylistClicked:(id)sender {
    NSInteger clickedRow = [self.outlineView clickedRow];
    if (clickedRow == -1) return;
    id item = [self.outlineView itemAtRow:clickedRow];
    if (![item isKindOfClass:[Song class]]) return;
    id parent = [self.outlineView parentForItem:item];
    if (![parent isKindOfClass:[Playlist class]]) return;
    typeof(self) __weak weakSelf = self;
    [[MediaDB shared] removeSong:item
                    fromPlaylist:parent
                      completion:^(NSError * _Nullable error, Playlist * _Nonnull updatedPlaylist) {
        dispatch_async(dispatch_get_main_queue(), ^{
            if (error) {
                [weakSelf displayError:error];
            }
            if (updatedPlaylist) {
                [self.outlineView reloadData];
            }
        });
    }];
}

- (IBAction)outlineViewMenuDeletePlaylistClicked:(id)sender {
    NSInteger clickedRow = [self.outlineView clickedRow];
    if (clickedRow < 0) return;
    Playlist *item = [self.outlineView itemAtRow:clickedRow];
    if (![item isKindOfClass:[Playlist class]]) return;
    NSAlert *alert = [[NSAlert alloc] init];
    [alert setMessageText:[NSString stringWithFormat:NSLocalizedString(@"Are you sure to delete the \"%@ playlist?", @""), item.name]];
    [alert addButtonWithTitle:NSLocalizedString(@"Yes", @"")];
    [alert addButtonWithTitle:NSLocalizedString(@"Cancel", @"")];
    typeof(self) __weak weakSelf = self;
    [alert beginSheetModalForWindow:self.window
                  completionHandler:^(NSModalResponse returnCode) {
        if (returnCode != NSAlertFirstButtonReturn) return;
        [[MediaDB shared] deletePlaylist:item
                              completion:^(NSError * _Nullable error) {
            if (error) {
                dispatch_async(dispatch_get_main_queue(), ^{
                    [weakSelf displayError:error];
                });
            }
            if (weakSelf.viewMode == PlayerWindowControllerViewModePlaylists) {
                [[MediaDB shared] listPlaylistsWithCompletion:^(NSError * _Nullable error, NSArray<Playlist *> * _Nullable playlists) {
                    dispatch_async(dispatch_get_main_queue(), ^{
                        [weakSelf storeCurrentViewModeState];
                        [weakSelf sortAndSetOutlineViewItems:playlists];
                        if (error) {
                            [weakSelf displayError:error];
                            return;
                        }
                        [weakSelf.outlineView reloadData];
                        [weakSelf restoreCurrentViewModeState];
                    });
                }];
            }
        }];
    }];
}

- (void)menuWillOpen:(NSMenu *)menu {
    if (menu == self.outlineViewPlaylistMenu) {
        NSMenuItem *loadingItem = [[NSMenuItem alloc] initWithTitle:NSLocalizedString(@"Loading", @"Add to playlist menu loading")
                                                             action:nil
                                                      keyEquivalent:@""];
        [loadingItem setEnabled:NO];
        [self.outlineViewPlaylistMenu setItemArray:@[loadingItem]];
        [[MediaDB shared] listPlaylistsWithCompletion:^(NSError * _Nullable error, NSArray<Playlist *> * _Nullable playlists) {
            dispatch_async(dispatch_get_main_queue(), ^{
                [self.outlineViewPlaylistMenu removeAllItems];
                for (Playlist *playlist in playlists) {
                    NSMenuItem *item = [[NSMenuItem alloc] initWithTitle:playlist.name
                                                                  action:@selector(outlineViewMenuAddToPlaylistClicked:)
                                                           keyEquivalent:@""];
                    [item setRepresentedObject:playlist];
                    [self.outlineViewPlaylistMenu addItem:item];
                }
            });
        }];
    }
}

- (void)menuDidClose:(NSMenu *)menu {

M Sonar/Player/PlayerWindowController.xib => Sonar/Player/PlayerWindowController.xib +26 -0
@@ 23,6 23,7 @@
                <outlet property="nextButton" destination="iZZ-fA-cDk" id="7td-jV-M8q"/>
                <outlet property="outlineView" destination="SQX-Jn-D0S" id="1Js-cz-0b7"/>
                <outlet property="outlineViewMenu" destination="gbx-S6-sKM" id="Txp-Ce-Wfa"/>
                <outlet property="outlineViewPlaylistMenu" destination="9V1-It-Dvk" id="dmJ-13-Nk8"/>
                <outlet property="playButton" destination="6em-6X-jen" id="Qbn-sl-Gw0"/>
                <outlet property="playlistsModeButton" destination="vMR-N3-Rxj" id="wX3-Ke-pge"/>
                <outlet property="previousButton" destination="hHV-yf-3EL" id="zgH-av-F70"/>


@@ 659,6 660,31 @@
                        <action selector="outlineViewMenuStarClicked:" target="-2" id="EdR-0r-oO6"/>
                    </connections>
                </menuItem>
                <menuItem title="Add To Playlists" identifier="addToPlaylist" id="eZB-dZ-AjD">
                    <modifierMask key="keyEquivalentModifierMask"/>
                    <menu key="submenu" title="Add To Playlists" id="9V1-It-Dvk">
                        <items>
                            <menuItem title="Loading..." enabled="NO" id="4Gg-57-rEE">
                                <modifierMask key="keyEquivalentModifierMask"/>
                            </menuItem>
                        </items>
                        <connections>
                            <outlet property="delegate" destination="-2" id="VSr-fI-SMK"/>
                        </connections>
                    </menu>
                </menuItem>
                <menuItem title="Remove From Playlist" identifier="removeFromPlaylist" id="x25-M6-qJ6">
                    <modifierMask key="keyEquivalentModifierMask"/>
                    <connections>
                        <action selector="outlineViewMenuRemoveFromPlaylistClicked:" target="-2" id="hVX-Ph-l2f"/>
                    </connections>
                </menuItem>
                <menuItem title="Delete Playlist..." identifier="deletePlaylist" id="f6w-5k-Ffo">
                    <modifierMask key="keyEquivalentModifierMask"/>
                    <connections>
                        <action selector="outlineViewMenuDeletePlaylistClicked:" target="-2" id="8Wf-UC-T6C"/>
                    </connections>
                </menuItem>
            </items>
            <connections>
                <outlet property="delegate" destination="-2" id="Nei-aI-54q"/>