diff --git a/Spottie.xcodeproj/project.pbxproj b/Spottie.xcodeproj/project.pbxproj index ddb69ec..738f3a0 100644 --- a/Spottie.xcodeproj/project.pbxproj +++ b/Spottie.xcodeproj/project.pbxproj @@ -78,6 +78,7 @@ 4764B39F2684001A00AE471E /* TokenObject.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4764B39E2684001A00AE471E /* TokenObject.swift */; }; 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 */; }; 47C56F602679A789003EA20A /* PlayerCommands.swift in Sources */ = {isa = PBXBuildFile; fileRef = 47C56F5F2679A789003EA20A /* PlayerCommands.swift */; }; /* End PBXBuildFile section */ @@ -155,6 +156,7 @@ 4764B39E2684001A00AE471E /* TokenObject.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TokenObject.swift; sourceTree = ""; }; 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 = ""; }; 47C56F5F2679A789003EA20A /* PlayerCommands.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PlayerCommands.swift; sourceTree = ""; }; /* End PBXFileReference section */ @@ -190,6 +192,7 @@ isa = PBXGroup; children = ( 4730616B26565EB8001E3A1F /* Backend */, + 4764B3A62684865B00AE471E /* Utilities */, 4730617526588C40001E3A1F /* ViewModels */, 4730615D26565706001E3A1F /* Views */, 47C56F5E2679A779003EA20A /* Commands */, @@ -354,6 +357,14 @@ path = WebAPI; sourceTree = ""; }; + 4764B3A62684865B00AE471E /* Utilities */ = { + isa = PBXGroup; + children = ( + 4764B3A72684867600AE471E /* DurationFormatter.swift */, + ); + path = Utilities; + sourceTree = ""; + }; 47C56F5E2679A779003EA20A /* Commands */ = { isa = PBXGroup; children = ( @@ -459,6 +470,7 @@ 470201BF265BB4320030ECA9 /* PreviousTrackButton.swift in Sources */, 470201C3265CF29B0030ECA9 /* NowPlaying.swift in Sources */, 475798C3266E309A00AADF2F /* RecommendationItem.swift in Sources */, + 4764B3A82684867600AE471E /* DurationFormatter.swift in Sources */, 4730616A26565BB7001E3A1F /* BottomBar.swift in Sources */, 473061722656629F001E3A1F /* Nothing.swift in Sources */, 474BAFBC2668B0170006EB16 /* RepeatMode.swift in Sources */, diff --git a/Spottie/Utilities/DurationFormatter.swift b/Spottie/Utilities/DurationFormatter.swift new file mode 100644 index 0000000..8472216 --- /dev/null +++ b/Spottie/Utilities/DurationFormatter.swift @@ -0,0 +1,24 @@ +// +// DurationFormatter.swift +// Spottie +// +// Created by Lee Jun Kit on 24/6/21. +// + +import Foundation + +class DurationFormatter { + static let shared = DurationFormatter() + + private let formatter: DateComponentsFormatter + private init() { + formatter = DateComponentsFormatter() + formatter.allowedUnits = [.minute, .second] + formatter.unitsStyle = .positional + formatter.zeroFormattingBehavior = .pad + } + + func format(_ from: TimeInterval) -> String { + return formatter.string(from: from)! + } +} diff --git a/Spottie/ViewModels/PlayerViewModel.swift b/Spottie/ViewModels/PlayerViewModel.swift index 9e88e12..148632d 100644 --- a/Spottie/ViewModels/PlayerViewModel.swift +++ b/Spottie/ViewModels/PlayerViewModel.swift @@ -13,39 +13,67 @@ class PlayerViewModel: PlayerStateProtocol { @Published var isPlaying = false @Published var durationMs = 1 @Published var progressMs = 0 + @Published var progressPercent = 0.0 @Published var trackName = "" @Published var artistName = "" @Published var artworkURL: URL? @Published var isShuffling = false @Published var repeatMode = RepeatMode.none + @Published var isScrubbing = false private var cancellables = [AnyCancellable]() private var eventBroker: EventBroker + private var timerPublisher = Timer.publish(every: 1, on: .main, in: .common).autoconnect() + private var timerSubscription: AnyCancellable? + init(_ eventBroker: EventBroker) { self.eventBroker = eventBroker - let token = SpotifyAPI.currentPlayerState().sink(receiveCompletion: {[weak self] status in - if case .failure(let error) = status { - print(error) - } else { - guard let self = self else { return } - - // 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 } + SpotifyAPI.currentPlayerState().sink(receiveCompletion: {_ in + // subscribe to events + eventBroker.onEventReceived.sink(receiveValue: { [weak self] event in + self?.onEventReceived(event: event) + }).store(in: &self.cancellables) + }) { [weak self] context in if let ctx = context { - self.handleInitialStateUpdate(context: ctx) + self?.handleInitialStateUpdate(context: ctx) } - } - - token.store(in: &cancellables) + }.store(in: &cancellables) + // progress timer to activate only when isPlaying is true + $isPlaying.sink { [weak self] playing in + guard let strongSelf = self else { return } + + if playing { + if strongSelf.timerSubscription == nil { + strongSelf.timerSubscription = strongSelf.timerPublisher.sink { _ in + if !strongSelf.isScrubbing { + strongSelf.progressMs += 1000 + } + } + } + } else { + strongSelf.timerSubscription?.cancel() + strongSelf.timerSubscription = nil + } + }.store(in: &cancellables) + // derive percent progress + Publishers.CombineLatest($progressMs, $durationMs) + .map({ progressMs, durationMs -> Double in + if durationMs == 0 { + return 0.0 + } else { + return Double(progressMs) / Double(durationMs) + } + }) + .sink { [weak self] pc in + guard let strongSelf = self else { return } + if !strongSelf.isScrubbing { + strongSelf.progressPercent = pc + } + } + .store(in: &cancellables) } func handleInitialStateUpdate(context ctx: CurrentlyPlayingContextObject) { diff --git a/Spottie/Views/BottomBar/PlayerControls.swift b/Spottie/Views/BottomBar/PlayerControls.swift index afd8117..782f46a 100644 --- a/Spottie/Views/BottomBar/PlayerControls.swift +++ b/Spottie/Views/BottomBar/PlayerControls.swift @@ -32,9 +32,8 @@ struct PlayerControls: View { onRepeatButtonTapped: viewModel.cycleRepeatMode ) } - TrackProgressSlider(viewModel: TrackProgressSlider.ViewModel(isPlaying: viewModel.isPlaying, progressMs: viewModel.progressMs, durationMs: viewModel.durationMs, onScrubToNewProgressPercent: viewModel.seek)) - .padding(.leading) - .padding(.trailing) + TrackProgressSlider() + .padding([.leading, .trailing]) } } } diff --git a/Spottie/Views/BottomBar/TrackProgressSlider.swift b/Spottie/Views/BottomBar/TrackProgressSlider.swift index de077e7..da7e440 100644 --- a/Spottie/Views/BottomBar/TrackProgressSlider.swift +++ b/Spottie/Views/BottomBar/TrackProgressSlider.swift @@ -6,33 +6,31 @@ // import SwiftUI +import Combine struct TrackProgressSlider: View { - @ObservedObject var viewModel: TrackProgressSlider.ViewModel - - let timer = Timer.publish(every: 1, on: .main, in: .common).autoconnect() + @EnvironmentObject var viewModel: PlayerViewModel var prettyProgress: String { get { - let formatter = TrackProgressSlider.DurationFormatter.shared() - if (self.viewModel.isScrubbing) { - let scrubbedProgress = self.viewModel.progressPercent * Double(self.viewModel.durationMs) - return formatter.string(from: scrubbedProgress / 1000.0)! + if (viewModel.isScrubbing) { + let scrubbedProgress = viewModel.progressPercent * Double(viewModel.durationMs) + return DurationFormatter.shared.format(scrubbedProgress / 1000.0) } else { - return formatter.string(from: Double(self.viewModel.progressMs) / 1000.0)! + return DurationFormatter.shared.format(Double(viewModel.progressMs) / 1000.0) } } } var prettyDuration: String { get { - return TrackProgressSlider.DurationFormatter.shared().string(from: Double(self.viewModel.durationMs) / 1000.0)! + return DurationFormatter.shared.format(Double(viewModel.durationMs) / 1000.0) } } var body: some View { HStack { - VStack(alignment: .trailing) { + VStack { Text(prettyProgress).foregroundColor(.secondary) } .frame(width: 40) @@ -42,14 +40,12 @@ struct TrackProgressSlider: View { in: 0...1, onEditingChanged: { editing in if (!editing) { - viewModel.onScrubToNewProgressPercent(viewModel.progressPercent) + viewModel.seek(toPercent: viewModel.progressPercent) } + viewModel.isScrubbing = editing } ) - .onReceive(timer) { _ in - viewModel.updateProgress() - } VStack(alignment: .leading) { Text(prettyDuration).foregroundColor(.secondary) @@ -59,59 +55,11 @@ struct TrackProgressSlider: View { } } -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 - } - } - - class ViewModel: ObservableObject { - @Published var isPlaying: Bool - @Published var progressMs: Int - @Published var durationMs: Int - @Published var progressPercent: Double - var isScrubbing = false - var onScrubToNewProgressPercent: (_ progressPercent: Double) -> Void - - init(isPlaying: Bool, progressMs: Int, durationMs: Int, onScrubToNewProgressPercent: @escaping (_ progressPercent: Double) -> Void) { - self.isPlaying = isPlaying - self.progressMs = progressMs - self.durationMs = durationMs - self.progressPercent = Double(progressMs) / Double(durationMs) - self.onScrubToNewProgressPercent = onScrubToNewProgressPercent - } - - func calculateProgressPercent() { - self.progressPercent = Double(self.progressMs) / Double(self.durationMs) - } - - func updateProgress() { - if (self.isPlaying) { - if (self.progressMs < self.durationMs) { - self.progressMs += 1000 - if (!self.isScrubbing) { - self.calculateProgressPercent() - } - } - } - } - } -} - -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) - } -} +//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/Components/TrackListItem.swift b/Spottie/Views/Components/TrackListItem.swift index a2bcb23..019f057 100644 --- a/Spottie/Views/Components/TrackListItem.swift +++ b/Spottie/Views/Components/TrackListItem.swift @@ -44,23 +44,6 @@ struct TrackListItem: View { } } -extension TrackListItem { - class DurationFormatter { - static let shared = DurationFormatter() - - private let formatter: DateComponentsFormatter - private init() { - formatter = DateComponentsFormatter() - formatter.allowedUnits = [.hour, .minute, .second] - formatter.unitsStyle = .positional - } - - func format(_ from: TimeInterval) -> String { - return formatter.string(from: from)! - } - } -} - struct TrackListItem_Previews: PreviewProvider { static let viewModel = CarouselRowItem.ViewModel( id: "abc",