diff --git a/Spottie.xcodeproj/project.pbxproj b/Spottie.xcodeproj/project.pbxproj index f6bfc92..d5a79bd 100644 --- a/Spottie.xcodeproj/project.pbxproj +++ b/Spottie.xcodeproj/project.pbxproj @@ -55,6 +55,14 @@ 473061A12659218F001E3A1F /* SpottieError.swift in Sources */ = {isa = PBXBuildFile; fileRef = 473061A02659218F001E3A1F /* SpottieError.swift */; }; 473061A42659FFF5001E3A1F /* CurrentlyPlayingContextObject.swift in Sources */ = {isa = PBXBuildFile; fileRef = 473061A32659FFF5001E3A1F /* CurrentlyPlayingContextObject.swift */; }; 473061A6265B4A10001E3A1F /* WebAPITrackObject.swift in Sources */ = {isa = PBXBuildFile; fileRef = 473061A5265B4A10001E3A1F /* WebAPITrackObject.swift */; }; + 47306EF126D24A120018BCB0 /* SavedTracksTableViewController.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47306EF026D24A120018BCB0 /* SavedTracksTableViewController.swift */; }; + 47306EF326D24AAC0018BCB0 /* SavedTracksTable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47306EF226D24AAC0018BCB0 /* SavedTracksTable.swift */; }; + 47306EF626D4867F0018BCB0 /* DependencyInjector.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47306EF526D4867F0018BCB0 /* DependencyInjector.swift */; }; + 47306EF826D48F750018BCB0 /* PlayerCoreState.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47306EF726D48F750018BCB0 /* PlayerCoreState.swift */; }; + 47306EFC26D5DEB60018BCB0 /* v1.xcdatamodeld in Sources */ = {isa = PBXBuildFile; fileRef = 47306EFA26D5DEB60018BCB0 /* v1.xcdatamodeld */; }; + 47306EFE26D5E2060018BCB0 /* DBService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47306EFD26D5E2060018BCB0 /* DBService.swift */; }; + 47306F1026FB0E0B0018BCB0 /* WebAPISimplifiedPlaylistObject.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47306F0F26FB0E0B0018BCB0 /* WebAPISimplifiedPlaylistObject.swift */; }; + 47306F1826FB26450018BCB0 /* PlaylistDetail.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47306F1726FB26450018BCB0 /* PlaylistDetail.swift */; }; 474BAFB8266876030006EB16 /* VolumeSlider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 474BAFB7266876030006EB16 /* VolumeSlider.swift */; }; 474BAFBA26687AB60006EB16 /* WebAPIDeviceObject.swift in Sources */ = {isa = PBXBuildFile; fileRef = 474BAFB926687AB60006EB16 /* WebAPIDeviceObject.swift */; }; 474BAFBC2668B0170006EB16 /* RepeatMode.swift in Sources */ = {isa = PBXBuildFile; fileRef = 474BAFBB2668B0170006EB16 /* RepeatMode.swift */; }; @@ -79,10 +87,18 @@ 4764B3A1268407C600AE471E /* TrackListItem.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4764B3A0268407C600AE471E /* TrackListItem.swift */; }; 4764B3A526842ECA00AE471E /* WebAPIImageCollection.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4764B3A426842ECA00AE471E /* WebAPIImageCollection.swift */; }; 4764B3A82684867600AE471E /* DurationFormatter.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4764B3A72684867600AE471E /* DurationFormatter.swift */; }; + 476A51A326D0DDE00092A24E /* SpotifyWebAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 476A51A226D0DDE00092A24E /* SpotifyWebAPI.swift */; }; + 476A51A526D0E5350092A24E /* WebAPISavedTrackObject.swift in Sources */ = {isa = PBXBuildFile; fileRef = 476A51A426D0E5350092A24E /* WebAPISavedTrackObject.swift */; }; + 478C83F426D0CE2B005EBBC7 /* LikedSongs.swift in Sources */ = {isa = PBXBuildFile; fileRef = 478C83F326D0CE2B005EBBC7 /* LikedSongs.swift */; }; 47C56F602679A789003EA20A /* PlayerCommands.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47C56F5F2679A789003EA20A /* PlayerCommands.swift */; }; 47F77E5C26C7D04400FC882B /* spottie_curl.c in Sources */ = {isa = PBXBuildFile; fileRef = 47F77E5B26C7D04400FC882B /* spottie_curl.c */; }; 47F77E5F26C7D19800FC882B /* libcurl.tbd in Frameworks */ = {isa = PBXBuildFile; fileRef = 47F77E5E26C7D11D00FC882B /* libcurl.tbd */; }; 47F77E6126C7D2E000FC882B /* PlayerCore.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47F77E6026C7D2E000FC882B /* PlayerCore.swift */; }; + 47F77E6826C7DADC00FC882B /* libspottie_player_core.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 47F77E6726C7DADC00FC882B /* libspottie_player_core.a */; }; + 47F77E6E26C7DC6B00FC882B /* AudioToolbox.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 47F77E6B26C7DC6B00FC882B /* AudioToolbox.framework */; }; + 47F77E6F26C7DC6B00FC882B /* AudioUnit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 47F77E6C26C7DC6B00FC882B /* AudioUnit.framework */; }; + 47F77E7026C7DC6B00FC882B /* CoreAudio.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 47F77E6D26C7DC6B00FC882B /* CoreAudio.framework */; }; + 47F77E7426CDD3DD00FC882B /* CurlClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47F77E7326CDD3DD00FC882B /* CurlClient.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -105,7 +121,7 @@ 47306151265656EF001E3A1F /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 47306154265656EF001E3A1F /* Preview Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = "Preview Assets.xcassets"; sourceTree = ""; }; 47306156265656EF001E3A1F /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = ""; }; - 47306157265656EF001E3A1F /* Spottie.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Spottie.entitlements; sourceTree = ""; }; + 47306157265656EF001E3A1F /* Spottie.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Spottie.entitlements; sourceTree = SOURCE_ROOT; }; 4730615E2656573B001E3A1F /* Sidebar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Sidebar.swift; sourceTree = ""; }; 473061602656580F001E3A1F /* Home.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Home.swift; sourceTree = ""; }; 4730616226565828001E3A1F /* Search.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Search.swift; sourceTree = ""; }; @@ -136,6 +152,14 @@ 473061A02659218F001E3A1F /* SpottieError.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpottieError.swift; sourceTree = ""; }; 473061A32659FFF5001E3A1F /* CurrentlyPlayingContextObject.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CurrentlyPlayingContextObject.swift; sourceTree = ""; }; 473061A5265B4A10001E3A1F /* WebAPITrackObject.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebAPITrackObject.swift; sourceTree = ""; }; + 47306EF026D24A120018BCB0 /* SavedTracksTableViewController.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SavedTracksTableViewController.swift; sourceTree = ""; }; + 47306EF226D24AAC0018BCB0 /* SavedTracksTable.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SavedTracksTable.swift; sourceTree = ""; }; + 47306EF526D4867F0018BCB0 /* DependencyInjector.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DependencyInjector.swift; sourceTree = ""; }; + 47306EF726D48F750018BCB0 /* PlayerCoreState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerCoreState.swift; sourceTree = ""; }; + 47306EFB26D5DEB60018BCB0 /* v1.xcdatamodel */ = {isa = PBXFileReference; lastKnownFileType = wrapper.xcdatamodel; path = v1.xcdatamodel; sourceTree = ""; }; + 47306EFD26D5E2060018BCB0 /* DBService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DBService.swift; sourceTree = ""; }; + 47306F0F26FB0E0B0018BCB0 /* WebAPISimplifiedPlaylistObject.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebAPISimplifiedPlaylistObject.swift; sourceTree = ""; }; + 47306F1726FB26450018BCB0 /* PlaylistDetail.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaylistDetail.swift; sourceTree = ""; }; 474BAFB7266876030006EB16 /* VolumeSlider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VolumeSlider.swift; sourceTree = ""; }; 474BAFB926687AB60006EB16 /* WebAPIDeviceObject.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebAPIDeviceObject.swift; sourceTree = ""; }; 474BAFBB2668B0170006EB16 /* RepeatMode.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RepeatMode.swift; sourceTree = ""; }; @@ -160,13 +184,21 @@ 4764B3A0268407C600AE471E /* TrackListItem.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrackListItem.swift; sourceTree = ""; }; 4764B3A426842ECA00AE471E /* WebAPIImageCollection.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebAPIImageCollection.swift; sourceTree = ""; }; 4764B3A72684867600AE471E /* DurationFormatter.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DurationFormatter.swift; sourceTree = ""; }; + 476A51A226D0DDE00092A24E /* SpotifyWebAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpotifyWebAPI.swift; sourceTree = ""; }; + 476A51A426D0E5350092A24E /* WebAPISavedTrackObject.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebAPISavedTrackObject.swift; sourceTree = ""; }; + 478C83F326D0CE2B005EBBC7 /* LikedSongs.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LikedSongs.swift; sourceTree = ""; }; 47C56F5F2679A789003EA20A /* PlayerCommands.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerCommands.swift; sourceTree = ""; }; 47F77E5926C7D04400FC882B /* Spottie-Bridging-Header.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "Spottie-Bridging-Header.h"; sourceTree = ""; }; 47F77E5A26C7D04400FC882B /* spottie_curl.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = spottie_curl.h; sourceTree = ""; }; 47F77E5B26C7D04400FC882B /* spottie_curl.c */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.c; path = spottie_curl.c; sourceTree = ""; }; 47F77E5E26C7D11D00FC882B /* libcurl.tbd */ = {isa = PBXFileReference; lastKnownFileType = "sourcecode.text-based-dylib-definition"; name = libcurl.tbd; path = usr/lib/libcurl.tbd; sourceTree = SDKROOT; }; 47F77E6026C7D2E000FC882B /* PlayerCore.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerCore.swift; sourceTree = ""; }; - 47F77E6326C7D39E00FC882B /* player_core.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = player_core.h; sourceTree = ""; }; + 47F77E6626C7DADC00FC882B /* libspottie_player_core.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = libspottie_player_core.h; sourceTree = ""; }; + 47F77E6726C7DADC00FC882B /* libspottie_player_core.a */ = {isa = PBXFileReference; lastKnownFileType = archive.ar; path = libspottie_player_core.a; sourceTree = ""; }; + 47F77E6B26C7DC6B00FC882B /* AudioToolbox.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = AudioToolbox.framework; path = System/Library/Frameworks/AudioToolbox.framework; sourceTree = SDKROOT; }; + 47F77E6C26C7DC6B00FC882B /* AudioUnit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = AudioUnit.framework; path = System/Library/Frameworks/AudioUnit.framework; sourceTree = SDKROOT; }; + 47F77E6D26C7DC6B00FC882B /* CoreAudio.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreAudio.framework; path = System/Library/Frameworks/CoreAudio.framework; sourceTree = SDKROOT; }; + 47F77E7326CDD3DD00FC882B /* CurlClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CurlClient.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -174,7 +206,11 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 47F77E7026C7DC6B00FC882B /* CoreAudio.framework in Frameworks */, + 47F77E6F26C7DC6B00FC882B /* AudioUnit.framework in Frameworks */, + 47F77E6E26C7DC6B00FC882B /* AudioToolbox.framework in Frameworks */, 47F77E5F26C7D19800FC882B /* libcurl.tbd in Frameworks */, + 47F77E6826C7DADC00FC882B /* libspottie_player_core.a in Frameworks */, 470201C6265CF4560030ECA9 /* SDWebImageSwiftUI in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -204,6 +240,7 @@ 4730614C265656ED001E3A1F /* Spottie */ = { isa = PBXGroup; children = ( + 47306EF926D5DE830018BCB0 /* Persistence */, 4730616B26565EB8001E3A1F /* Backend */, 47F77E6226C7D2E600FC882B /* PlayerCore */, 4764B3A62684865B00AE471E /* Utilities */, @@ -229,6 +266,7 @@ 4730615D26565706001E3A1F /* Views */ = { isa = PBXGroup; children = ( + 47306F1626FB26270018BCB0 /* SavedTracksTable */, 475EE240267C92EC007BEBDC /* Components */, 4730616626565AF8001E3A1F /* BottomBar */, 4730614F265656ED001E3A1F /* ContentView.swift */, @@ -236,6 +274,8 @@ 473061602656580F001E3A1F /* Home.swift */, 4730616226565828001E3A1F /* Search.swift */, 4730616426565841001E3A1F /* Library.swift */, + 478C83F326D0CE2B005EBBC7 /* LikedSongs.swift */, + 47306F1726FB26450018BCB0 /* PlaylistDetail.swift */, ); path = Views; sourceTree = ""; @@ -266,6 +306,7 @@ 4730617326587EF6001E3A1F /* WebsocketClient.swift */, 4730616E26566076001E3A1F /* SpotifyAPI.swift */, 4730617A265903A6001E3A1F /* EventBroker.swift */, + 476A51A226D0DDE00092A24E /* SpotifyWebAPI.swift */, ); path = Backend; sourceTree = ""; @@ -337,6 +378,24 @@ path = API; sourceTree = ""; }; + 47306EF926D5DE830018BCB0 /* Persistence */ = { + isa = PBXGroup; + children = ( + 47306EFA26D5DEB60018BCB0 /* v1.xcdatamodeld */, + 47306EFD26D5E2060018BCB0 /* DBService.swift */, + ); + path = Persistence; + sourceTree = ""; + }; + 47306F1626FB26270018BCB0 /* SavedTracksTable */ = { + isa = PBXGroup; + children = ( + 47306EF026D24A120018BCB0 /* SavedTracksTableViewController.swift */, + 47306EF226D24AAC0018BCB0 /* SavedTracksTable.swift */, + ); + path = SavedTracksTable; + sourceTree = ""; + }; 475EE240267C92EC007BEBDC /* Components */ = { isa = PBXGroup; children = ( @@ -367,6 +426,8 @@ 475798D4266F1B9B00AADF2F /* WebAPIPlaylistTracksRefObject.swift */, 475EE24E267EBFEA007BEBDC /* WebAPIPagingObject.swift */, 4764B3A426842ECA00AE471E /* WebAPIImageCollection.swift */, + 476A51A426D0E5350092A24E /* WebAPISavedTrackObject.swift */, + 47306F0F26FB0E0B0018BCB0 /* WebAPISimplifiedPlaylistObject.swift */, ); path = WebAPI; sourceTree = ""; @@ -375,6 +436,7 @@ isa = PBXGroup; children = ( 4764B3A72684867600AE471E /* DurationFormatter.swift */, + 47306EF526D4867F0018BCB0 /* DependencyInjector.swift */, ); path = Utilities; sourceTree = ""; @@ -392,6 +454,7 @@ children = ( 47F77E5A26C7D04400FC882B /* spottie_curl.h */, 47F77E5B26C7D04400FC882B /* spottie_curl.c */, + 47F77E7326CDD3DD00FC882B /* CurlClient.swift */, ); path = CURL; sourceTree = ""; @@ -399,6 +462,9 @@ 47F77E5D26C7D11D00FC882B /* Frameworks */ = { isa = PBXGroup; children = ( + 47F77E6B26C7DC6B00FC882B /* AudioToolbox.framework */, + 47F77E6C26C7DC6B00FC882B /* AudioUnit.framework */, + 47F77E6D26C7DC6B00FC882B /* CoreAudio.framework */, 47F77E5E26C7D11D00FC882B /* libcurl.tbd */, ); name = Frameworks; @@ -407,8 +473,10 @@ 47F77E6226C7D2E600FC882B /* PlayerCore */ = { isa = PBXGroup; children = ( - 47F77E6326C7D39E00FC882B /* player_core.h */, + 47F77E6726C7DADC00FC882B /* libspottie_player_core.a */, + 47F77E6626C7DADC00FC882B /* libspottie_player_core.h */, 47F77E6026C7D2E000FC882B /* PlayerCore.swift */, + 47306EF726D48F750018BCB0 /* PlayerCoreState.swift */, ); path = PlayerCore; sourceTree = ""; @@ -420,6 +488,7 @@ isa = PBXNativeTarget; buildConfigurationList = 4730615A265656EF001E3A1F /* Build configuration list for PBXNativeTarget "Spottie" */; buildPhases = ( + 47F77E6426C7D87A00FC882B /* Run Script: Build PlayerCore */, 47306146265656ED001E3A1F /* Sources */, 47306147265656ED001E3A1F /* Frameworks */, 47306148265656ED001E3A1F /* Resources */, @@ -484,6 +553,27 @@ }; /* End PBXResourcesBuildPhase section */ +/* Begin PBXShellScriptBuildPhase section */ + 47F77E6426C7D87A00FC882B /* Run Script: Build PlayerCore */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputFileListPaths = ( + ); + inputPaths = ( + ); + name = "Run Script: Build PlayerCore"; + outputFileListPaths = ( + ); + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/bash; + shellScript = "$PROJECT_DIR/Scripts/build_player_core.sh\n"; + }; +/* End PBXShellScriptBuildPhase section */ + /* Begin PBXSourcesBuildPhase section */ 47306146265656ED001E3A1F /* Sources */ = { isa = PBXSourcesBuildPhase; @@ -493,6 +583,7 @@ 47306150265656ED001E3A1F /* ContentView.swift in Sources */, 475EE244267D6659007BEBDC /* ShortcutItem.swift in Sources */, 473061792658A02E001E3A1F /* SpotifyEvent.swift in Sources */, + 476A51A526D0E5350092A24E /* WebAPISavedTrackObject.swift in Sources */, 470201BD265B80970030ECA9 /* FakePlayerViewModel.swift in Sources */, 4730618326590931001E3A1F /* TrackObject.swift in Sources */, 470201BB265B7C190030ECA9 /* PlayerStateProtocol.swift in Sources */, @@ -503,6 +594,7 @@ 4730618B26591E36001E3A1F /* CoverGroupObject.swift in Sources */, 475EE242267C9306007BEBDC /* ShortcutGrid.swift in Sources */, 475EE24C267EBDE7007BEBDC /* SearchResultsResponse.swift in Sources */, + 47306EF626D4867F0018BCB0 /* DependencyInjector.swift in Sources */, 4730618926591E16001E3A1F /* DateObject.swift in Sources */, 4730616826565B03001E3A1F /* PlayPauseButton.swift in Sources */, 4730616326565828001E3A1F /* Search.swift in Sources */, @@ -515,10 +607,13 @@ 4730616A26565BB7001E3A1F /* BottomBar.swift in Sources */, 473061722656629F001E3A1F /* Nothing.swift in Sources */, 474BAFBC2668B0170006EB16 /* RepeatMode.swift in Sources */, + 47306F1826FB26450018BCB0 /* PlaylistDetail.swift in Sources */, + 47306EF126D24A120018BCB0 /* SavedTracksTableViewController.swift in Sources */, 473061A12659218F001E3A1F /* SpottieError.swift in Sources */, 4730616F26566076001E3A1F /* SpotifyAPI.swift in Sources */, 473061A42659FFF5001E3A1F /* CurrentlyPlayingContextObject.swift in Sources */, 4730616D26565ED1001E3A1F /* HTTPClient.swift in Sources */, + 47306EFC26D5DEB60018BCB0 /* v1.xcdatamodeld in Sources */, 4730618D26591E50001E3A1F /* ImageObject.swift in Sources */, 470201C8265CF8D90030ECA9 /* PlayerControls.swift in Sources */, 4730619B26591FFC001E3A1F /* TrackSeekedEvent.swift in Sources */, @@ -529,12 +624,15 @@ 4730617426587EF6001E3A1F /* WebsocketClient.swift in Sources */, 473061612656580F001E3A1F /* Home.swift in Sources */, 470201C1265BB43C0030ECA9 /* NextTrackButton.swift in Sources */, + 478C83F426D0CE2B005EBBC7 /* LikedSongs.swift in Sources */, 470201CE265DE8720030ECA9 /* TrackProgressSlider.swift in Sources */, 4730619F26592046001E3A1F /* InactiveSessionEvent.swift in Sources */, + 47306F1026FB0E0B0018BCB0 /* WebAPISimplifiedPlaylistObject.swift in Sources */, 4730614E265656ED001E3A1F /* SpottieApp.swift in Sources */, 4730617726588C51001E3A1F /* PlayerViewModel.swift in Sources */, 475EE24A267DA451007BEBDC /* GreenPlayButton.swift in Sources */, 470201B3265B55F30030ECA9 /* WebAPISimplifiedArtistObject.swift in Sources */, + 47306EFE26D5E2060018BCB0 /* DBService.swift in Sources */, 470201B7265B56860030ECA9 /* WebAPIArtistObject.swift in Sources */, 473061A6265B4A10001E3A1F /* WebAPITrackObject.swift in Sources */, 475798C1266E2DD700AADF2F /* RecommendationGroup.swift in Sources */, @@ -544,6 +642,7 @@ 4730619D26592023001E3A1F /* PlaybackPausedEvent.swift in Sources */, 475798D3266F12EB00AADF2F /* RecommendationsResponse.swift in Sources */, 475798D1266F103100AADF2F /* WebAPIPlaylistTrackObject.swift in Sources */, + 47306EF826D48F750018BCB0 /* PlayerCoreState.swift in Sources */, 47F77E5C26C7D04400FC882B /* spottie_curl.c in Sources */, 475798CF266F101600AADF2F /* WebAPIPublicUserObject.swift in Sources */, 4730618F26591E9C001E3A1F /* ContextChangedEvent.swift in Sources */, @@ -551,9 +650,12 @@ 474BAFC1266B84CD0006EB16 /* CarouselRow.swift in Sources */, 4730616526565841001E3A1F /* Library.swift in Sources */, 4764B39F2684001A00AE471E /* TokenObject.swift in Sources */, + 47306EF326D24AAC0018BCB0 /* SavedTracksTable.swift in Sources */, + 476A51A326D0DDE00092A24E /* SpotifyWebAPI.swift in Sources */, 4730619926591F92001E3A1F /* VolumeChangedEvent.swift in Sources */, 4730619726591F14001E3A1F /* MetadataAvailableEvent.swift in Sources */, 4764B39D26833B9900AE471E /* HoverState.swift in Sources */, + 47F77E7426CDD3DD00FC882B /* CurlClient.swift in Sources */, 4730617B265903A6001E3A1F /* EventBroker.swift in Sources */, 470201CA265CF9380030ECA9 /* ShuffleButton.swift in Sources */, 4730619326591EE1001E3A1F /* TrackChangedEvent.swift in Sources */, @@ -688,17 +790,23 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CLANG_ENABLE_MODULES = YES; - CODE_SIGN_ENTITLEMENTS = Spottie/Spottie.entitlements; + CODE_SIGN_ENTITLEMENTS = Spottie.entitlements; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; DEVELOPMENT_ASSET_PATHS = "\"Spottie/Preview Content\""; + DEVELOPMENT_TEAM = YW4H592L4M; ENABLE_PREVIEWS = YES; + HEADER_SEARCH_PATHS = "Spottie/Spottie-Bridging-Header.h/**"; INFOPLIST_FILE = Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/../Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 11.0; + LIBRARY_SEARCH_PATHS = ( + "$(inherited)", + "$(PROJECT_DIR)/Spottie/PlayerCore", + ); + MACOSX_DEPLOYMENT_TARGET = 12.0; PRODUCT_BUNDLE_IDENTIFIER = ljk.Spottie; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_OBJC_BRIDGING_HEADER = "Spottie/Spottie-Bridging-Header.h"; @@ -713,17 +821,23 @@ ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; CLANG_ENABLE_MODULES = YES; - CODE_SIGN_ENTITLEMENTS = Spottie/Spottie.entitlements; + CODE_SIGN_ENTITLEMENTS = Spottie.entitlements; CODE_SIGN_STYLE = Automatic; COMBINE_HIDPI_IMAGES = YES; DEVELOPMENT_ASSET_PATHS = "\"Spottie/Preview Content\""; + DEVELOPMENT_TEAM = YW4H592L4M; ENABLE_PREVIEWS = YES; + HEADER_SEARCH_PATHS = "Spottie/Spottie-Bridging-Header.h/**"; INFOPLIST_FILE = Info.plist; LD_RUNPATH_SEARCH_PATHS = ( "$(inherited)", "@executable_path/../Frameworks", ); - MACOSX_DEPLOYMENT_TARGET = 11.0; + LIBRARY_SEARCH_PATHS = ( + "$(inherited)", + "$(PROJECT_DIR)/Spottie/PlayerCore", + ); + MACOSX_DEPLOYMENT_TARGET = 12.0; PRODUCT_BUNDLE_IDENTIFIER = ljk.Spottie; PRODUCT_NAME = "$(TARGET_NAME)"; SWIFT_OBJC_BRIDGING_HEADER = "Spottie/Spottie-Bridging-Header.h"; @@ -772,6 +886,19 @@ productName = SDWebImageSwiftUI; }; /* End XCSwiftPackageProductDependency section */ + +/* Begin XCVersionGroup section */ + 47306EFA26D5DEB60018BCB0 /* v1.xcdatamodeld */ = { + isa = XCVersionGroup; + children = ( + 47306EFB26D5DEB60018BCB0 /* v1.xcdatamodel */, + ); + currentVersion = 47306EFB26D5DEB60018BCB0 /* v1.xcdatamodel */; + path = v1.xcdatamodeld; + sourceTree = ""; + versionGroupType = wrapper.xcdatamodel; + }; +/* End XCVersionGroup section */ }; rootObject = 47306142265656ED001E3A1F /* Project object */; } diff --git a/Spottie/SpottieApp.swift b/Spottie/SpottieApp.swift index c623dde..5ef8b43 100644 --- a/Spottie/SpottieApp.swift +++ b/Spottie/SpottieApp.swift @@ -10,8 +10,10 @@ import Combine @main struct SpottieApp: App { + // register dependencies into the container + @Provider var playerCore = PlayerCore() init() { - + DependencyInjector.register(dependency: SpotifyWebAPI(playerCore: playerCore)) } @StateObject private var playerViewModel = PlayerViewModel(EventBroker()) diff --git a/Spottie/Utilities/DependencyInjector.swift b/Spottie/Utilities/DependencyInjector.swift new file mode 100644 index 0000000..5dfb682 --- /dev/null +++ b/Spottie/Utilities/DependencyInjector.swift @@ -0,0 +1,39 @@ +// +// DependencyInjector.swift +// DependencyInjector +// +// Created by Lee Jun Kit on 24/8/21. +// + +// https://medium.com/@hanneshertach/swiftui-dependency-injection-in-20-lines-b322457065a5 +struct DependencyInjector { + private static var dependencyList: [String: Any] = [:] + + static func resolve() -> T { + guard let t = dependencyList[String(describing: T.self)] as? T else { + fatalError("No povider registered for type \(T.self)") + } + return t + } + + static func register(dependency: T) { + dependencyList[String(describing: T.self)] = dependency + } +} + +@propertyWrapper struct Inject { + var wrappedValue: T + + init() { + self.wrappedValue = DependencyInjector.resolve() + } +} + +@propertyWrapper struct Provider { + var wrappedValue: T + + init(wrappedValue: T) { + self.wrappedValue = wrappedValue + DependencyInjector.register(dependency: wrappedValue) + } +} diff --git a/Spottie/Views/BottomBar/BottomBar.swift b/Spottie/Views/BottomBar/BottomBar.swift index a913580..fc31bd4 100644 --- a/Spottie/Views/BottomBar/BottomBar.swift +++ b/Spottie/Views/BottomBar/BottomBar.swift @@ -11,13 +11,9 @@ struct BottomBar: View { @EnvironmentObject var viewModel: M var body: some View { HStack(spacing: 40) { - NowPlaying( - trackName: viewModel.trackName, - artistName: viewModel.artistName, - artworkURL: viewModel.artworkURL - ) + NowPlaying() .frame(width: 240, alignment: .leading) - PlayerControls() + PlayerControls() VolumeSlider( volumePercent: viewModel.volumePercent, onVolumeChanged: viewModel.setVolume diff --git a/Spottie/Views/BottomBar/NowPlaying.swift b/Spottie/Views/BottomBar/NowPlaying.swift index ffbdd9c..479012b 100644 --- a/Spottie/Views/BottomBar/NowPlaying.swift +++ b/Spottie/Views/BottomBar/NowPlaying.swift @@ -6,21 +6,52 @@ // import SwiftUI +import Combine import SDWebImageSwiftUI struct NowPlaying: View { - var trackName = "" - var artistName = "" - var artworkURL: URL? + class ViewModel: ObservableObject { + @Published var trackName = "" + @Published var artistName = "" + @Published var artworkURL: URL? + + @Inject var playerCore: PlayerCore + @Inject var webAPI: SpotifyWebAPI + private var cancellables = [AnyCancellable]() + + init() { + playerCore + .statePublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] state in + guard let self = self else { return } + guard let state = state else { return } + + if let trackId = state.player.trackId { + Task.init { + if case let .success(track) = await self.webAPI.getTrack(trackId) { + DispatchQueue.main.async { + self.trackName = track.name + self.artistName = track.artistString + self.artworkURL = track.album.getImageURL(.small) + } + } + } + } + }.store(in: &cancellables) + } + } + + @StateObject var viewModel = ViewModel() var body: some View { HStack(spacing: 12) { - WebImage(url: artworkURL) + WebImage(url: viewModel.artworkURL) .resizable() .frame(width: 56, height: 56) VStack(alignment: .leading, spacing: 4) { - Text(trackName) - Text(artistName) + Text(viewModel.trackName) + Text(viewModel.artistName) .foregroundColor(.secondary) } Button(action: { diff --git a/Spottie/Views/BottomBar/PlayerControls.swift b/Spottie/Views/BottomBar/PlayerControls.swift index 782f46a..38f6abd 100644 --- a/Spottie/Views/BottomBar/PlayerControls.swift +++ b/Spottie/Views/BottomBar/PlayerControls.swift @@ -6,42 +6,67 @@ // import SwiftUI +import Combine -struct PlayerControls: View { - @EnvironmentObject var viewModel: M - +struct PlayerControls: View { + @StateObject var viewModel = ViewModel() var body: some View { VStack { HStack(spacing: 28) { + /* ShuffleButton( isShuffling: viewModel.isShuffling, toggle: viewModel.toggleShuffle ) + */ PreviousTrackButton( - previousTrackButtonTapped: viewModel.previousTrack + previousTrackButtonTapped: { + Task.init { + let _ = await viewModel.playerCore.previous() + } + } ) PlayPauseButton( - playPauseButtonTapped: viewModel.togglePlayPause, + playPauseButtonTapped: { + Task.init { + let _ = await viewModel.playerCore.togglePlayback() + } + }, isPlaying: viewModel.isPlaying ) NextTrackButton( - nextTrackButtonTapped: viewModel.nextTrack + nextTrackButtonTapped: { + Task.init { + let _ = await viewModel.playerCore.next() + } + } ) + /* RepeatButton( repeatMode: viewModel.repeatMode, onRepeatButtonTapped: viewModel.cycleRepeatMode ) + */ } TrackProgressSlider() .padding([.leading, .trailing]) } } -} - -struct PlayerControls_Previews: PreviewProvider { - static var previews: some View { - PlayerControls() - .environmentObject(FakePlayerViewModel()) + + class ViewModel: ObservableObject { + @Published var isPlaying = false + + @Inject var playerCore: PlayerCore + private var cancellables = [AnyCancellable]() + init() { + playerCore + .statePublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] state in + guard let self = self else { return } + guard let state = state else { return } + self.isPlaying = state.player.state == .playing + }.store(in: &cancellables) + } } } - diff --git a/Spottie/Views/BottomBar/TrackProgressSlider.swift b/Spottie/Views/BottomBar/TrackProgressSlider.swift index da7e440..49167bf 100644 --- a/Spottie/Views/BottomBar/TrackProgressSlider.swift +++ b/Spottie/Views/BottomBar/TrackProgressSlider.swift @@ -9,57 +9,115 @@ import SwiftUI import Combine struct TrackProgressSlider: View { - @EnvironmentObject var viewModel: PlayerViewModel - - var prettyProgress: String { - get { - if (viewModel.isScrubbing) { - let scrubbedProgress = viewModel.progressPercent * Double(viewModel.durationMs) - return DurationFormatter.shared.format(scrubbedProgress / 1000.0) - } else { - return DurationFormatter.shared.format(Double(viewModel.progressMs) / 1000.0) - } - } - } - - var prettyDuration: String { - get { - return DurationFormatter.shared.format(Double(viewModel.durationMs) / 1000.0) - } - } + @StateObject var vm2 = ViewModel() var body: some View { HStack { VStack { - Text(prettyProgress).foregroundColor(.secondary) + Text(vm2.prettyElapsed) + .foregroundColor(.secondary) + .font(.monospacedDigit(.system(.body))()) } .frame(width: 40) Slider( - value: $viewModel.progressPercent, + value: $vm2.progressPercent, in: 0...1, onEditingChanged: { editing in if (!editing) { - viewModel.seek(toPercent: viewModel.progressPercent) + vm2.seek(toPercent: vm2.progressPercent) } - viewModel.isScrubbing = editing + vm2.isScrubbing = editing } ) VStack(alignment: .leading) { - Text(prettyDuration).foregroundColor(.secondary) + Text(vm2.prettyDuration) + .foregroundColor(.secondary) + .font(.monospacedDigit(.system(.body))()) } .frame(width: 40) } } + + class ViewModel: ObservableObject { + @Published var isPlaying = false + @Published var isScrubbing = false + @Published var prettyElapsed = "00:00" + @Published var prettyDuration = "00:00" + @Published var progressPercent = 0.0 { + willSet { + if isScrubbing { + let newElapsed = newValue * Double(currentTrackDuration) + prettyElapsed = DurationFormatter.shared.format(newElapsed / 1000) + } + } + } + + @Inject var playerCore: PlayerCore + @Inject var webAPI: SpotifyWebAPI + private var cancellables = [AnyCancellable]() + + private var timerPublisher = Timer.publish(every: 0.5, on: .main, in: .common).autoconnect() + private var timerSubscription: AnyCancellable? + + private var since = Date() + private var currentTrackDuration = 0 + + init() { + playerCore + .statePublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] state in + guard let self = self else { return } + guard let state = state else { return } + + if state.player.state == .playing { + self.isPlaying = true + if let trackId = state.player.trackId { + Task.init { + if case let .success(track) = await self.webAPI.getTrack(trackId) { + DispatchQueue.main.async { + self.currentTrackDuration = track.durationMs + self.prettyDuration = track.durationString + } + } + } + } + + if let since = state.player.since { + self.since = since + // start the timer for updating elapsed + if self.timerSubscription == nil { + self.timerSubscription = self.timerPublisher.sink { _ in + if !self.isScrubbing { + let elapsed = Date().timeIntervalSince(self.since) + self.prettyElapsed = DurationFormatter.shared.format(elapsed) + self.progressPercent = elapsed / Double(self.currentTrackDuration / 1000) + } + } + } + } + } else { + self.isPlaying = false + + // cancel the timer for updating elapsed + self.timerSubscription?.cancel() + self.timerSubscription = nil + + if let elapsed = state.player.elapsed { + self.prettyElapsed = DurationFormatter.shared.format(elapsed) + } + } + }.store(in: &cancellables) + } + + func seek(toPercent: Double) { + let positionMs = Double(self.currentTrackDuration) * toPercent + Task.init { + await playerCore.seek(Int(positionMs)) + } + } + } } - -//struct TrackProgressSlider_Previews: PreviewProvider { -// static let viewModel = TrackProgressSlider.ViewModel(isPlaying: true, progressMs: 20000, durationMs: 120000) { progressPercent in -// -// } -// static var previews: some View { -// TrackProgressSlider(viewModel: viewModel) -// } -//} diff --git a/Spottie/Views/Home.swift b/Spottie/Views/Home.swift index b122b83..5ec1521 100644 --- a/Spottie/Views/Home.swift +++ b/Spottie/Views/Home.swift @@ -9,7 +9,7 @@ import SwiftUI import Combine struct Home: View { - @ObservedObject var viewModel: ViewModel = ViewModel() + @ObservedObject var viewModel = ViewModel() // search @State private var searchText = "" @@ -84,12 +84,21 @@ struct Home: View { extension Home { class ViewModel: ObservableObject { + @Inject var webAPI: SpotifyWebAPI @Published var rowViewModels: [CarouselRow.ViewModel] = [] private var cancellables = [AnyCancellable]() init() { - SpotifyAPI.getPersonalizedRecommendations().sink { _ in } receiveValue: { response in - let recommendationGroups = response!.content.items + // TODO: fix broken API call here for personalized recommendations + + Task.init { + let result = await webAPI.getPersonalizedRecommendations() + guard case let .success(response) = result else { + // TODO: handle errors here + return + } + + let recommendationGroups = response.content.items self.rowViewModels = recommendationGroups.map { group in if group.id.hasPrefix("podcast") { return CarouselRow.ViewModel( @@ -154,7 +163,7 @@ extension Home { return vm } - }.store(in: &cancellables) + } } func load(_ uri: String) { diff --git a/Spottie/Views/LikedSongs.swift b/Spottie/Views/LikedSongs.swift new file mode 100644 index 0000000..ef9b3a2 --- /dev/null +++ b/Spottie/Views/LikedSongs.swift @@ -0,0 +1,66 @@ +// +// LikedSongs.swift +// LikedSongs +// +// Created by Lee Jun Kit on 21/8/21. +// + +import SwiftUI +import Combine +struct LikedSongs: View { + class ViewModel: ObservableObject { + @Published var savedTracks = [WebAPISavedTrackObject]() + @Published var currentTrackId: String? + @Inject var webAPI: SpotifyWebAPI + @Inject var playerCore: PlayerCore + private var cancellables = [AnyCancellable]() + + func load() async { + webAPI + .getSavedTracks() + .receive(on: DispatchQueue.main) + .sink(receiveCompletion: { completion in + // TODO: handle error + }, receiveValue: { tracksChunk in + self.savedTracks.append(contentsOf: tracksChunk) + }).store(in: &cancellables) + + playerCore + .statePublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] state in + guard let self = self else { return } + guard let state = state else { return } + self.currentTrackId = state.player.trackId + }.store(in: &cancellables) + } + + func onRowDoubleClicked(_ row: Int) { + let ids = savedTracks[row..() + + Task.init { + await webAPI.sendItemsToSubjectFromPagedEndpoint( + type: WebAPISavedTrackObject.self, + endpoint: endpoint, + subject: subject + ) + } + + subject.receive(on: DispatchQueue.main).sink(receiveCompletion: { completion in + // TODO: handle error + }, receiveValue: { [weak self] tracksChunk in + self?.tracks.append(contentsOf: tracksChunk) + }).store(in: &cancellables) + + playerCore + .statePublisher + .receive(on: DispatchQueue.main) + .sink { [weak self] state in + guard let self = self else { return } + guard let state = state else { return } + self.currentTrackId = state.player.trackId + }.store(in: &cancellables) + } + + func onRowDoubleClicked(_ row: Int) { + let ids = tracks[row.. Void + + typealias NSViewControllerType = SavedTracksTableViewController + func makeNSViewController(context: Context) -> SavedTracksTableViewController { + let controller = SavedTracksTableViewController() + controller.delegate = context.coordinator + return controller + } + + func updateNSViewController(_ nsViewController: SavedTracksTableViewController, context: Context) { + nsViewController.refresh(savedTracks: data, currentTrackId: currentTrackId) + } + + func makeCoordinator() -> SavedTracksTable.Coordinator { + return Coordinator(self) + } +} + +extension SavedTracksTable { + class Coordinator: NSObject { + var parent: SavedTracksTable + init(_ parent: SavedTracksTable) { + self.parent = parent + } + + func onRowDoubleClicked(_ row: Int) { + self.parent.onRowDoubleClicked(row) + } + } +} diff --git a/Spottie/Views/SavedTracksTable/SavedTracksTableViewController.swift b/Spottie/Views/SavedTracksTable/SavedTracksTableViewController.swift new file mode 100644 index 0000000..cabb562 --- /dev/null +++ b/Spottie/Views/SavedTracksTable/SavedTracksTableViewController.swift @@ -0,0 +1,166 @@ +// +// LikedSongsTableViewController.swift +// LikedSongsTableViewController +// +// Created by Lee Jun Kit on 22/8/21. +// + +import Cocoa + +class SavedTracksTableViewController: NSViewController, NSTableViewDelegate, NSTableViewDataSource { + + var data: [WebAPISavedTrackObject] = [] + var currentTrackId: String? + weak var delegate: SavedTracksTable.Coordinator? + + var initialized = false + let scrollView = NSScrollView() + let tableView = NSTableView() + let relativeDateFormatter = RelativeDateTimeFormatter() + + override func loadView() { + self.view = NSView() + } + + override func viewDidLoad() { + super.viewDidLoad() + } + + override func viewDidLayout() { + if !initialized { + initialized = true + setupView() + setupTableView() + } else { + tableView.tableColumns.forEach { tableColumn in + if tableColumn.identifier.rawValue == "Title" { + tableColumn.width = Double(self.view.frame.width) * 0.3 + } else if tableColumn.identifier.rawValue == "Artist" { + tableColumn.width = Double(self.view.frame.width) * 0.2 + } else if tableColumn.identifier.rawValue == "Album" { + tableColumn.width = Double(self.view.frame.width) * 0.2 + } else if tableColumn.identifier.rawValue == "Date Added" { + tableColumn.width = Double(self.view.frame.width) * 0.1 + } else if tableColumn.identifier.rawValue == "Duration" { + tableColumn.width = Double(self.view.frame.width) * 0.08 + } + } + } + } + + func setupView() { + self.view.translatesAutoresizingMaskIntoConstraints = false + } + + func setupTableView() { + self.view.addSubview(scrollView) + self.scrollView.translatesAutoresizingMaskIntoConstraints = false + NSLayoutConstraint.activate([ + self.scrollView.leftAnchor.constraint(equalTo: self.view.leftAnchor), + self.scrollView.topAnchor.constraint(equalTo: self.view.topAnchor), + self.scrollView.rightAnchor.constraint(equalTo: self.view.rightAnchor), + self.scrollView.bottomAnchor.constraint(equalTo: self.view.bottomAnchor), + ]) + + tableView.frame = scrollView.bounds + tableView.delegate = self + tableView.dataSource = self + tableView.usesAlternatingRowBackgroundColors = true + tableView.doubleAction = #selector(handleDoubleClick) + + + let columns = [ + (name: "Title", width: 0.3), + (name: "Duration", width: 0.08), + (name: "Artist", width: 0.2), + (name: "Album", width: 0.2), + (name: "Date Added", width: 0.1), + ] + + columns.forEach { column in + let col = NSTableColumn(identifier: NSUserInterfaceItemIdentifier(rawValue: column.name)) + col.title = column.name + col.width = Double(self.view.frame.width) * column.width + tableView.addTableColumn(col) + } + + scrollView.documentView = tableView + scrollView.hasHorizontalScroller = false + scrollView.hasVerticalScroller = true + } + + func numberOfRows(in tableView: NSTableView) -> Int { + return data.count + } + + func tableView(_ tableView: NSTableView, viewFor tableColumn: NSTableColumn?, row: Int) -> NSView? { + let savedTrack = data[row] + + let text = NSTextField() + if let tableColumn = tableColumn { + if tableColumn.identifier.rawValue == "Title" { + text.stringValue = savedTrack.track.name + } else if tableColumn.identifier.rawValue == "Artist" { + text.stringValue = savedTrack.track.artistString + } else if tableColumn.identifier.rawValue == "Album" { + text.stringValue = savedTrack.track.album.name + } else if tableColumn.identifier.rawValue == "Date Added" { + let dateAdded = savedTrack.addedAt + let now = Date() + text.stringValue = relativeDateFormatter.localizedString(for: dateAdded, relativeTo: now) + } else if tableColumn.identifier.rawValue == "Duration" { + text.stringValue = savedTrack.track.durationString + } + } + + let cell = NSTableCellView() + cell.addSubview(text) + text.drawsBackground = false + text.isBordered = false + text.isEditable = false + text.isSelectable = false + text.maximumNumberOfLines = 1 + text.translatesAutoresizingMaskIntoConstraints = false + text.lineBreakMode = .byTruncatingTail + + let fd = NSFontDescriptor.preferredFontDescriptor(forTextStyle: .body, options: [:]) + if (savedTrack.track.id == currentTrackId) { + let boldFd = fd.withSymbolicTraits(.bold) + text.font = NSFont(descriptor: boldFd, size: fd.pointSize) + text.textColor = NSColor.systemGreen + } else { + text.font = NSFont(descriptor: fd, size: fd.pointSize) + text.textColor = NSColor.labelColor + } + + NSLayoutConstraint.activate([ + text.leftAnchor.constraint(equalTo: cell.leftAnchor), + text.rightAnchor.constraint(equalTo: cell.rightAnchor), + text.centerYAnchor.constraint(equalTo: cell.centerYAnchor), + ]) + return cell + } + + func tableView(_ tableView: NSTableView, rowViewForRow row: Int) -> NSTableRowView? { + let rowView = NSTableRowView() + return rowView + } + + func refresh(savedTracks: [WebAPISavedTrackObject], currentTrackId: String?) { + self.data = savedTracks + self.currentTrackId = currentTrackId + + let selectedRowIndexes = self.tableView.selectedRowIndexes + self.tableView.reloadData() + self.tableView.selectRowIndexes(selectedRowIndexes, byExtendingSelection: false) + } + + @objc func handleDoubleClick() { + let clickedRow = tableView.clickedRow + if clickedRow >= 0 { + if let delegate = self.delegate { + delegate.onRowDoubleClicked(clickedRow) + } + } + } +} diff --git a/Spottie/Views/Sidebar.swift b/Spottie/Views/Sidebar.swift index 9917e7a..edb8aea 100644 --- a/Spottie/Views/Sidebar.swift +++ b/Spottie/Views/Sidebar.swift @@ -6,30 +6,92 @@ // import SwiftUI +import Combine struct Sidebar: View { + class ViewModel: ObservableObject { + @Published var playlists = [WebAPISimplifiedPlaylistObject]() + + @Inject var webAPI: SpotifyWebAPI + @Inject var playerCore: PlayerCore + private var cancellables = [AnyCancellable]() + + func load() { + webAPI + .getLibraryPlaylists() + .receive(on: DispatchQueue.main) + .sink(receiveCompletion: { completion in + // TODO: handle error + }, receiveValue: { playlistsChunk in + self.playlists.append(contentsOf: playlistsChunk) + }).store(in: &cancellables) + } + } + + @StateObject var viewModel = ViewModel() @Binding var state: Screen? var body: some View { List { - NavigationLink( - destination: Home(), - tag: Screen.home, - selection: $state, - label: { - Label("Home", systemImage: "house") + Group { + NavigationLink( + destination: Home(), + tag: Screen.home, + selection: $state, + label: { + Label("Home", systemImage: "house") + } + ) + NavigationLink( + destination: Library(), + tag: Screen.library, + selection: $state, + label: { + Label("Browse", systemImage: "book") + } + ) + NavigationLink( + destination: Library(), + tag: Screen.library, + selection: $state, + label: { + Label("Play Queue", systemImage: "book") + } + ) + } + + Spacer() + Text("Library") + Group { + NavigationLink(destination: LikedSongs()) { + Label("Liked Songs", systemImage: "option") } - ) - NavigationLink( - destination: Library(), - tag: Screen.library, - selection: $state, - label: { - Label("Library", systemImage: "book") + NavigationLink(destination: Library()) { + Label("Artists", systemImage: "slider.horizontal.3") } - ) + NavigationLink(destination: Library()) { + Label("Albums", systemImage: "slider.horizontal.3") + } + NavigationLink(destination: Library()) { + Label("Podcasts", systemImage: "slider.horizontal.3") + } + } + + Divider() + Group { + ForEach(viewModel.playlists) { playlist in + NavigationLink(destination: PlaylistDetail(viewModel: PlaylistDetail.ViewModel(playlist))) { + Label(playlist.name, systemImage: "").labelStyle(TitleOnlyLabelStyle()) + } + } + } } .listStyle(SidebarListStyle()) + .onAppear { + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + viewModel.load() + } + } } }