diff --git a/Spottie.xcodeproj/project.pbxproj b/Spottie.xcodeproj/project.pbxproj index 039daa1..3e53245 100644 --- a/Spottie.xcodeproj/project.pbxproj +++ b/Spottie.xcodeproj/project.pbxproj @@ -55,6 +55,8 @@ 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 */; }; + 474BAFB8266876030006EB16 /* VolumeSlider.swift in Sources */ = {isa = PBXBuildFile; fileRef = 474BAFB7266876030006EB16 /* VolumeSlider.swift */; }; + 474BAFBA26687AB60006EB16 /* WebAPIDeviceObject.swift in Sources */ = {isa = PBXBuildFile; fileRef = 474BAFB926687AB60006EB16 /* WebAPIDeviceObject.swift */; }; /* End PBXBuildFile section */ /* Begin PBXFileReference section */ @@ -108,6 +110,8 @@ 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 = ""; }; + 474BAFB7266876030006EB16 /* VolumeSlider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = VolumeSlider.swift; sourceTree = ""; }; + 474BAFB926687AB60006EB16 /* WebAPIDeviceObject.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WebAPIDeviceObject.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -186,6 +190,7 @@ 470201C2265CF29B0030ECA9 /* NowPlaying.swift */, 470201C7265CF8D90030ECA9 /* PlayerControls.swift */, 470201CD265DE8720030ECA9 /* TrackProgressSlider.swift */, + 474BAFB7266876030006EB16 /* VolumeSlider.swift */, ); path = Components; sourceTree = ""; @@ -254,6 +259,7 @@ 470201B2265B55F30030ECA9 /* WebAPISimplifiedArtistObject.swift */, 470201B4265B56350030ECA9 /* WebAPIImageObject.swift */, 470201B6265B56860030ECA9 /* WebAPIArtistObject.swift */, + 474BAFB926687AB60006EB16 /* WebAPIDeviceObject.swift */, ); path = Base; sourceTree = ""; @@ -346,6 +352,7 @@ 470201BD265B80970030ECA9 /* FakePlayerViewModel.swift in Sources */, 4730618326590931001E3A1F /* TrackObject.swift in Sources */, 470201BB265B7C190030ECA9 /* PlayerStateProtocol.swift in Sources */, + 474BAFBA26687AB60006EB16 /* WebAPIDeviceObject.swift in Sources */, 4730615F2656573B001E3A1F /* Sidebar.swift in Sources */, 470201CC265CF9610030ECA9 /* RepeatButton.swift in Sources */, 4730618B26591E36001E3A1F /* CoverGroupObject.swift in Sources */, @@ -377,6 +384,7 @@ 470201B7265B56860030ECA9 /* WebAPIArtistObject.swift in Sources */, 473061A6265B4A10001E3A1F /* WebAPITrackObject.swift in Sources */, 473061852659164E001E3A1F /* AlbumObject.swift in Sources */, + 474BAFB8266876030006EB16 /* VolumeSlider.swift in Sources */, 4730619D26592023001E3A1F /* PlaybackPausedEvent.swift in Sources */, 4730618F26591E9C001E3A1F /* ContextChangedEvent.swift in Sources */, 4730616526565841001E3A1F /* Library.swift in Sources */, diff --git a/Spottie/Backend/SpotifyAPI.swift b/Spottie/Backend/SpotifyAPI.swift index 29268c5..464ac69 100644 --- a/Spottie/Backend/SpotifyAPI.swift +++ b/Spottie/Backend/SpotifyAPI.swift @@ -54,4 +54,15 @@ extension SpotifyAPI { req.httpMethod = "POST" return client.run(req).print().map(\.value).eraseToAnyPublisher() } + + static func setVolume(volumePercent: Float) -> AnyPublisher { + let rawVolume = Int(volumePercent * 65535) + let queryItems = [URLQueryItem(name: "volume", value: "\(rawVolume)")] + var urlComponents = URLComponents(url: base.appendingPathComponent("/player/set-volume"), resolvingAgainstBaseURL: false)! + urlComponents.queryItems = queryItems + + var req = URLRequest(url: urlComponents.url!) + 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 f8ee15d..46bf677 100644 --- a/Spottie/Backend/Types/API/CurrentlyPlayingContextObject.swift +++ b/Spottie/Backend/Types/API/CurrentlyPlayingContextObject.swift @@ -12,6 +12,7 @@ enum RepeatState: String, Codable { } struct CurrentlyPlayingContextObject: Codable { + var device: WebAPIDeviceObject var isPlaying: Bool var shuffleState: Bool var repeatState: RepeatState diff --git a/Spottie/Backend/Types/Base/WebAPIDeviceObject.swift b/Spottie/Backend/Types/Base/WebAPIDeviceObject.swift new file mode 100644 index 0000000..38602b7 --- /dev/null +++ b/Spottie/Backend/Types/Base/WebAPIDeviceObject.swift @@ -0,0 +1,16 @@ +// +// WebAPIDeviceObject.swift +// Spottie +// +// Created by Lee Jun Kit on 3/6/21. +// + +import Foundation + +struct WebAPIDeviceObject: Codable { + var id: String + var isActive: Bool + var name: String + var type: String + var volumePercent: Float +} diff --git a/Spottie/ViewModels/FakePlayerViewModel.swift b/Spottie/ViewModels/FakePlayerViewModel.swift index f1ad799..13476b5 100644 --- a/Spottie/ViewModels/FakePlayerViewModel.swift +++ b/Spottie/ViewModels/FakePlayerViewModel.swift @@ -8,6 +8,7 @@ import Foundation final class FakePlayerViewModel: PlayerStateProtocol { + var volumePercent : Float = 0.5 var isPlaying = false var durationMs = 1200 var progressMs = 3600 @@ -20,4 +21,5 @@ final class FakePlayerViewModel: PlayerStateProtocol { func nextTrack() {} func togglePlayPause() {} func seek(toPercent: Double) {} + func setVolume(volumePercent: Float) {} } diff --git a/Spottie/ViewModels/PlayerStateProtocol.swift b/Spottie/ViewModels/PlayerStateProtocol.swift index dc913b6..2db044d 100644 --- a/Spottie/ViewModels/PlayerStateProtocol.swift +++ b/Spottie/ViewModels/PlayerStateProtocol.swift @@ -9,6 +9,7 @@ import Foundation import Combine protocol PlayerStateProtocol: ObservableObject { + var volumePercent: Float { get set } var isPlaying: Bool { get set } var trackName: String { get set } var artistName: String { get set } @@ -20,4 +21,5 @@ protocol PlayerStateProtocol: ObservableObject { func nextTrack() -> Void func previousTrack() -> Void func seek(toPercent: Double) -> Void + func setVolume(volumePercent: Float) -> Void } diff --git a/Spottie/ViewModels/PlayerViewModel.swift b/Spottie/ViewModels/PlayerViewModel.swift index 6eb62c5..b29a6c5 100644 --- a/Spottie/ViewModels/PlayerViewModel.swift +++ b/Spottie/ViewModels/PlayerViewModel.swift @@ -9,6 +9,7 @@ import Foundation import Combine class PlayerViewModel: PlayerStateProtocol { + @Published var volumePercent: Float = 0.0 @Published var isPlaying = false @Published var durationMs = 0 @Published var progressMs = 0 @@ -16,7 +17,6 @@ class PlayerViewModel: PlayerStateProtocol { @Published var artistName = "" @Published var artworkURL: URL? - private var cancellables = [AnyCancellable]() private var eventBroker: EventBroker @@ -25,60 +25,66 @@ class PlayerViewModel: PlayerStateProtocol { 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 + } else { guard let self = self else { return } - - switch (event.data) { - case .playbackEnded(_): - self.isPlaying = false - case let .playbackPaused(playbackPausedEvent): - self.progressMs = playbackPausedEvent.trackTime - self.isPlaying = false - case let .playbackResumed(playbackResumedEvent): - self.isPlaying = true - self.progressMs = playbackResumedEvent.trackTime - case let .trackSeeked(trackSeekedEvent): - self.progressMs = trackSeekedEvent.trackTime - case let .trackChanged(trackChangedEvent): - self.progressMs = 0 - self.isPlaying = true - 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 - default: - break; - } - print(" in event recevied: self.durationMs: \(self.durationMs) self.progressMs: \(self.progressMs)") - }).store(in: &self.cancellables) + // subscribe to events + eventBroker.onEventReceived.sink(receiveValue: {[weak self] event in + guard let self = self else { return } + self.onEventReceived(event: event) + }).store(in: &self.cancellables) + } }) {[weak self] context in guard let self = self else { return } 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 + self.handleInitialStateUpdate(context: ctx) } - - print(" in value recevied: self.durationMs: \(self.durationMs) self.progressMs: \(self.progressMs)") } token.store(in: &cancellables) } + func handleInitialStateUpdate(context ctx: CurrentlyPlayingContextObject) { + self.volumePercent = ctx.device.volumePercent + 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 + } + + func onEventReceived(event: SpotifyEvent) { + switch (event.data) { + case let .volumeChanged(volumeChangedEvent): + self.volumePercent = volumeChangedEvent.value + case .playbackEnded(_): + self.isPlaying = false + case let .playbackPaused(playbackPausedEvent): + self.progressMs = playbackPausedEvent.trackTime + self.isPlaying = false + case let .playbackResumed(playbackResumedEvent): + self.isPlaying = true + self.progressMs = playbackResumedEvent.trackTime + case let .trackSeeked(trackSeekedEvent): + self.progressMs = trackSeekedEvent.trackTime + case let .trackChanged(trackChangedEvent): + self.progressMs = 0 + self.isPlaying = true + 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 + default: + break; + } + } + func togglePlayPause() { var apiCall: AnyPublisher; if self.isPlaying { @@ -106,4 +112,9 @@ class PlayerViewModel: PlayerStateProtocol { let posMs = Int(Double(self.durationMs) * toPercent) SpotifyAPI.seek(posMs: posMs).sink { _ in } receiveValue: { _ in }.store(in: &cancellables) } + + func setVolume(volumePercent: Float) { + self.volumePercent = volumePercent + SpotifyAPI.setVolume(volumePercent: volumePercent).sink { _ in } receiveValue: { _ in }.store(in: &cancellables) + } } diff --git a/Spottie/Views/BottomBar.swift b/Spottie/Views/BottomBar.swift index 61dda01..a913580 100644 --- a/Spottie/Views/BottomBar.swift +++ b/Spottie/Views/BottomBar.swift @@ -18,6 +18,11 @@ struct BottomBar: View { ) .frame(width: 240, alignment: .leading) PlayerControls() + VolumeSlider( + volumePercent: viewModel.volumePercent, + onVolumeChanged: viewModel.setVolume + ) + .frame(width: 160, alignment: .trailing) } } } diff --git a/Spottie/Views/Components/TrackProgressSlider.swift b/Spottie/Views/Components/TrackProgressSlider.swift index fc01347..de077e7 100644 --- a/Spottie/Views/Components/TrackProgressSlider.swift +++ b/Spottie/Views/Components/TrackProgressSlider.swift @@ -31,21 +31,30 @@ struct TrackProgressSlider: View { } var body: some View { - Slider( - value: $viewModel.progressPercent, - onEditingChanged: { editing in - if (!editing) { - viewModel.onScrubToNewProgressPercent(viewModel.progressPercent) + HStack { + VStack(alignment: .trailing) { + Text(prettyProgress).foregroundColor(.secondary) + } + .frame(width: 40) + + Slider( + value: $viewModel.progressPercent, + in: 0...1, + onEditingChanged: { editing in + if (!editing) { + viewModel.onScrubToNewProgressPercent(viewModel.progressPercent) + } + viewModel.isScrubbing = editing } - viewModel.isScrubbing = editing - }, - minimumValueLabel: Text(prettyProgress).foregroundColor(.secondary), - maximumValueLabel: Text(prettyDuration).foregroundColor(.secondary) - ) { - EmptyView() - } - .onReceive(timer) { _ in - viewModel.updateProgress() + ) + .onReceive(timer) { _ in + viewModel.updateProgress() + } + + VStack(alignment: .leading) { + Text(prettyDuration).foregroundColor(.secondary) + } + .frame(width: 40) } } } diff --git a/Spottie/Views/Components/VolumeSlider.swift b/Spottie/Views/Components/VolumeSlider.swift new file mode 100644 index 0000000..2bb328b --- /dev/null +++ b/Spottie/Views/Components/VolumeSlider.swift @@ -0,0 +1,51 @@ +// +// VolumeSlider.swift +// Spottie +// +// Created by Lee Jun Kit on 3/6/21. +// + +import SwiftUI + +struct VolumeSlider: View { + var volumePercent: Float + var onVolumeChanged: (Float) -> Void + + var imageName: String { + get { + if (volumePercent > 0.8) { + return "speaker.wave.3.fill" + } else if (volumePercent > 0.5) { + return "speaker.wave.2.fill" + } else if (volumePercent > 0.3) { + return "speaker.wave.1.fill" + } else { + return "speaker.fill" + } + } + } + + var body: some View { + Slider(value: Binding(get: { + self.volumePercent + }, set: { newVolumePercent in + self.onVolumeChanged(newVolumePercent) + }), in: 0...1) { + VStack(alignment: .trailing) { + Image(systemName: imageName) + .foregroundColor(.secondary) + } + .frame(width: 26, height: 26) + } + } +} + +struct VolumeSlider_Previews: PreviewProvider { + static func onVolumeChanged(volumePercent: Float) { + print("new volume: \(volumePercent)") + } + + static var previews: some View { + VolumeSlider(volumePercent: Float(0.5), onVolumeChanged: onVolumeChanged) + } +}