parent
9c934235d6
commit
d61ccda111
@ -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…
Reference in new issue