feat: add PlayerViewModel with queue management, shuffle, and play tracking

feat/music-streaming
Laurent 1 month ago
parent 9c934235d6
commit d61ccda111
  1. 104
      Music/ViewModels/PlayerViewModel.swift
  2. 105
      MusicTests/PlayerViewModelTests.swift

@ -0,0 +1,104 @@
import Foundation
import Observation
@Observable
final class PlayerViewModel {
var currentTrack: Track?
var currentIndex: Int?
var isShuffled = false
private(set) var queue: [Track] = []
private var originalQueue: [Track] = []
private let audio: AudioService
private let db: DatabaseService?
private var halfwayReported = false
init(audio: AudioService, db: DatabaseService?) {
self.audio = audio
self.db = db
audio.onTrackFinished = { [weak self] in
self?.trackDidFinish()
}
}
func setQueue(_ tracks: [Track]) {
originalQueue = tracks
if isShuffled {
queue = buildShuffledQueue(from: tracks, startingWith: currentTrack)
} else {
queue = tracks
}
if let current = currentTrack {
currentIndex = queue.firstIndex(where: { $0.id == current.id })
}
}
func play(_ track: Track) {
currentTrack = track
currentIndex = queue.firstIndex(where: { $0.id == track.id })
halfwayReported = false
guard let url = URL(string: track.fileURL) else { return }
audio.play(url: url)
}
func next() {
guard let idx = currentIndex else { return }
let nextIdx = idx + 1
if nextIdx < queue.count {
play(queue[nextIdx])
} else {
audio.stop()
currentTrack = nil
currentIndex = nil
}
}
func previous() {
guard let idx = currentIndex else { return }
let prevIdx = max(0, idx - 1)
play(queue[prevIdx])
}
func toggleShuffle() {
isShuffled.toggle()
if isShuffled {
queue = buildShuffledQueue(from: originalQueue, startingWith: currentTrack)
} else {
queue = originalQueue
}
if let current = currentTrack {
currentIndex = queue.firstIndex(where: { $0.id == current.id })
}
}
func checkHalfway() {
guard !halfwayReported,
audio.duration > 0,
audio.currentTime >= audio.duration * 0.5,
let track = currentTrack,
let trackId = track.id else { return }
halfwayReported = true
let newCount = track.playCount + 1
try? db?.updatePlayStats(trackId: trackId, playCount: newCount, lastPlayedAt: Date())
}
private func trackDidFinish() {
if let track = currentTrack, let trackId = track.id, !halfwayReported {
let newCount = track.playCount + 1
try? db?.updatePlayStats(trackId: trackId, playCount: newCount, lastPlayedAt: Date())
}
next()
}
private func buildShuffledQueue(from tracks: [Track], startingWith current: Track?) -> [Track] {
var shuffled = tracks.shuffled()
if let current, let idx = shuffled.firstIndex(where: { $0.id == current.id }) {
shuffled.remove(at: idx)
shuffled.insert(current, at: 0)
}
return shuffled
}
}

@ -0,0 +1,105 @@
import Testing
import Foundation
@testable import Music
@MainActor
struct PlayerViewModelTests {
private func makeTracks(_ count: Int) -> [Track] {
(0..<count).map { i in
Track.fixture(
id: Int64(i + 1),
fileURL: "/track\(i).mp3",
title: "Track \(i)"
)
}
}
// Sets the queue and plays a track, verifies current track and index are set.
@Test func playTrackSetsCurrentTrackAndIndex() {
let vm = PlayerViewModel(audio: AudioService(), db: nil)
let tracks = makeTracks(5)
vm.setQueue(tracks)
vm.play(tracks[2])
#expect(vm.currentTrack?.id == 3)
#expect(vm.currentIndex == 2)
}
// Calls next() and verifies it advances to the next track.
@Test func nextAdvancesToNextTrack() {
let vm = PlayerViewModel(audio: AudioService(), db: nil)
let tracks = makeTracks(5)
vm.setQueue(tracks)
vm.play(tracks[0])
vm.next()
#expect(vm.currentTrack?.id == 2)
#expect(vm.currentIndex == 1)
}
// Calls next() on the last track and verifies it stops (no wrap for v1).
@Test func nextAtEndStops() {
let vm = PlayerViewModel(audio: AudioService(), db: nil)
let tracks = makeTracks(3)
vm.setQueue(tracks)
vm.play(tracks[2])
vm.next()
#expect(vm.currentTrack == nil)
}
// Calls previous() and verifies it goes to the previous track.
@Test func previousGoesToPreviousTrack() {
let vm = PlayerViewModel(audio: AudioService(), db: nil)
let tracks = makeTracks(5)
vm.setQueue(tracks)
vm.play(tracks[3])
vm.previous()
#expect(vm.currentTrack?.id == 3)
}
// Calls previous() on the first track and verifies it stays at the first track.
@Test func previousAtStartStaysAtFirst() {
let vm = PlayerViewModel(audio: AudioService(), db: nil)
let tracks = makeTracks(3)
vm.setQueue(tracks)
vm.play(tracks[0])
vm.previous()
#expect(vm.currentTrack?.id == 1)
#expect(vm.currentIndex == 0)
}
// Enables shuffle and verifies the shuffled queue contains all tracks
// and starts with the current track.
@Test func shuffleContainsAllTracksStartingWithCurrent() {
let vm = PlayerViewModel(audio: AudioService(), db: nil)
let tracks = makeTracks(20)
vm.setQueue(tracks)
vm.play(tracks[5])
vm.toggleShuffle()
#expect(vm.isShuffled == true)
#expect(vm.currentTrack?.id == 6)
let shuffledIds = Set(vm.queue.map { $0.id })
let originalIds = Set(tracks.map { $0.id })
#expect(shuffledIds == originalIds)
}
// Disables shuffle and verifies the queue returns to original order
// and current track is preserved.
@Test func unshuffleRestoresOriginalOrder() {
let vm = PlayerViewModel(audio: AudioService(), db: nil)
let tracks = makeTracks(10)
vm.setQueue(tracks)
vm.play(tracks[3])
vm.toggleShuffle()
vm.toggleShuffle()
#expect(vm.isShuffled == false)
#expect(vm.currentTrack?.id == 4)
#expect(vm.queue.map { $0.id } == tracks.map { $0.id })
}
}
Loading…
Cancel
Save