Skip to content

Commit

Permalink
WIP SwiftUI views
Browse files Browse the repository at this point in the history
  • Loading branch information
Lee Jun Kit committed Oct 9, 2021
1 parent 35243b6 commit 41df4da
Show file tree
Hide file tree
Showing 13 changed files with 782 additions and 83 deletions.
141 changes: 134 additions & 7 deletions Spottie.xcodeproj/project.pbxproj

Large diffs are not rendered by default.

4 changes: 3 additions & 1 deletion Spottie/SpottieApp.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand Down
39 changes: 39 additions & 0 deletions Spottie/Utilities/DependencyInjector.swift
Original file line number Diff line number Diff line change
@@ -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>() -> 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<T>(dependency: T) {
dependencyList[String(describing: T.self)] = dependency
}
}

@propertyWrapper struct Inject<T> {
var wrappedValue: T

init() {
self.wrappedValue = DependencyInjector.resolve()
}
}

@propertyWrapper struct Provider<T> {
var wrappedValue: T

init(wrappedValue: T) {
self.wrappedValue = wrappedValue
DependencyInjector.register(dependency: wrappedValue)
}
}
8 changes: 2 additions & 6 deletions Spottie/Views/BottomBar/BottomBar.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,13 +11,9 @@ struct BottomBar<M: PlayerStateProtocol>: 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<M>()
PlayerControls()
VolumeSlider(
volumePercent: viewModel.volumePercent,
onVolumeChanged: viewModel.setVolume
Expand Down
43 changes: 37 additions & 6 deletions Spottie/Views/BottomBar/NowPlaying.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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: {
Expand Down
51 changes: 38 additions & 13 deletions Spottie/Views/BottomBar/PlayerControls.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,42 +6,67 @@
//

import SwiftUI
import Combine

struct PlayerControls<M: PlayerStateProtocol>: 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<FakePlayerViewModel>()
.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)
}
}
}

122 changes: 90 additions & 32 deletions Spottie/Views/BottomBar/TrackProgressSlider.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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)
// }
//}
17 changes: 13 additions & 4 deletions Spottie/Views/Home.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 = ""
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -154,7 +163,7 @@ extension Home {

return vm
}
}.store(in: &cancellables)
}
}

func load(_ uri: String) {
Expand Down
Loading

0 comments on commit 41df4da

Please sign in to comment.