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