From 36cce78faaad7da51a85f1a9759ec1185c378d3d Mon Sep 17 00:00:00 2001 From: Lee Jun Kit Date: Thu, 27 May 2021 10:13:06 +0800 Subject: [PATCH] wip: player bottom bar, mock working progress slider --- Spottie.xcodeproj/project.pbxproj | 267 +++++++++++++++++- .../xcshareddata/swiftpm/Package.resolved | 25 ++ Spottie/Backend/EventBroker.swift | 28 ++ Spottie/Backend/HTTPClient.swift | 31 +- Spottie/Backend/SpotifyAPI.swift | 39 +++ .../API/CurrentlyPlayingContextObject.swift | 12 +- Spottie/Backend/Types/Base/AlbumObject.swift | 2 +- .../Backend/Types/Base/CoverGroupObject.swift | 5 + Spottie/Backend/Types/Base/TrackObject.swift | 4 +- .../Types/Base/WebAPIArtistObject.swift | 8 +- .../Types/Base/WebAPIImageObject.swift | 6 +- .../Base/WebAPISimplifiedAlbumObject.swift | 28 +- .../Base/WebAPISimplifiedArtistObject.swift | 6 +- .../Types/Base/WebAPITrackObject.swift | 13 +- .../Types/Events/ContextChangedEvent.swift | 4 +- .../Types/Events/InactiveSessionEvent.swift | 4 +- .../Types/Events/MetadataAvailableEvent.swift | 4 +- .../Types/Events/PlaybackPausedEvent.swift | 4 +- .../Types/Events/PlaybackResumedEvent.swift | 4 +- .../Types/Events/TrackChangedEvent.swift | 5 +- .../Types/Events/TrackSeekedEvent.swift | 4 +- .../Types/Events/VolumeChangedEvent.swift | 4 +- Spottie/Backend/Types/Nothing.swift | 4 +- Spottie/Backend/Types/SpotifyEvent.swift | 71 ++++- Spottie/Backend/Types/SpottieError.swift | 4 +- Spottie/Backend/WebsocketClient.swift | 29 +- Spottie/Info.plist | 5 + Spottie/Spottie.entitlements | 10 +- Spottie/SpottieApp.swift | 5 +- Spottie/ViewModels/FakePlayerViewModel.swift | 14 + Spottie/ViewModels/PlayerStateProtocol.swift | 14 + Spottie/ViewModels/PlayerViewModel.swift | 79 +++++- Spottie/Views/BottomBar.swift | 16 +- .../Views/Components/NextTrackButton.swift | 16 +- Spottie/Views/Components/NowPlaying.swift | 22 +- .../Views/Components/PlayPauseButton.swift | 20 +- Spottie/Views/Components/PlayerControls.swift | 19 +- .../Components/PreviousTrackButton.swift | 16 +- Spottie/Views/Components/RepeatButton.swift | 8 +- Spottie/Views/Components/ShuffleButton.swift | 8 +- .../Components/TrackProgressSlider.swift | 51 +++- Spottie/Views/ContentView.swift | 16 +- Spottie/Views/Home.swift | 8 +- Spottie/Views/Library.swift | 2 +- Spottie/Views/Search.swift | 2 +- Spottie/Views/Sidebar.swift | 33 ++- 46 files changed, 906 insertions(+), 73 deletions(-) create mode 100644 Spottie.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved diff --git a/Spottie.xcodeproj/project.pbxproj b/Spottie.xcodeproj/project.pbxproj index 1c2de12..039daa1 100644 --- a/Spottie.xcodeproj/project.pbxproj +++ b/Spottie.xcodeproj/project.pbxproj @@ -3,17 +3,74 @@ archiveVersion = 1; classes = { }; - objectVersion = 50; + objectVersion = 52; objects = { /* Begin PBXBuildFile section */ + 470201B1265B54720030ECA9 /* WebAPISimplifiedAlbumObject.swift in Sources */ = {isa = PBXBuildFile; fileRef = 470201B0265B54720030ECA9 /* WebAPISimplifiedAlbumObject.swift */; }; + 470201B3265B55F30030ECA9 /* WebAPISimplifiedArtistObject.swift in Sources */ = {isa = PBXBuildFile; fileRef = 470201B2265B55F30030ECA9 /* WebAPISimplifiedArtistObject.swift */; }; + 470201B5265B56350030ECA9 /* WebAPIImageObject.swift in Sources */ = {isa = PBXBuildFile; fileRef = 470201B4265B56350030ECA9 /* WebAPIImageObject.swift */; }; + 470201B7265B56860030ECA9 /* WebAPIArtistObject.swift in Sources */ = {isa = PBXBuildFile; fileRef = 470201B6265B56860030ECA9 /* WebAPIArtistObject.swift */; }; + 470201BB265B7C190030ECA9 /* PlayerStateProtocol.swift in Sources */ = {isa = PBXBuildFile; fileRef = 470201BA265B7C190030ECA9 /* PlayerStateProtocol.swift */; }; + 470201BD265B80970030ECA9 /* FakePlayerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 470201BC265B80970030ECA9 /* FakePlayerViewModel.swift */; }; + 470201BF265BB4320030ECA9 /* PreviousTrackButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 470201BE265BB4320030ECA9 /* PreviousTrackButton.swift */; }; + 470201C1265BB43C0030ECA9 /* NextTrackButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 470201C0265BB43C0030ECA9 /* NextTrackButton.swift */; }; + 470201C3265CF29B0030ECA9 /* NowPlaying.swift in Sources */ = {isa = PBXBuildFile; fileRef = 470201C2265CF29B0030ECA9 /* NowPlaying.swift */; }; + 470201C6265CF4560030ECA9 /* SDWebImageSwiftUI in Frameworks */ = {isa = PBXBuildFile; productRef = 470201C5265CF4560030ECA9 /* SDWebImageSwiftUI */; }; + 470201C8265CF8D90030ECA9 /* PlayerControls.swift in Sources */ = {isa = PBXBuildFile; fileRef = 470201C7265CF8D90030ECA9 /* PlayerControls.swift */; }; + 470201CA265CF9380030ECA9 /* ShuffleButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 470201C9265CF9380030ECA9 /* ShuffleButton.swift */; }; + 470201CC265CF9610030ECA9 /* RepeatButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 470201CB265CF9610030ECA9 /* RepeatButton.swift */; }; + 470201CE265DE8720030ECA9 /* TrackProgressSlider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 470201CD265DE8720030ECA9 /* TrackProgressSlider.swift */; }; 4730614E265656ED001E3A1F /* SpottieApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4730614D265656ED001E3A1F /* SpottieApp.swift */; }; 47306150265656ED001E3A1F /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4730614F265656ED001E3A1F /* ContentView.swift */; }; 47306152265656EF001E3A1F /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 47306151265656EF001E3A1F /* Assets.xcassets */; }; 47306155265656EF001E3A1F /* Preview Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 47306154265656EF001E3A1F /* Preview Assets.xcassets */; }; + 4730615F2656573B001E3A1F /* Sidebar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4730615E2656573B001E3A1F /* Sidebar.swift */; }; + 473061612656580F001E3A1F /* Home.swift in Sources */ = {isa = PBXBuildFile; fileRef = 473061602656580F001E3A1F /* Home.swift */; }; + 4730616326565828001E3A1F /* Search.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4730616226565828001E3A1F /* Search.swift */; }; + 4730616526565841001E3A1F /* Library.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4730616426565841001E3A1F /* Library.swift */; }; + 4730616826565B03001E3A1F /* PlayPauseButton.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4730616726565B03001E3A1F /* PlayPauseButton.swift */; }; + 4730616A26565BB7001E3A1F /* BottomBar.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4730616926565BB7001E3A1F /* BottomBar.swift */; }; + 4730616D26565ED1001E3A1F /* HTTPClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4730616C26565ED1001E3A1F /* HTTPClient.swift */; }; + 4730616F26566076001E3A1F /* SpotifyAPI.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4730616E26566076001E3A1F /* SpotifyAPI.swift */; }; + 473061722656629F001E3A1F /* Nothing.swift in Sources */ = {isa = PBXBuildFile; fileRef = 473061712656629F001E3A1F /* Nothing.swift */; }; + 4730617426587EF6001E3A1F /* WebsocketClient.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4730617326587EF6001E3A1F /* WebsocketClient.swift */; }; + 4730617726588C51001E3A1F /* PlayerViewModel.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4730617626588C51001E3A1F /* PlayerViewModel.swift */; }; + 473061792658A02E001E3A1F /* SpotifyEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 473061782658A02E001E3A1F /* SpotifyEvent.swift */; }; + 4730617B265903A6001E3A1F /* EventBroker.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4730617A265903A6001E3A1F /* EventBroker.swift */; }; + 4730618326590931001E3A1F /* TrackObject.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4730618226590931001E3A1F /* TrackObject.swift */; }; + 473061852659164E001E3A1F /* AlbumObject.swift in Sources */ = {isa = PBXBuildFile; fileRef = 473061842659164E001E3A1F /* AlbumObject.swift */; }; + 4730618726591DF7001E3A1F /* ArtistObject.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4730618626591DF7001E3A1F /* ArtistObject.swift */; }; + 4730618926591E16001E3A1F /* DateObject.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4730618826591E16001E3A1F /* DateObject.swift */; }; + 4730618B26591E36001E3A1F /* CoverGroupObject.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4730618A26591E36001E3A1F /* CoverGroupObject.swift */; }; + 4730618D26591E50001E3A1F /* ImageObject.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4730618C26591E50001E3A1F /* ImageObject.swift */; }; + 4730618F26591E9C001E3A1F /* ContextChangedEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4730618E26591E9C001E3A1F /* ContextChangedEvent.swift */; }; + 4730619326591EE1001E3A1F /* TrackChangedEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4730619226591EE1001E3A1F /* TrackChangedEvent.swift */; }; + 4730619526591EF9001E3A1F /* PlaybackResumedEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4730619426591EF9001E3A1F /* PlaybackResumedEvent.swift */; }; + 4730619726591F14001E3A1F /* MetadataAvailableEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4730619626591F14001E3A1F /* MetadataAvailableEvent.swift */; }; + 4730619926591F92001E3A1F /* VolumeChangedEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4730619826591F92001E3A1F /* VolumeChangedEvent.swift */; }; + 4730619B26591FFC001E3A1F /* TrackSeekedEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4730619A26591FFC001E3A1F /* TrackSeekedEvent.swift */; }; + 4730619D26592023001E3A1F /* PlaybackPausedEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4730619C26592023001E3A1F /* PlaybackPausedEvent.swift */; }; + 4730619F26592046001E3A1F /* InactiveSessionEvent.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4730619E26592046001E3A1F /* InactiveSessionEvent.swift */; }; + 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 */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ + 470201B0265B54720030ECA9 /* WebAPISimplifiedAlbumObject.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebAPISimplifiedAlbumObject.swift; sourceTree = ""; }; + 470201B2265B55F30030ECA9 /* WebAPISimplifiedArtistObject.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebAPISimplifiedArtistObject.swift; sourceTree = ""; }; + 470201B4265B56350030ECA9 /* WebAPIImageObject.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebAPIImageObject.swift; sourceTree = ""; }; + 470201B6265B56860030ECA9 /* WebAPIArtistObject.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebAPIArtistObject.swift; sourceTree = ""; }; + 470201BA265B7C190030ECA9 /* PlayerStateProtocol.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerStateProtocol.swift; sourceTree = ""; }; + 470201BC265B80970030ECA9 /* FakePlayerViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = FakePlayerViewModel.swift; sourceTree = ""; }; + 470201BE265BB4320030ECA9 /* PreviousTrackButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PreviousTrackButton.swift; sourceTree = ""; }; + 470201C0265BB43C0030ECA9 /* NextTrackButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NextTrackButton.swift; sourceTree = ""; }; + 470201C2265CF29B0030ECA9 /* NowPlaying.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NowPlaying.swift; sourceTree = ""; }; + 470201C7265CF8D90030ECA9 /* PlayerControls.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerControls.swift; sourceTree = ""; }; + 470201C9265CF9380030ECA9 /* ShuffleButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ShuffleButton.swift; sourceTree = ""; }; + 470201CB265CF9610030ECA9 /* RepeatButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RepeatButton.swift; sourceTree = ""; }; + 470201CD265DE8720030ECA9 /* TrackProgressSlider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrackProgressSlider.swift; sourceTree = ""; }; 4730614A265656ED001E3A1F /* Spottie.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Spottie.app; sourceTree = BUILT_PRODUCTS_DIR; }; 4730614D265656ED001E3A1F /* SpottieApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpottieApp.swift; sourceTree = ""; }; 4730614F265656ED001E3A1F /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; @@ -21,6 +78,36 @@ 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 = ""; }; + 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 = ""; }; + 4730616426565841001E3A1F /* Library.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Library.swift; sourceTree = ""; }; + 4730616726565B03001E3A1F /* PlayPauseButton.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayPauseButton.swift; sourceTree = ""; }; + 4730616926565BB7001E3A1F /* BottomBar.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BottomBar.swift; sourceTree = ""; }; + 4730616C26565ED1001E3A1F /* HTTPClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = HTTPClient.swift; sourceTree = ""; }; + 4730616E26566076001E3A1F /* SpotifyAPI.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpotifyAPI.swift; sourceTree = ""; }; + 473061712656629F001E3A1F /* Nothing.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Nothing.swift; sourceTree = ""; }; + 4730617326587EF6001E3A1F /* WebsocketClient.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebsocketClient.swift; sourceTree = ""; }; + 4730617626588C51001E3A1F /* PlayerViewModel.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerViewModel.swift; sourceTree = ""; }; + 473061782658A02E001E3A1F /* SpotifyEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SpotifyEvent.swift; sourceTree = ""; }; + 4730617A265903A6001E3A1F /* EventBroker.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EventBroker.swift; sourceTree = ""; }; + 4730618226590931001E3A1F /* TrackObject.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrackObject.swift; sourceTree = ""; }; + 473061842659164E001E3A1F /* AlbumObject.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlbumObject.swift; sourceTree = ""; }; + 4730618626591DF7001E3A1F /* ArtistObject.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ArtistObject.swift; sourceTree = ""; }; + 4730618826591E16001E3A1F /* DateObject.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DateObject.swift; sourceTree = ""; }; + 4730618A26591E36001E3A1F /* CoverGroupObject.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CoverGroupObject.swift; sourceTree = ""; }; + 4730618C26591E50001E3A1F /* ImageObject.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ImageObject.swift; sourceTree = ""; }; + 4730618E26591E9C001E3A1F /* ContextChangedEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContextChangedEvent.swift; sourceTree = ""; }; + 4730619226591EE1001E3A1F /* TrackChangedEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrackChangedEvent.swift; sourceTree = ""; }; + 4730619426591EF9001E3A1F /* PlaybackResumedEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaybackResumedEvent.swift; sourceTree = ""; }; + 4730619626591F14001E3A1F /* MetadataAvailableEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MetadataAvailableEvent.swift; sourceTree = ""; }; + 4730619826591F92001E3A1F /* VolumeChangedEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VolumeChangedEvent.swift; sourceTree = ""; }; + 4730619A26591FFC001E3A1F /* TrackSeekedEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TrackSeekedEvent.swift; sourceTree = ""; }; + 4730619C26592023001E3A1F /* PlaybackPausedEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlaybackPausedEvent.swift; sourceTree = ""; }; + 4730619E26592046001E3A1F /* InactiveSessionEvent.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InactiveSessionEvent.swift; sourceTree = ""; }; + 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 = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -28,6 +115,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + 470201C6265CF4560030ECA9 /* SDWebImageSwiftUI in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -53,8 +141,10 @@ 4730614C265656ED001E3A1F /* Spottie */ = { isa = PBXGroup; children = ( + 4730616B26565EB8001E3A1F /* Backend */, + 4730617526588C40001E3A1F /* ViewModels */, + 4730615D26565706001E3A1F /* Views */, 4730614D265656ED001E3A1F /* SpottieApp.swift */, - 4730614F265656ED001E3A1F /* ContentView.swift */, 47306151265656EF001E3A1F /* Assets.xcassets */, 47306156265656EF001E3A1F /* Info.plist */, 47306157265656EF001E3A1F /* Spottie.entitlements */, @@ -71,6 +161,111 @@ path = "Preview Content"; sourceTree = ""; }; + 4730615D26565706001E3A1F /* Views */ = { + isa = PBXGroup; + children = ( + 4730616626565AF8001E3A1F /* Components */, + 4730614F265656ED001E3A1F /* ContentView.swift */, + 4730615E2656573B001E3A1F /* Sidebar.swift */, + 473061602656580F001E3A1F /* Home.swift */, + 4730616226565828001E3A1F /* Search.swift */, + 4730616426565841001E3A1F /* Library.swift */, + 4730616926565BB7001E3A1F /* BottomBar.swift */, + ); + path = Views; + sourceTree = ""; + }; + 4730616626565AF8001E3A1F /* Components */ = { + isa = PBXGroup; + children = ( + 470201C9265CF9380030ECA9 /* ShuffleButton.swift */, + 4730616726565B03001E3A1F /* PlayPauseButton.swift */, + 470201BE265BB4320030ECA9 /* PreviousTrackButton.swift */, + 470201C0265BB43C0030ECA9 /* NextTrackButton.swift */, + 470201CB265CF9610030ECA9 /* RepeatButton.swift */, + 470201C2265CF29B0030ECA9 /* NowPlaying.swift */, + 470201C7265CF8D90030ECA9 /* PlayerControls.swift */, + 470201CD265DE8720030ECA9 /* TrackProgressSlider.swift */, + ); + path = Components; + sourceTree = ""; + }; + 4730616B26565EB8001E3A1F /* Backend */ = { + isa = PBXGroup; + children = ( + 4730617026566280001E3A1F /* Types */, + 4730616C26565ED1001E3A1F /* HTTPClient.swift */, + 4730617326587EF6001E3A1F /* WebsocketClient.swift */, + 4730616E26566076001E3A1F /* SpotifyAPI.swift */, + 4730617A265903A6001E3A1F /* EventBroker.swift */, + ); + path = Backend; + sourceTree = ""; + }; + 4730617026566280001E3A1F /* Types */ = { + isa = PBXGroup; + children = ( + 4730619126591EAC001E3A1F /* Base */, + 4730619026591EA6001E3A1F /* Events */, + 473061A22659FFDD001E3A1F /* API */, + 473061712656629F001E3A1F /* Nothing.swift */, + 473061782658A02E001E3A1F /* SpotifyEvent.swift */, + 473061A02659218F001E3A1F /* SpottieError.swift */, + ); + path = Types; + sourceTree = ""; + }; + 4730617526588C40001E3A1F /* ViewModels */ = { + isa = PBXGroup; + children = ( + 470201BA265B7C190030ECA9 /* PlayerStateProtocol.swift */, + 4730617626588C51001E3A1F /* PlayerViewModel.swift */, + 470201BC265B80970030ECA9 /* FakePlayerViewModel.swift */, + ); + path = ViewModels; + sourceTree = ""; + }; + 4730619026591EA6001E3A1F /* Events */ = { + isa = PBXGroup; + children = ( + 4730618E26591E9C001E3A1F /* ContextChangedEvent.swift */, + 4730619226591EE1001E3A1F /* TrackChangedEvent.swift */, + 4730619426591EF9001E3A1F /* PlaybackResumedEvent.swift */, + 4730619626591F14001E3A1F /* MetadataAvailableEvent.swift */, + 4730619826591F92001E3A1F /* VolumeChangedEvent.swift */, + 4730619A26591FFC001E3A1F /* TrackSeekedEvent.swift */, + 4730619C26592023001E3A1F /* PlaybackPausedEvent.swift */, + 4730619E26592046001E3A1F /* InactiveSessionEvent.swift */, + ); + path = Events; + sourceTree = ""; + }; + 4730619126591EAC001E3A1F /* Base */ = { + isa = PBXGroup; + children = ( + 4730618226590931001E3A1F /* TrackObject.swift */, + 473061842659164E001E3A1F /* AlbumObject.swift */, + 4730618626591DF7001E3A1F /* ArtistObject.swift */, + 4730618826591E16001E3A1F /* DateObject.swift */, + 4730618A26591E36001E3A1F /* CoverGroupObject.swift */, + 4730618C26591E50001E3A1F /* ImageObject.swift */, + 473061A5265B4A10001E3A1F /* WebAPITrackObject.swift */, + 470201B0265B54720030ECA9 /* WebAPISimplifiedAlbumObject.swift */, + 470201B2265B55F30030ECA9 /* WebAPISimplifiedArtistObject.swift */, + 470201B4265B56350030ECA9 /* WebAPIImageObject.swift */, + 470201B6265B56860030ECA9 /* WebAPIArtistObject.swift */, + ); + path = Base; + sourceTree = ""; + }; + 473061A22659FFDD001E3A1F /* API */ = { + isa = PBXGroup; + children = ( + 473061A32659FFF5001E3A1F /* CurrentlyPlayingContextObject.swift */, + ); + path = API; + sourceTree = ""; + }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ @@ -87,6 +282,9 @@ dependencies = ( ); name = Spottie; + packageProductDependencies = ( + 470201C5265CF4560030ECA9 /* SDWebImageSwiftUI */, + ); productName = Spottie; productReference = 4730614A265656ED001E3A1F /* Spottie.app */; productType = "com.apple.product-type.application"; @@ -114,6 +312,9 @@ Base, ); mainGroup = 47306141265656ED001E3A1F; + packageReferences = ( + 470201C4265CF4560030ECA9 /* XCRemoteSwiftPackageReference "SDWebImageSwiftUI" */, + ); productRefGroup = 4730614B265656ED001E3A1F /* Products */; projectDirPath = ""; projectRoot = ""; @@ -141,7 +342,50 @@ buildActionMask = 2147483647; files = ( 47306150265656ED001E3A1F /* ContentView.swift in Sources */, + 473061792658A02E001E3A1F /* SpotifyEvent.swift in Sources */, + 470201BD265B80970030ECA9 /* FakePlayerViewModel.swift in Sources */, + 4730618326590931001E3A1F /* TrackObject.swift in Sources */, + 470201BB265B7C190030ECA9 /* PlayerStateProtocol.swift in Sources */, + 4730615F2656573B001E3A1F /* Sidebar.swift in Sources */, + 470201CC265CF9610030ECA9 /* RepeatButton.swift in Sources */, + 4730618B26591E36001E3A1F /* CoverGroupObject.swift in Sources */, + 4730618926591E16001E3A1F /* DateObject.swift in Sources */, + 4730616826565B03001E3A1F /* PlayPauseButton.swift in Sources */, + 4730616326565828001E3A1F /* Search.swift in Sources */, + 4730618726591DF7001E3A1F /* ArtistObject.swift in Sources */, + 470201BF265BB4320030ECA9 /* PreviousTrackButton.swift in Sources */, + 470201C3265CF29B0030ECA9 /* NowPlaying.swift in Sources */, + 4730616A26565BB7001E3A1F /* BottomBar.swift in Sources */, + 473061722656629F001E3A1F /* Nothing.swift in Sources */, + 473061A12659218F001E3A1F /* SpottieError.swift in Sources */, + 4730616F26566076001E3A1F /* SpotifyAPI.swift in Sources */, + 473061A42659FFF5001E3A1F /* CurrentlyPlayingContextObject.swift in Sources */, + 4730616D26565ED1001E3A1F /* HTTPClient.swift in Sources */, + 4730618D26591E50001E3A1F /* ImageObject.swift in Sources */, + 470201C8265CF8D90030ECA9 /* PlayerControls.swift in Sources */, + 4730619B26591FFC001E3A1F /* TrackSeekedEvent.swift in Sources */, + 470201B1265B54720030ECA9 /* WebAPISimplifiedAlbumObject.swift in Sources */, + 470201B5265B56350030ECA9 /* WebAPIImageObject.swift in Sources */, + 4730617426587EF6001E3A1F /* WebsocketClient.swift in Sources */, + 473061612656580F001E3A1F /* Home.swift in Sources */, + 470201C1265BB43C0030ECA9 /* NextTrackButton.swift in Sources */, + 470201CE265DE8720030ECA9 /* TrackProgressSlider.swift in Sources */, + 4730619F26592046001E3A1F /* InactiveSessionEvent.swift in Sources */, 4730614E265656ED001E3A1F /* SpottieApp.swift in Sources */, + 4730617726588C51001E3A1F /* PlayerViewModel.swift in Sources */, + 470201B3265B55F30030ECA9 /* WebAPISimplifiedArtistObject.swift in Sources */, + 470201B7265B56860030ECA9 /* WebAPIArtistObject.swift in Sources */, + 473061A6265B4A10001E3A1F /* WebAPITrackObject.swift in Sources */, + 473061852659164E001E3A1F /* AlbumObject.swift in Sources */, + 4730619D26592023001E3A1F /* PlaybackPausedEvent.swift in Sources */, + 4730618F26591E9C001E3A1F /* ContextChangedEvent.swift in Sources */, + 4730616526565841001E3A1F /* Library.swift in Sources */, + 4730619926591F92001E3A1F /* VolumeChangedEvent.swift in Sources */, + 4730619726591F14001E3A1F /* MetadataAvailableEvent.swift in Sources */, + 4730617B265903A6001E3A1F /* EventBroker.swift in Sources */, + 470201CA265CF9380030ECA9 /* ShuffleButton.swift in Sources */, + 4730619326591EE1001E3A1F /* TrackChangedEvent.swift in Sources */, + 4730619526591EF9001E3A1F /* PlaybackResumedEvent.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -329,6 +573,25 @@ defaultConfigurationName = Release; }; /* End XCConfigurationList section */ + +/* Begin XCRemoteSwiftPackageReference section */ + 470201C4265CF4560030ECA9 /* XCRemoteSwiftPackageReference "SDWebImageSwiftUI" */ = { + isa = XCRemoteSwiftPackageReference; + repositoryURL = "https://github.com/SDWebImage/SDWebImageSwiftUI"; + requirement = { + kind = upToNextMajorVersion; + minimumVersion = 2.0.2; + }; + }; +/* End XCRemoteSwiftPackageReference section */ + +/* Begin XCSwiftPackageProductDependency section */ + 470201C5265CF4560030ECA9 /* SDWebImageSwiftUI */ = { + isa = XCSwiftPackageProductDependency; + package = 470201C4265CF4560030ECA9 /* XCRemoteSwiftPackageReference "SDWebImageSwiftUI" */; + productName = SDWebImageSwiftUI; + }; +/* End XCSwiftPackageProductDependency section */ }; rootObject = 47306142265656ED001E3A1F /* Project object */; } diff --git a/Spottie.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Spottie.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved new file mode 100644 index 0000000..aaf57ac --- /dev/null +++ b/Spottie.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -0,0 +1,25 @@ +{ + "object": { + "pins": [ + { + "package": "SDWebImage", + "repositoryURL": "https://github.com/SDWebImage/SDWebImage.git", + "state": { + "branch": null, + "revision": "76dd4b49110b8624317fc128e7fa0d8a252018bc", + "version": "5.11.1" + } + }, + { + "package": "SDWebImageSwiftUI", + "repositoryURL": "https://github.com/SDWebImage/SDWebImageSwiftUI", + "state": { + "branch": null, + "revision": "cd8625b7cf11a97698e180d28bb7d5d357196678", + "version": "2.0.2" + } + } + ] + }, + "version": 1 +} diff --git a/Spottie/Backend/EventBroker.swift b/Spottie/Backend/EventBroker.swift index cc802fa..7593a6a 100644 --- a/Spottie/Backend/EventBroker.swift +++ b/Spottie/Backend/EventBroker.swift @@ -6,3 +6,31 @@ // import Foundation +import Combine + +class EventBroker : ObservableObject { + private let websocketClient = WebsocketClient(url: URL(string: "ws://localhost:24879/events")!) + private var cancellables = [AnyCancellable]() + private let decoder = JSONDecoder() + + let onEventReceived = PassthroughSubject() + + init() { + websocketClient + .onMessageReceived + .receive(on: DispatchQueue.main) + .print() + .sink { [weak self] message in + do { + guard let self = self else { + return; + } + let data = message.data(using: .utf8)! + let event = try self.decoder.decode(SpotifyEvent.self, from: data) + self.onEventReceived.send(event) + } catch let error { + print(error) + } + }.store(in: &cancellables) + } +} diff --git a/Spottie/Backend/HTTPClient.swift b/Spottie/Backend/HTTPClient.swift index b593110..50c51c6 100644 --- a/Spottie/Backend/HTTPClient.swift +++ b/Spottie/Backend/HTTPClient.swift @@ -3,6 +3,35 @@ // Spottie // // Created by Lee Jun Kit on 20/5/21. -// +// https://www.vadimbulavin.com/modern-networking-in-swift-5-with-urlsession-combine-framework-and-codable/ import Foundation +import Combine + +struct HTTPClient { + struct Response { + let value: T? + let response: URLResponse + } + + func run(_ request: URLRequest, _ decoder: JSONDecoder = JSONDecoder()) -> AnyPublisher, Error> { + return URLSession.shared + .dataTaskPublisher(for: request) + .tryMap { result -> Response in + let response = result.response as! HTTPURLResponse + let contentType = response.allHeaderFields["Content-Type"] as? String + print(response.allHeaderFields) + + if let contentType = contentType { + if contentType.contains("application/json") { + let value = try decoder.decode(T.self, from: result.data) + return Response(value: value, response: result.response) + } + } + + return Response(value: nil, response: result.response) + } + .receive(on: DispatchQueue.main) + .eraseToAnyPublisher() + } +} diff --git a/Spottie/Backend/SpotifyAPI.swift b/Spottie/Backend/SpotifyAPI.swift index ed39075..56d08dd 100644 --- a/Spottie/Backend/SpotifyAPI.swift +++ b/Spottie/Backend/SpotifyAPI.swift @@ -6,3 +6,42 @@ // import Foundation +import Combine + +enum SpotifyAPI { + static let client = HTTPClient() + static let base = URL(string: "http://localhost:24879")! +} + +extension SpotifyAPI { + static func currentPlayerState() -> AnyPublisher { + let decoder = JSONDecoder() + decoder.keyDecodingStrategy = .convertFromSnakeCase; + let req = URLRequest(url: base.appendingPathComponent("/web-api/v1/me/player")) + return client.run(req, decoder).map(\.value).eraseToAnyPublisher() + } + + static func pause() -> AnyPublisher { + var req = URLRequest(url: base.appendingPathComponent("/player/pause")) + req.httpMethod = "POST" + return client.run(req).map(\.value).eraseToAnyPublisher() + } + + static func resume() -> AnyPublisher { + var req = URLRequest(url: base.appendingPathComponent("/player/resume")) + req.httpMethod = "POST" + return client.run(req).print().map(\.value).eraseToAnyPublisher() + } + + static func previousTrack() -> AnyPublisher { + var req = URLRequest(url: base.appendingPathComponent("/player/prev")) + req.httpMethod = "POST" + return client.run(req).print().map(\.value).eraseToAnyPublisher() + } + + static func nextTrack() -> AnyPublisher { + var req = URLRequest(url: base.appendingPathComponent("/player/next")) + req.httpMethod = "POST" + return client.run(req).print().map(\.value).eraseToAnyPublisher() + } +} diff --git a/Spottie/Backend/Types/API/CurrentlyPlayingContextObject.swift b/Spottie/Backend/Types/API/CurrentlyPlayingContextObject.swift index e416090..f8ee15d 100644 --- a/Spottie/Backend/Types/API/CurrentlyPlayingContextObject.swift +++ b/Spottie/Backend/Types/API/CurrentlyPlayingContextObject.swift @@ -5,8 +5,16 @@ // Created by Lee Jun Kit on 23/5/21. // -struct CurrentPlayerStateResponse: Codable { +enum RepeatState: String, Codable { + case off + case track + case context +} + +struct CurrentlyPlayingContextObject: Codable { var isPlaying: Bool var shuffleState: Bool - + var repeatState: RepeatState + var progressMs: Int + var item: WebAPITrackObject } diff --git a/Spottie/Backend/Types/Base/AlbumObject.swift b/Spottie/Backend/Types/Base/AlbumObject.swift index a43f246..c88102c 100644 --- a/Spottie/Backend/Types/Base/AlbumObject.swift +++ b/Spottie/Backend/Types/Base/AlbumObject.swift @@ -8,7 +8,7 @@ struct AlbumObject: Codable { var gid: String var name: String - var artist: ArtistObject + var artist: [ArtistObject] var label: String var date: DateObject var coverGroup: CoverGroupObject diff --git a/Spottie/Backend/Types/Base/CoverGroupObject.swift b/Spottie/Backend/Types/Base/CoverGroupObject.swift index db5ddd0..023baa3 100644 --- a/Spottie/Backend/Types/Base/CoverGroupObject.swift +++ b/Spottie/Backend/Types/Base/CoverGroupObject.swift @@ -4,7 +4,12 @@ // // Created by Lee Jun Kit on 22/5/21. // +import Foundation struct CoverGroupObject: Codable { var image: [ImageObject] + func getArtworkURL() -> URL? { + let fileId = self.image[0].fileId.lowercased() + return URL(string: "https://i.scdn.co/image/\(fileId)") + } } diff --git a/Spottie/Backend/Types/Base/TrackObject.swift b/Spottie/Backend/Types/Base/TrackObject.swift index 39db0a1..091aa3c 100644 --- a/Spottie/Backend/Types/Base/TrackObject.swift +++ b/Spottie/Backend/Types/Base/TrackObject.swift @@ -9,9 +9,9 @@ struct TrackObject: Codable { var gid: String var name: String var album: AlbumObject - var artist: ArtistObject + var artist: [ArtistObject] var number: Int var discNumber: Int var duration: Int - var popularity: Int + var popularity: Int } diff --git a/Spottie/Backend/Types/Base/WebAPIArtistObject.swift b/Spottie/Backend/Types/Base/WebAPIArtistObject.swift index f76c22b..045676f 100644 --- a/Spottie/Backend/Types/Base/WebAPIArtistObject.swift +++ b/Spottie/Backend/Types/Base/WebAPIArtistObject.swift @@ -5,4 +5,10 @@ // Created by Lee Jun Kit on 24/5/21. // -import Foundation +struct WebAPIArtistObject: Codable { + var id: String + var uri: String + var name: String + var images: [WebAPIImageObject] + var popularity: Int +} diff --git a/Spottie/Backend/Types/Base/WebAPIImageObject.swift b/Spottie/Backend/Types/Base/WebAPIImageObject.swift index 9ab1a70..4f8322b 100644 --- a/Spottie/Backend/Types/Base/WebAPIImageObject.swift +++ b/Spottie/Backend/Types/Base/WebAPIImageObject.swift @@ -5,4 +5,8 @@ // Created by Lee Jun Kit on 24/5/21. // -import Foundation +struct WebAPIImageObject: Codable { + var url: String + var width: Int? + var height: Int? +} diff --git a/Spottie/Backend/Types/Base/WebAPISimplifiedAlbumObject.swift b/Spottie/Backend/Types/Base/WebAPISimplifiedAlbumObject.swift index fd3d4b8..3d716ac 100644 --- a/Spottie/Backend/Types/Base/WebAPISimplifiedAlbumObject.swift +++ b/Spottie/Backend/Types/Base/WebAPISimplifiedAlbumObject.swift @@ -5,6 +5,32 @@ // Created by Lee Jun Kit on 24/5/21. // -struct WebAPISimplifiedArtistObject: Codable { +import Foundation + +enum AlbumType: String, Codable { + case album + case single + case compilation +} + +enum ReleaseDatePrecision: String, Codable { + case year + case month + case day +} + +struct WebAPISimplifiedAlbumObject: Codable { + var id: String + var uri: String + var albumType: AlbumType + var artists: [WebAPISimplifiedArtistObject] + var images: [WebAPIImageObject] + var name: String + var releaseDate: String + var releaseDatePrecision: ReleaseDatePrecision + var totalTracks: Int + func getArtworkURL() -> URL? { + return URL(string: self.images[0].url) + } } diff --git a/Spottie/Backend/Types/Base/WebAPISimplifiedArtistObject.swift b/Spottie/Backend/Types/Base/WebAPISimplifiedArtistObject.swift index baa9830..dc06b09 100644 --- a/Spottie/Backend/Types/Base/WebAPISimplifiedArtistObject.swift +++ b/Spottie/Backend/Types/Base/WebAPISimplifiedArtistObject.swift @@ -5,4 +5,8 @@ // Created by Lee Jun Kit on 24/5/21. // -import Foundation +struct WebAPISimplifiedArtistObject: Codable { + var id: String + var uri: String + var name: String +} diff --git a/Spottie/Backend/Types/Base/WebAPITrackObject.swift b/Spottie/Backend/Types/Base/WebAPITrackObject.swift index 8d00a79..0c3c048 100644 --- a/Spottie/Backend/Types/Base/WebAPITrackObject.swift +++ b/Spottie/Backend/Types/Base/WebAPITrackObject.swift @@ -5,4 +5,15 @@ // Created by Lee Jun Kit on 24/5/21. // -import Foundation +struct WebAPITrackObject: Codable { + var id: String + var uri: String + var album: WebAPISimplifiedAlbumObject + var artists: [WebAPISimplifiedArtistObject] + var durationMs: Int + var explicit: Bool + var name: String + var popularity: Int + var discNumber: Int + var trackNumber: Int +} diff --git a/Spottie/Backend/Types/Events/ContextChangedEvent.swift b/Spottie/Backend/Types/Events/ContextChangedEvent.swift index 0e8d113..0b49ac0 100644 --- a/Spottie/Backend/Types/Events/ContextChangedEvent.swift +++ b/Spottie/Backend/Types/Events/ContextChangedEvent.swift @@ -5,4 +5,6 @@ // Created by Lee Jun Kit on 22/5/21. // -import Foundation +struct ContextChangedEvent: Codable { + var uri: String +} diff --git a/Spottie/Backend/Types/Events/InactiveSessionEvent.swift b/Spottie/Backend/Types/Events/InactiveSessionEvent.swift index bb88bed..c3efe8f 100644 --- a/Spottie/Backend/Types/Events/InactiveSessionEvent.swift +++ b/Spottie/Backend/Types/Events/InactiveSessionEvent.swift @@ -5,4 +5,6 @@ // Created by Lee Jun Kit on 22/5/21. // -import Foundation +struct InactiveSessionEvent: Codable { + var timeout: Bool +} diff --git a/Spottie/Backend/Types/Events/MetadataAvailableEvent.swift b/Spottie/Backend/Types/Events/MetadataAvailableEvent.swift index ecb1de4..0ca6cdb 100644 --- a/Spottie/Backend/Types/Events/MetadataAvailableEvent.swift +++ b/Spottie/Backend/Types/Events/MetadataAvailableEvent.swift @@ -5,4 +5,6 @@ // Created by Lee Jun Kit on 22/5/21. // -import Foundation +struct MetadataAvailableEvent: Codable { + var track: TrackObject +} diff --git a/Spottie/Backend/Types/Events/PlaybackPausedEvent.swift b/Spottie/Backend/Types/Events/PlaybackPausedEvent.swift index a2a9c32..aac9dcb 100644 --- a/Spottie/Backend/Types/Events/PlaybackPausedEvent.swift +++ b/Spottie/Backend/Types/Events/PlaybackPausedEvent.swift @@ -5,4 +5,6 @@ // Created by Lee Jun Kit on 22/5/21. // -import Foundation +struct PlaybackPausedEvent: Codable { + var trackTime: Int +} diff --git a/Spottie/Backend/Types/Events/PlaybackResumedEvent.swift b/Spottie/Backend/Types/Events/PlaybackResumedEvent.swift index 4bc39bd..0603bfc 100644 --- a/Spottie/Backend/Types/Events/PlaybackResumedEvent.swift +++ b/Spottie/Backend/Types/Events/PlaybackResumedEvent.swift @@ -5,4 +5,6 @@ // Created by Lee Jun Kit on 22/5/21. // -import Foundation +struct PlaybackResumedEvent: Codable { + var trackTime: Int +} diff --git a/Spottie/Backend/Types/Events/TrackChangedEvent.swift b/Spottie/Backend/Types/Events/TrackChangedEvent.swift index b40a888..99e358c 100644 --- a/Spottie/Backend/Types/Events/TrackChangedEvent.swift +++ b/Spottie/Backend/Types/Events/TrackChangedEvent.swift @@ -5,4 +5,7 @@ // Created by Lee Jun Kit on 22/5/21. // -import Foundation +struct TrackChangedEvent: Codable { + var uri: String + var track: TrackObject? +} diff --git a/Spottie/Backend/Types/Events/TrackSeekedEvent.swift b/Spottie/Backend/Types/Events/TrackSeekedEvent.swift index e3df294..c3362e6 100644 --- a/Spottie/Backend/Types/Events/TrackSeekedEvent.swift +++ b/Spottie/Backend/Types/Events/TrackSeekedEvent.swift @@ -5,4 +5,6 @@ // Created by Lee Jun Kit on 22/5/21. // -import Foundation +struct TrackSeekedEvent: Codable { + var trackTime: Int +} diff --git a/Spottie/Backend/Types/Events/VolumeChangedEvent.swift b/Spottie/Backend/Types/Events/VolumeChangedEvent.swift index 2860ade..aff7a87 100644 --- a/Spottie/Backend/Types/Events/VolumeChangedEvent.swift +++ b/Spottie/Backend/Types/Events/VolumeChangedEvent.swift @@ -5,4 +5,6 @@ // Created by Lee Jun Kit on 22/5/21. // -import Foundation +struct VolumeChangedEvent: Codable { + var value: Float +} diff --git a/Spottie/Backend/Types/Nothing.swift b/Spottie/Backend/Types/Nothing.swift index 71866af..7aa076a 100644 --- a/Spottie/Backend/Types/Nothing.swift +++ b/Spottie/Backend/Types/Nothing.swift @@ -5,4 +5,6 @@ // Created by Lee Jun Kit on 20/5/21. // -import Foundation +struct Nothing: Codable { + +} diff --git a/Spottie/Backend/Types/SpotifyEvent.swift b/Spottie/Backend/Types/SpotifyEvent.swift index 2fddc29..206821e 100644 --- a/Spottie/Backend/Types/SpotifyEvent.swift +++ b/Spottie/Backend/Types/SpotifyEvent.swift @@ -1,5 +1,5 @@ // -// WebsocketMessage.swift +// SpotifyEvent.swift // Spottie // // Created by Lee Jun Kit on 22/5/21. @@ -23,6 +23,71 @@ enum EventType: String, Codable { case panic } -struct WebsocketMessage: Codable { - var type: EventType +enum EventData { + case contextChanged(ContextChangedEvent) + case trackChanged(TrackChangedEvent) + case playbackEnded(Nothing) + case playbackPaused(PlaybackPausedEvent) + case playbackResumed(PlaybackResumedEvent) + case volumeChanged(VolumeChangedEvent) + case trackSeeked(TrackSeekedEvent) + case metadataAvailable(MetadataAvailableEvent) + case playbackHaltStateChanged(Nothing) + case sessionCleared(Nothing) + case sessionChanged(Nothing) + case inactiveSession(InactiveSessionEvent) + case connectionDropped(Nothing) + case connectionEstablished(Nothing) + case panic(Nothing) +} + +struct SpotifyEvent: Decodable { + var event: EventType + var data: EventData + enum CodingKeys: String, CodingKey { + case event + case data + } + + init(from decoder: Decoder) throws { + let values = try decoder.container(keyedBy: CodingKeys.self) + guard let eventName = EventType(rawValue: try values.decode(String.self, forKey:.event)) else { + throw SpottieError.invalidEventName + } + + event = eventName + let container = try decoder.singleValueContainer() + switch event { + case .contextChanged: + data = EventData.contextChanged(try container.decode(ContextChangedEvent.self)) + case .trackChanged: + data = EventData.trackChanged(try container.decode(TrackChangedEvent.self)) + case .playbackEnded: + data = EventData.playbackEnded(Nothing()) + case .playbackPaused: + data = EventData.playbackPaused(try container.decode(PlaybackPausedEvent.self)) + case .playbackResumed: + data = EventData.playbackResumed(try container.decode(PlaybackResumedEvent.self)) + case .volumeChanged: + data = EventData.volumeChanged(try container.decode(VolumeChangedEvent.self)) + case .trackSeeked: + data = EventData.trackSeeked(try container.decode(TrackSeekedEvent.self)) + case .metadataAvailable: + data = EventData.metadataAvailable(try container.decode(MetadataAvailableEvent.self)) + case .playbackHaltStateChanged: + data = EventData.playbackHaltStateChanged(Nothing()) + case .sessionCleared: + data = EventData.sessionCleared(Nothing()) + case .sessionChanged: + data = EventData.sessionChanged(Nothing()) + case .inactiveSession: + data = EventData.inactiveSession(try container.decode(InactiveSessionEvent.self)) + case .connectionDropped: + data = EventData.connectionDropped(Nothing()) + case .connectionEstablished: + data = EventData.connectionEstablished(Nothing()) + case .panic: + data = EventData.panic(Nothing()) + } + } } diff --git a/Spottie/Backend/Types/SpottieError.swift b/Spottie/Backend/Types/SpottieError.swift index 1b585b0..e054de1 100644 --- a/Spottie/Backend/Types/SpottieError.swift +++ b/Spottie/Backend/Types/SpottieError.swift @@ -5,4 +5,6 @@ // Created by Lee Jun Kit on 22/5/21. // -import Foundation +enum SpottieError: Error { + case invalidEventName +} diff --git a/Spottie/Backend/WebsocketClient.swift b/Spottie/Backend/WebsocketClient.swift index 49fa91f..2ba15b9 100644 --- a/Spottie/Backend/WebsocketClient.swift +++ b/Spottie/Backend/WebsocketClient.swift @@ -8,7 +8,16 @@ import Foundation import Combine +enum ConnectionState { + case disconnected + case connecting + case connected +} + class WebsocketClient: NSObject, URLSessionWebSocketDelegate { + let connectionState = CurrentValueSubject(.disconnected) + let onMessageReceived = PassthroughSubject() + var urlSession: URLSession! var websocketTask: URLSessionWebSocketTask! let delegateQueue = OperationQueue() @@ -22,37 +31,39 @@ class WebsocketClient: NSObject, URLSessionWebSocketDelegate { // MARK: - URLSessionWebSocketDelegate func urlSession(_ session: URLSession, webSocketTask: URLSessionWebSocketTask, didOpenWithProtocol protocol: String?) { - + connectionState.send(.connected) } func urlSession(_ session: URLSession, webSocketTask: URLSessionWebSocketTask, didCloseWith closeCode: URLSessionWebSocketTask.CloseCode, reason: Data?) { - + connectionState.send(.disconnected) } // MARK: - Receiving events func connect() { + connectionState.send(.connecting) websocketTask.resume() listen() } func listen() { websocketTask.receive {[weak self] result in + guard let self = self else { + return; + } + switch result { case .success(let response): switch response { - case .data(_): - print("data received") + case .data(_): break case .string(let message): - print("string received") - print(message) - @unknown default: - print("Unknown default") + self.onMessageReceived.send(message) + @unknown default: break } case .failure(let error): print("Error: \(error)") } - self?.listen() + self.listen() } } } diff --git a/Spottie/Info.plist b/Spottie/Info.plist index 69c84ae..aa0e776 100644 --- a/Spottie/Info.plist +++ b/Spottie/Info.plist @@ -2,6 +2,11 @@ + NSAppTransportSecurity + + NSAllowsArbitraryLoads + + CFBundleDevelopmentRegion $(DEVELOPMENT_LANGUAGE) CFBundleExecutable diff --git a/Spottie/Spottie.entitlements b/Spottie/Spottie.entitlements index f2ef3ae..625af03 100644 --- a/Spottie/Spottie.entitlements +++ b/Spottie/Spottie.entitlements @@ -2,9 +2,11 @@ - com.apple.security.app-sandbox - - com.apple.security.files.user-selected.read-only - + com.apple.security.app-sandbox + + com.apple.security.files.user-selected.read-only + + com.apple.security.network.client + diff --git a/Spottie/SpottieApp.swift b/Spottie/SpottieApp.swift index 708b383..e110713 100644 --- a/Spottie/SpottieApp.swift +++ b/Spottie/SpottieApp.swift @@ -9,9 +9,12 @@ import SwiftUI @main struct SpottieApp: App { + @StateObject private var playerViewModel = PlayerViewModel(EventBroker()) + var body: some Scene { WindowGroup { - ContentView() + ContentView() + .environmentObject(playerViewModel) } } } diff --git a/Spottie/ViewModels/FakePlayerViewModel.swift b/Spottie/ViewModels/FakePlayerViewModel.swift index 1c779c6..63d9941 100644 --- a/Spottie/ViewModels/FakePlayerViewModel.swift +++ b/Spottie/ViewModels/FakePlayerViewModel.swift @@ -6,3 +6,17 @@ // import Foundation + +final class FakePlayerViewModel: PlayerStateProtocol { + var isPlaying = false + var durationMs = 1200 + var progressMs = 3600 + + var trackName = "Track Name" + var artistName = "Artist Name" + var artworkURL = URL(string: "https://i.scdn.co/image/ab67616d00004851a48964b5d9a3d6968ae3e0de") + + func onPlayPauseButtonTapped() {} + func onNextTrackButtonTapped() {} + func onPreviousTrackButtonTapped() {} +} diff --git a/Spottie/ViewModels/PlayerStateProtocol.swift b/Spottie/ViewModels/PlayerStateProtocol.swift index ad78bc4..e5ec685 100644 --- a/Spottie/ViewModels/PlayerStateProtocol.swift +++ b/Spottie/ViewModels/PlayerStateProtocol.swift @@ -6,3 +6,17 @@ // import Foundation +import Combine + +protocol PlayerStateProtocol: ObservableObject { + var isPlaying: Bool { get set } + var trackName: String { get set } + var artistName: String { get set } + var artworkURL: URL? { get set } + var durationMs: Int { get set } + var progressMs: Int { get set } + + func onPlayPauseButtonTapped() -> Void + func onNextTrackButtonTapped() -> Void + func onPreviousTrackButtonTapped() -> Void +} diff --git a/Spottie/ViewModels/PlayerViewModel.swift b/Spottie/ViewModels/PlayerViewModel.swift index dffb859..abe87d7 100644 --- a/Spottie/ViewModels/PlayerViewModel.swift +++ b/Spottie/ViewModels/PlayerViewModel.swift @@ -8,6 +8,81 @@ import Foundation import Combine -class SpotifyState: ObservableObject { - @Published var currentTrackTitle = ""; +class PlayerViewModel: PlayerStateProtocol { + @Published var isPlaying = false + @Published var durationMs = 0 + @Published var progressMs = 0 + @Published var trackName = "" + @Published var artistName = "" + @Published var artworkURL: URL? + + + private var cancellables = [AnyCancellable]() + private var eventBroker: EventBroker + + init(_ eventBroker: EventBroker) { + self.eventBroker = eventBroker + let token = SpotifyAPI.currentPlayerState().sink(receiveCompletion: {[weak self] status in + if case .failure(let error) = status { + print(error) + } + + guard let self = self else { return } + + // subscribe to events + eventBroker.onEventReceived.sink(receiveValue: {[weak self] event in + switch (event.data) { + case .playbackEnded(_): + fallthrough + case .playbackPaused(_): + self?.isPlaying = false + case .playbackResumed(_): + self?.isPlaying = true + case let .trackChanged(trackChangedEvent): + if let track = trackChangedEvent.track { + self?.trackName = track.name + self?.artistName = track.artist[0].name + } + case let .metadataAvailable(metadataAvailableEvent): + self?.trackName = metadataAvailableEvent.track.name + self?.artistName = metadataAvailableEvent.track.artist[0].name + self?.artworkURL = metadataAvailableEvent.track.album.coverGroup.getArtworkURL() + self?.durationMs = metadataAvailableEvent.track.duration + self?.progressMs = 0 + default: + break; + } + }).store(in: &self.cancellables) + }) {[weak self] context in + if let ctx = context { + self?.isPlaying = ctx.isPlaying + self?.trackName = ctx.item.name + self?.artistName = ctx.item.artists[0].name + self?.artworkURL = ctx.item.album.getArtworkURL() + self?.durationMs = ctx.item.durationMs + self?.progressMs = ctx.progressMs + } + } + + token.store(in: &cancellables) + } + + func onPlayPauseButtonTapped() { + var apiCall: AnyPublisher; + if self.isPlaying { + apiCall = SpotifyAPI.pause() + } else { + apiCall = SpotifyAPI.resume() + } + + apiCall.print().sink { _ in } receiveValue: { _ in }.store(in: &cancellables) + } + + func onNextTrackButtonTapped() { + SpotifyAPI.nextTrack().sink { _ in } receiveValue: { _ in }.store(in: &cancellables) + } + + func onPreviousTrackButtonTapped() { + SpotifyAPI.previousTrack().sink { _ in } receiveValue: { _ in }.store(in: &cancellables) + } } diff --git a/Spottie/Views/BottomBar.swift b/Spottie/Views/BottomBar.swift index c19a571..61dda01 100644 --- a/Spottie/Views/BottomBar.swift +++ b/Spottie/Views/BottomBar.swift @@ -7,14 +7,24 @@ import SwiftUI -struct BottomBar: View { +struct BottomBar: View { + @EnvironmentObject var viewModel: M var body: some View { - Text(/*@START_MENU_TOKEN@*/"Hello, World!"/*@END_MENU_TOKEN@*/) + HStack(spacing: 40) { + NowPlaying( + trackName: viewModel.trackName, + artistName: viewModel.artistName, + artworkURL: viewModel.artworkURL + ) + .frame(width: 240, alignment: .leading) + PlayerControls() + } } } struct BottomBar_Previews: PreviewProvider { static var previews: some View { - BottomBar() + BottomBar() + .environmentObject(FakePlayerViewModel()) } } diff --git a/Spottie/Views/Components/NextTrackButton.swift b/Spottie/Views/Components/NextTrackButton.swift index 16c6948..b89a9f9 100644 --- a/Spottie/Views/Components/NextTrackButton.swift +++ b/Spottie/Views/Components/NextTrackButton.swift @@ -7,14 +7,24 @@ import SwiftUI -struct NextTrackButton: View { +struct NextTrackButton: View { + @EnvironmentObject var viewModel: M + var body: some View { - Text(/*@START_MENU_TOKEN@*/"Hello, World!"/*@END_MENU_TOKEN@*/) + Button(action: { + viewModel.onNextTrackButtonTapped() + }) { + Image(systemName: "forward.end.fill") + .resizable() + .frame(width: 12, height: 12) + } + .buttonStyle(BorderlessButtonStyle()) } } struct NextTrackButton_Previews: PreviewProvider { static var previews: some View { - NextTrackButton() + NextTrackButton() + .environmentObject(FakePlayerViewModel()) } } diff --git a/Spottie/Views/Components/NowPlaying.swift b/Spottie/Views/Components/NowPlaying.swift index 58aff9b..ffbdd9c 100644 --- a/Spottie/Views/Components/NowPlaying.swift +++ b/Spottie/Views/Components/NowPlaying.swift @@ -6,10 +6,30 @@ // import SwiftUI +import SDWebImageSwiftUI struct NowPlaying: View { + var trackName = "" + var artistName = "" + var artworkURL: URL? + var body: some View { - Text(/*@START_MENU_TOKEN@*/"Hello, World!"/*@END_MENU_TOKEN@*/) + HStack(spacing: 12) { + WebImage(url: artworkURL) + .resizable() + .frame(width: 56, height: 56) + VStack(alignment: .leading, spacing: 4) { + Text(trackName) + Text(artistName) + .foregroundColor(.secondary) + } + Button(action: { + print("Like button was tapped") + }) { + Image(systemName: "heart") + } + .buttonStyle(BorderlessButtonStyle()) + } } } diff --git a/Spottie/Views/Components/PlayPauseButton.swift b/Spottie/Views/Components/PlayPauseButton.swift index b2c64a2..b07a2fb 100644 --- a/Spottie/Views/Components/PlayPauseButton.swift +++ b/Spottie/Views/Components/PlayPauseButton.swift @@ -6,15 +6,29 @@ // import SwiftUI +import Combine -struct PlayPauseButton: View { +struct PlayPauseButton: View { + @EnvironmentObject var viewModel: M + var body: some View { - Text(/*@START_MENU_TOKEN@*/"Hello, World!"/*@END_MENU_TOKEN@*/) + Button(action: { + viewModel.onPlayPauseButtonTapped() + }) { + Image(systemName: viewModel.isPlaying ? "pause.circle.fill" : "play.circle.fill") + .resizable() + .clipShape(/*@START_MENU_TOKEN@*/Circle()/*@END_MENU_TOKEN@*/) + .frame(width: 32, height: 32) + } + .buttonStyle(BorderlessButtonStyle()) } } struct PlayPauseButton_Previews: PreviewProvider { + + static var previews: some View { - PlayPauseButton() + PlayPauseButton() + .environmentObject(FakePlayerViewModel()) } } diff --git a/Spottie/Views/Components/PlayerControls.swift b/Spottie/Views/Components/PlayerControls.swift index df236ab..d73c578 100644 --- a/Spottie/Views/Components/PlayerControls.swift +++ b/Spottie/Views/Components/PlayerControls.swift @@ -7,14 +7,27 @@ import SwiftUI -struct PlayerControls: View { +struct PlayerControls: View { var body: some View { - Text(/*@START_MENU_TOKEN@*/"Hello, World!"/*@END_MENU_TOKEN@*/) + VStack { + HStack(spacing: 28) { + ShuffleButton() + PreviousTrackButton() + PlayPauseButton() + NextTrackButton() + RepeatButton() + } + TrackProgressSlider() + .padding(.leading) + .padding(.trailing) + } } } struct PlayerControls_Previews: PreviewProvider { static var previews: some View { - PlayerControls() + PlayerControls() + .environmentObject(FakePlayerViewModel()) } } + diff --git a/Spottie/Views/Components/PreviousTrackButton.swift b/Spottie/Views/Components/PreviousTrackButton.swift index 39abf01..f052e66 100644 --- a/Spottie/Views/Components/PreviousTrackButton.swift +++ b/Spottie/Views/Components/PreviousTrackButton.swift @@ -7,14 +7,24 @@ import SwiftUI -struct PreviousTrackButton: View { +struct PreviousTrackButton: View { + @EnvironmentObject var viewModel: M + var body: some View { - Text(/*@START_MENU_TOKEN@*/"Hello, World!"/*@END_MENU_TOKEN@*/) + Button(action: { + viewModel.onPreviousTrackButtonTapped() + }) { + Image(systemName: "backward.end.fill") + .resizable() + .frame(width: 12, height: 12) + } + .buttonStyle(BorderlessButtonStyle()) } } struct PreviousTrackButton_Previews: PreviewProvider { static var previews: some View { - PreviousTrackButton() + PreviousTrackButton() + .environmentObject(FakePlayerViewModel()) } } diff --git a/Spottie/Views/Components/RepeatButton.swift b/Spottie/Views/Components/RepeatButton.swift index eee81fc..9269d61 100644 --- a/Spottie/Views/Components/RepeatButton.swift +++ b/Spottie/Views/Components/RepeatButton.swift @@ -9,7 +9,13 @@ import SwiftUI struct RepeatButton: View { var body: some View { - Text(/*@START_MENU_TOKEN@*/"Hello, World!"/*@END_MENU_TOKEN@*/) + Button(action: { + }) { + Image(systemName: "repeat") + .resizable() + .frame(width: 12, height: 12) + } + .buttonStyle(BorderlessButtonStyle()) } } diff --git a/Spottie/Views/Components/ShuffleButton.swift b/Spottie/Views/Components/ShuffleButton.swift index f62d315..cce8233 100644 --- a/Spottie/Views/Components/ShuffleButton.swift +++ b/Spottie/Views/Components/ShuffleButton.swift @@ -9,7 +9,13 @@ import SwiftUI struct ShuffleButton: View { var body: some View { - Text(/*@START_MENU_TOKEN@*/"Hello, World!"/*@END_MENU_TOKEN@*/) + Button(action: { + }) { + Image(systemName: "shuffle") + .resizable() + .frame(width: 12, height: 12) + } + .buttonStyle(BorderlessButtonStyle()) } } diff --git a/Spottie/Views/Components/TrackProgressSlider.swift b/Spottie/Views/Components/TrackProgressSlider.swift index e17cc3d..7e5be78 100644 --- a/Spottie/Views/Components/TrackProgressSlider.swift +++ b/Spottie/Views/Components/TrackProgressSlider.swift @@ -8,8 +8,57 @@ import SwiftUI struct TrackProgressSlider: View { + @State var isPlaying = true + @State var progressMs = 0 + @State var durationMs = 60000 + @State var progressPercent = 0.0 + + let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect() + + var prettyProgress: String { + get { + return TrackProgressSlider.DurationFormatter.shared().string(from: Double(self.progressMs) / 1000.0)! + } + } + + var prettyDuration: String { + get { + return TrackProgressSlider.DurationFormatter.shared().string(from: Double(self.durationMs) / 1000.0)! + } + } + var body: some View { - Text(/*@START_MENU_TOKEN@*/"Hello, World!"/*@END_MENU_TOKEN@*/) + Slider( + value: $progressPercent, + minimumValueLabel: Text(prettyProgress).foregroundColor(.secondary), + maximumValueLabel: Text(prettyDuration).foregroundColor(.secondary) + ) { + Text("") + } + .onReceive(timer) { timer in + if isPlaying { + if (progressMs < durationMs) { + progressMs += 1000 + progressPercent = Double(self.progressMs) / Double(self.durationMs) + } + } + } + } +} + +extension TrackProgressSlider { + class DurationFormatter { + private static var sharedDurationFormatter: DateComponentsFormatter = { + let formatter = DateComponentsFormatter() + formatter.unitsStyle = .positional + formatter.allowedUnits = [ .minute, .second ] + formatter.zeroFormattingBehavior = [ .pad ] + return formatter + }() + + class func shared() -> DateComponentsFormatter { + return sharedDurationFormatter + } } } diff --git a/Spottie/Views/ContentView.swift b/Spottie/Views/ContentView.swift index 7737e9f..1fa0d41 100644 --- a/Spottie/Views/ContentView.swift +++ b/Spottie/Views/ContentView.swift @@ -11,19 +11,25 @@ enum Screen: Hashable { case home, search, library } -struct ContentView: View { +struct ContentView: View { @State var screen: Screen? = .home var body: some View { - NavigationView { - Sidebar(state: $screen) + VStack { + NavigationView { + Sidebar(state: $screen) + } + .navigationViewStyle(DoubleColumnNavigationViewStyle()) + BottomBar() + .frame(height: 66) + .padding() } - .navigationViewStyle(DoubleColumnNavigationViewStyle()) } } struct ContentView_Previews: PreviewProvider { static var previews: some View { - ContentView() + ContentView() + .environmentObject(FakePlayerViewModel()) } } diff --git a/Spottie/Views/Home.swift b/Spottie/Views/Home.swift index 34fc912..53f097b 100644 --- a/Spottie/Views/Home.swift +++ b/Spottie/Views/Home.swift @@ -7,14 +7,16 @@ import SwiftUI -struct Home: View { +struct Home: View { + @EnvironmentObject var viewModel: M var body: some View { - Text(/*@START_MENU_TOKEN@*/"Hello, World!"/*@END_MENU_TOKEN@*/) + Text("\(viewModel.trackName) by \(viewModel.artistName)") } } struct Home_Previews: PreviewProvider { static var previews: some View { - Home() + Home() + .environmentObject(FakePlayerViewModel()) } } diff --git a/Spottie/Views/Library.swift b/Spottie/Views/Library.swift index aa8bf0e..ad7c682 100644 --- a/Spottie/Views/Library.swift +++ b/Spottie/Views/Library.swift @@ -9,7 +9,7 @@ import SwiftUI struct Library: View { var body: some View { - Text(/*@START_MENU_TOKEN@*/"Hello, World!"/*@END_MENU_TOKEN@*/) + Text("Hello, Library!") } } diff --git a/Spottie/Views/Search.swift b/Spottie/Views/Search.swift index 8c248b5..dfd93e9 100644 --- a/Spottie/Views/Search.swift +++ b/Spottie/Views/Search.swift @@ -9,7 +9,7 @@ import SwiftUI struct Search: View { var body: some View { - Text(/*@START_MENU_TOKEN@*/"Hello, World!"/*@END_MENU_TOKEN@*/) + Text("Hello, Search!") } } diff --git a/Spottie/Views/Sidebar.swift b/Spottie/Views/Sidebar.swift index 893e4c0..6ad543b 100644 --- a/Spottie/Views/Sidebar.swift +++ b/Spottie/Views/Sidebar.swift @@ -8,13 +8,42 @@ import SwiftUI struct Sidebar: View { + @Binding var state: Screen? + var body: some View { - Text(/*@START_MENU_TOKEN@*/"Hello, World!"/*@END_MENU_TOKEN@*/) + List { + NavigationLink( + destination: Home(), + tag: Screen.home, + selection: $state, + label: { + Label("Home", systemImage: "house" ) + } + ) + NavigationLink( + destination: Search(), + tag: Screen.search, + selection: $state, + label: { + Label("Search", systemImage: "magnifyingglass") + } + ) + NavigationLink( + destination: Library(), + tag: Screen.library, + selection: $state, + label: { + Label("Library", systemImage: "book") + } + ) + } + .listStyle(SidebarListStyle()) + .navigationTitle("Spottie") } } struct Sidebar_Previews: PreviewProvider { static var previews: some View { - Sidebar() + Sidebar(state: .constant(.home)) } }