1120 lines
41 KiB
Swift
1120 lines
41 KiB
Swift
import Foundation
|
|
#if canImport(SwiftUI)
|
|
import SwiftUI
|
|
#endif
|
|
#if canImport(UIKit)
|
|
import UIKit
|
|
#endif
|
|
import XCTest
|
|
import VelodyDomain
|
|
import VelodyNetworking
|
|
import VelodyPlayback
|
|
import VelodyPersistence
|
|
import VelodyUtilities
|
|
@testable import VelodyiPhone
|
|
|
|
@MainActor
|
|
final class iPhoneLibraryViewModelPlaybackTests: XCTestCase {
|
|
func testNowPlayingArtworkHeightStaysWithinRequestedRange() {
|
|
let contentWidths: [CGFloat] = [280, 343, 361, 398, 520]
|
|
|
|
for width in contentWidths {
|
|
let artworkHeight = NowPlayingCardLayoutMetrics.artworkHeight(for: width)
|
|
XCTAssertGreaterThanOrEqual(artworkHeight, 140)
|
|
XCTAssertLessThanOrEqual(artworkHeight, 180)
|
|
}
|
|
|
|
XCTAssertEqual(NowPlayingCardLayoutMetrics.artworkHeight(for: 280), 148)
|
|
XCTAssertEqual(NowPlayingCardLayoutMetrics.artworkHeight(for: 520), 176)
|
|
}
|
|
|
|
func testNowPlayingControlsLayoutFitsCommonIPhoneCardWidths() {
|
|
let cases: [(name: String, availableWidth: CGFloat, expectedStyle: NowPlayingCardLayoutMetrics.ControlsLayout.Style)] = [
|
|
("iPhone 16e", 311, .regular),
|
|
("iPhone 17", 321, .regular),
|
|
("iPhone 17 Pro Max", 358, .regular),
|
|
]
|
|
|
|
for testCase in cases {
|
|
let layout = NowPlayingCardLayoutMetrics.controlsLayout(for: testCase.availableWidth)
|
|
XCTAssertLessThanOrEqual(
|
|
layout.firstRowWidth,
|
|
testCase.availableWidth,
|
|
"\(testCase.name) control row overflowed the card width"
|
|
)
|
|
XCTAssertGreaterThan(layout.primaryButtonSize, layout.secondaryButtonSize)
|
|
XCTAssertEqual(layout.style, testCase.expectedStyle)
|
|
}
|
|
}
|
|
|
|
#if canImport(UIKit)
|
|
func testNowPlayingViewRendersCommonIPhoneLayouts() async throws {
|
|
let captures: [(name: String, size: CGSize)] = [
|
|
("iphone-16e", CGSize(width: 375, height: 812)),
|
|
("iphone-17", CGSize(width: 393, height: 852)),
|
|
("iphone-17-pro-max", CGSize(width: 430, height: 932)),
|
|
]
|
|
let outputDirectory = URL(fileURLWithPath: NSTemporaryDirectory())
|
|
.appendingPathComponent("velody-now-playing-layout", isDirectory: true)
|
|
|
|
try? FileManager.default.removeItem(at: outputDirectory)
|
|
try FileManager.default.createDirectory(at: outputDirectory, withIntermediateDirectories: true)
|
|
|
|
for capture in captures {
|
|
let viewModel = try await makeNowPlayingScreenshotViewModel()
|
|
let image = try XCTUnwrap(
|
|
renderNowPlayingLayoutScreenshot(viewModel: viewModel, size: capture.size)
|
|
)
|
|
|
|
XCTAssertEqual(image.size.width, capture.size.width)
|
|
XCTAssertEqual(image.size.height, capture.size.height)
|
|
|
|
let url = outputDirectory.appendingPathComponent("\(capture.name).png")
|
|
let pngData = try XCTUnwrap(image.pngData())
|
|
try pngData.write(to: url)
|
|
print("Saved now playing screenshot to \(url.path)")
|
|
}
|
|
}
|
|
|
|
private func makeNowPlayingScreenshotViewModel() async throws -> iPhoneLibraryViewModel {
|
|
let firstTrack = makeRemoteTrack(
|
|
trackId: "remote-layout-first",
|
|
assetId: "asset-layout-first",
|
|
title: "Northern Lights"
|
|
)
|
|
let secondTrack = makeRemoteTrack(
|
|
trackId: "remote-layout-second",
|
|
assetId: "asset-layout-second",
|
|
title: "Horizons"
|
|
)
|
|
let thirdTrack = makeRemoteTrack(
|
|
trackId: "remote-layout-third",
|
|
assetId: "asset-layout-third",
|
|
title: "Coastline"
|
|
)
|
|
let favoriteStore = InMemoryFavoriteTrackStore(
|
|
tracks: [
|
|
FavoriteTrackRecord(
|
|
remoteTrackId: secondTrack.trackId,
|
|
favoritedAt: Date(timeIntervalSince1970: 3_000)
|
|
),
|
|
]
|
|
)
|
|
let viewModel = makeViewModel(
|
|
remoteTracks: [firstTrack, secondTrack, thirdTrack],
|
|
downloadStates: [
|
|
makeDownloadedState(for: firstTrack),
|
|
makeDownloadedState(for: secondTrack),
|
|
makeDownloadedState(for: thirdTrack),
|
|
],
|
|
favoriteTrackStore: favoriteStore,
|
|
audioFiles: [
|
|
localFilePath(for: firstTrack): Data([0x1]),
|
|
localFilePath(for: secondTrack): Data([0x2]),
|
|
localFilePath(for: thirdTrack): Data([0x3]),
|
|
]
|
|
)
|
|
|
|
await viewModel.loadIfNeeded()
|
|
viewModel.togglePlayback(trackID: secondTrack.trackId)
|
|
viewModel.cycleRepeatMode()
|
|
|
|
return viewModel
|
|
}
|
|
|
|
private func renderNowPlayingLayoutScreenshot(
|
|
viewModel: iPhoneLibraryViewModel,
|
|
size: CGSize
|
|
) -> UIImage? {
|
|
let rootView = iPhoneLibraryView(viewModel: viewModel)
|
|
.frame(width: size.width, height: size.height)
|
|
let hostingController = UIHostingController(rootView: rootView)
|
|
let window = UIWindow(frame: CGRect(origin: .zero, size: size))
|
|
|
|
window.rootViewController = hostingController
|
|
window.isHidden = false
|
|
hostingController.view.frame = window.bounds
|
|
hostingController.view.backgroundColor = .systemGroupedBackground
|
|
hostingController.view.setNeedsLayout()
|
|
hostingController.view.layoutIfNeeded()
|
|
window.layoutIfNeeded()
|
|
RunLoop.main.run(until: Date(timeIntervalSinceNow: 0.05))
|
|
|
|
let renderer = UIGraphicsImageRenderer(size: size)
|
|
let image = renderer.image { _ in
|
|
hostingController.view.drawHierarchy(in: hostingController.view.bounds, afterScreenUpdates: true)
|
|
}
|
|
|
|
window.isHidden = true
|
|
return image
|
|
}
|
|
#endif
|
|
|
|
func testNowPlayingCardShowsTitleArtistAndArtwork() async throws {
|
|
let artwork = RemoteArtwork(
|
|
artworkId: "artwork-midnight",
|
|
sha256: String(repeating: "b", count: 64),
|
|
mimeType: "image/png"
|
|
)
|
|
let track = makeRemoteTrack(
|
|
trackId: "remote-midnight-city",
|
|
assetId: "asset-midnight-city",
|
|
title: "Midnight City",
|
|
artwork: artwork
|
|
)
|
|
let player = TestPlayer()
|
|
let viewModel = makeViewModel(
|
|
remoteTracks: [track],
|
|
downloadStates: [makeDownloadedState(for: track)],
|
|
audioFiles: [localFilePath(for: track): Data([0x1, 0x2])],
|
|
artworkPayloadsByArtworkID: [artwork.artworkId: Data([0x9, 0x8])],
|
|
player: player
|
|
)
|
|
|
|
await viewModel.loadIfNeeded()
|
|
viewModel.togglePlayback(trackID: track.trackId)
|
|
|
|
let card = try XCTUnwrap(viewModel.nowPlayingCard)
|
|
XCTAssertEqual(card.title, track.title)
|
|
XCTAssertEqual(card.artist, track.artist)
|
|
XCTAssertEqual(card.artworkLocalFilePath, localArtworkFilePath(for: artwork))
|
|
XCTAssertEqual(card.downloadBadge, .downloaded)
|
|
XCTAssertEqual(card.playbackStateText, "Playing")
|
|
}
|
|
|
|
func testMissingLocalFilePreventsPlayingAndDisablesSeek() async throws {
|
|
let track = makeRemoteTrack(
|
|
trackId: "remote-missing-file",
|
|
assetId: "asset-missing-file",
|
|
title: "Lost Signal"
|
|
)
|
|
let player = TestPlayer()
|
|
player.missingTrackIDs.insert(track.trackId)
|
|
let viewModel = makeViewModel(
|
|
remoteTracks: [track],
|
|
downloadStates: [makeDownloadedState(for: track)],
|
|
player: player
|
|
)
|
|
|
|
await viewModel.loadIfNeeded()
|
|
viewModel.togglePlayback(trackID: track.trackId)
|
|
|
|
let card = try XCTUnwrap(viewModel.nowPlayingCard)
|
|
XCTAssertEqual(viewModel.nowPlaying.playbackState, .missingFile)
|
|
XCTAssertFalse(viewModel.nowPlaying.isPlaying)
|
|
XCTAssertFalse(card.canSeek)
|
|
XCTAssertEqual(card.downloadBadge, .missing)
|
|
XCTAssertEqual(card.title, track.title)
|
|
XCTAssertEqual(card.artist, track.artist)
|
|
XCTAssertEqual(card.playbackStateText, "Missing file")
|
|
XCTAssertEqual(
|
|
card.errorMessage,
|
|
"This downloaded file is missing. Re-download the track to play it again."
|
|
)
|
|
}
|
|
|
|
func testPauseStopsProgressUpdates() async throws {
|
|
let track = makeRemoteTrack(
|
|
trackId: "remote-pause",
|
|
assetId: "asset-pause",
|
|
title: "Pause Study"
|
|
)
|
|
let player = TestPlayer()
|
|
let viewModel = makeViewModel(
|
|
remoteTracks: [track],
|
|
downloadStates: [makeDownloadedState(for: track)],
|
|
audioFiles: [localFilePath(for: track): Data([0x1])],
|
|
player: player
|
|
)
|
|
|
|
await viewModel.loadIfNeeded()
|
|
viewModel.togglePlayback(trackID: track.trackId)
|
|
player.advanceProgress(by: 12)
|
|
|
|
XCTAssertEqual(viewModel.nowPlaying.currentTime, 12)
|
|
|
|
viewModel.togglePlayback(trackID: track.trackId)
|
|
player.advanceProgress(by: 8)
|
|
|
|
XCTAssertEqual(viewModel.nowPlaying.playbackState, .paused)
|
|
XCTAssertEqual(viewModel.nowPlaying.currentTime, 12)
|
|
}
|
|
|
|
func testStopResetsProgressSafely() async throws {
|
|
let track = makeRemoteTrack(
|
|
trackId: "remote-stop",
|
|
assetId: "asset-stop",
|
|
title: "Stop Motion"
|
|
)
|
|
let player = TestPlayer()
|
|
let viewModel = makeViewModel(
|
|
remoteTracks: [track],
|
|
downloadStates: [makeDownloadedState(for: track)],
|
|
audioFiles: [localFilePath(for: track): Data([0x1])],
|
|
player: player
|
|
)
|
|
|
|
await viewModel.loadIfNeeded()
|
|
viewModel.togglePlayback(trackID: track.trackId)
|
|
player.advanceProgress(by: 33)
|
|
|
|
viewModel.stopPlayback()
|
|
|
|
XCTAssertEqual(viewModel.nowPlaying.playbackState, .stopped)
|
|
XCTAssertEqual(viewModel.nowPlaying.currentTime, 0)
|
|
XCTAssertEqual(viewModel.nowPlaying.duration, 245)
|
|
}
|
|
|
|
func testNextTrackUpdatesNowPlayingMetadata() async throws {
|
|
let firstTrack = makeRemoteTrack(
|
|
trackId: "remote-next-first",
|
|
assetId: "asset-next-first",
|
|
title: "Arrival"
|
|
)
|
|
let secondTrack = makeRemoteTrack(
|
|
trackId: "remote-next-second",
|
|
assetId: "asset-next-second",
|
|
title: "Departure"
|
|
)
|
|
let player = TestPlayer()
|
|
let viewModel = makeViewModel(
|
|
remoteTracks: [firstTrack, secondTrack],
|
|
downloadStates: [
|
|
makeDownloadedState(for: firstTrack),
|
|
makeDownloadedState(for: secondTrack),
|
|
],
|
|
audioFiles: [
|
|
localFilePath(for: firstTrack): Data([0x1]),
|
|
localFilePath(for: secondTrack): Data([0x2]),
|
|
],
|
|
player: player
|
|
)
|
|
|
|
await viewModel.loadIfNeeded()
|
|
viewModel.togglePlayback(trackID: firstTrack.trackId)
|
|
viewModel.nextTrack()
|
|
|
|
let card = try XCTUnwrap(viewModel.nowPlayingCard)
|
|
XCTAssertEqual(viewModel.nowPlaying.trackID, secondTrack.trackId)
|
|
XCTAssertEqual(card.trackID, secondTrack.trackId)
|
|
XCTAssertEqual(card.title, secondTrack.title)
|
|
XCTAssertEqual(card.artist, secondTrack.artist)
|
|
XCTAssertEqual(viewModel.nowPlaying.playbackState, .playing)
|
|
XCTAssertTrue(viewModel.canGoPrevious)
|
|
XCTAssertFalse(viewModel.canGoNext)
|
|
}
|
|
|
|
func testPreviousTrackUpdatesNowPlayingMetadata() async throws {
|
|
let firstTrack = makeRemoteTrack(
|
|
trackId: "remote-previous-first",
|
|
assetId: "asset-previous-first",
|
|
title: "Early Bird"
|
|
)
|
|
let secondTrack = makeRemoteTrack(
|
|
trackId: "remote-previous-second",
|
|
assetId: "asset-previous-second",
|
|
title: "Night Owl"
|
|
)
|
|
let player = TestPlayer()
|
|
let viewModel = makeViewModel(
|
|
remoteTracks: [firstTrack, secondTrack],
|
|
downloadStates: [
|
|
makeDownloadedState(for: firstTrack),
|
|
makeDownloadedState(for: secondTrack),
|
|
],
|
|
audioFiles: [
|
|
localFilePath(for: firstTrack): Data([0x1]),
|
|
localFilePath(for: secondTrack): Data([0x2]),
|
|
],
|
|
player: player
|
|
)
|
|
|
|
await viewModel.loadIfNeeded()
|
|
viewModel.togglePlayback(trackID: firstTrack.trackId)
|
|
viewModel.nextTrack()
|
|
viewModel.previousTrack()
|
|
|
|
let card = try XCTUnwrap(viewModel.nowPlayingCard)
|
|
XCTAssertEqual(viewModel.nowPlaying.trackID, firstTrack.trackId)
|
|
XCTAssertEqual(card.trackID, firstTrack.trackId)
|
|
XCTAssertEqual(card.title, firstTrack.title)
|
|
XCTAssertEqual(card.artist, firstTrack.artist)
|
|
XCTAssertEqual(viewModel.nowPlaying.playbackState, .playing)
|
|
XCTAssertFalse(viewModel.canGoPrevious)
|
|
XCTAssertTrue(viewModel.canGoNext)
|
|
}
|
|
|
|
func testSearchDoesNotClearNowPlaying() async throws {
|
|
let firstTrack = makeRemoteTrack(
|
|
trackId: "remote-first-search",
|
|
assetId: "asset-first-search",
|
|
title: "Blue Avenue"
|
|
)
|
|
let secondTrack = makeRemoteTrack(
|
|
trackId: "remote-second-search",
|
|
assetId: "asset-second-search",
|
|
title: "Golden Hour"
|
|
)
|
|
let player = TestPlayer()
|
|
let viewModel = makeViewModel(
|
|
remoteTracks: [firstTrack, secondTrack],
|
|
downloadStates: [
|
|
makeDownloadedState(for: firstTrack),
|
|
makeDownloadedState(for: secondTrack),
|
|
],
|
|
audioFiles: [
|
|
localFilePath(for: firstTrack): Data([0x1]),
|
|
localFilePath(for: secondTrack): Data([0x2]),
|
|
],
|
|
player: player
|
|
)
|
|
|
|
await viewModel.loadIfNeeded()
|
|
viewModel.togglePlayback(trackID: firstTrack.trackId)
|
|
viewModel.searchText = secondTrack.title
|
|
|
|
let card = try XCTUnwrap(viewModel.nowPlayingCard)
|
|
XCTAssertEqual(card.trackID, firstTrack.trackId)
|
|
XCTAssertNil(remoteRow(in: viewModel, trackID: firstTrack.trackId))
|
|
XCTAssertEqual(viewModel.remoteTracks.count, 1)
|
|
}
|
|
|
|
func testPlaybackErrorDoesNotLeaveStateAsPlaying() async throws {
|
|
let track = makeRemoteTrack(
|
|
trackId: "remote-failure",
|
|
assetId: "asset-failure",
|
|
title: "Hard Stop"
|
|
)
|
|
let player = TestPlayer()
|
|
player.failingTrackIDs.insert(track.trackId)
|
|
let viewModel = makeViewModel(
|
|
remoteTracks: [track],
|
|
downloadStates: [makeDownloadedState(for: track)],
|
|
audioFiles: [localFilePath(for: track): Data([0x1])],
|
|
player: player
|
|
)
|
|
|
|
await viewModel.loadIfNeeded()
|
|
viewModel.togglePlayback(trackID: track.trackId)
|
|
|
|
XCTAssertEqual(viewModel.nowPlaying.playbackState, .failed)
|
|
XCTAssertFalse(viewModel.nowPlaying.isPlaying)
|
|
XCTAssertEqual(viewModel.nowPlaying.errorMessage, "Playback could not be started.")
|
|
}
|
|
|
|
func testSeekUpdatesCurrentTimeWhenTrackIsLoaded() async throws {
|
|
let track = makeRemoteTrack(
|
|
trackId: "remote-seek",
|
|
assetId: "asset-seek",
|
|
title: "Seeklight"
|
|
)
|
|
let player = TestPlayer()
|
|
let viewModel = makeViewModel(
|
|
remoteTracks: [track],
|
|
downloadStates: [makeDownloadedState(for: track)],
|
|
audioFiles: [localFilePath(for: track): Data([0x1])],
|
|
player: player
|
|
)
|
|
|
|
await viewModel.loadIfNeeded()
|
|
viewModel.togglePlayback(trackID: track.trackId)
|
|
viewModel.seekPlayback(to: 90)
|
|
|
|
XCTAssertEqual(viewModel.nowPlaying.currentTime, 90)
|
|
XCTAssertEqual(viewModel.nowPlaying.duration, 245)
|
|
}
|
|
|
|
func testShufflePreservesCurrentTrackAndUpdatesState() async throws {
|
|
let firstTrack = makeRemoteTrack(
|
|
trackId: "remote-shuffle-first",
|
|
assetId: "asset-shuffle-first",
|
|
title: "Northbound"
|
|
)
|
|
let secondTrack = makeRemoteTrack(
|
|
trackId: "remote-shuffle-second",
|
|
assetId: "asset-shuffle-second",
|
|
title: "Southbound"
|
|
)
|
|
let thirdTrack = makeRemoteTrack(
|
|
trackId: "remote-shuffle-third",
|
|
assetId: "asset-shuffle-third",
|
|
title: "Westbound"
|
|
)
|
|
let player = TestPlayer()
|
|
let viewModel = makeViewModel(
|
|
remoteTracks: [firstTrack, secondTrack, thirdTrack],
|
|
downloadStates: [
|
|
makeDownloadedState(for: firstTrack),
|
|
makeDownloadedState(for: secondTrack),
|
|
makeDownloadedState(for: thirdTrack),
|
|
],
|
|
audioFiles: [
|
|
localFilePath(for: firstTrack): Data([0x1]),
|
|
localFilePath(for: secondTrack): Data([0x2]),
|
|
localFilePath(for: thirdTrack): Data([0x3]),
|
|
],
|
|
player: player
|
|
)
|
|
|
|
await viewModel.loadIfNeeded()
|
|
viewModel.togglePlayback(trackID: secondTrack.trackId)
|
|
|
|
let originalTrackIDs = Set([firstTrack.trackId, secondTrack.trackId, thirdTrack.trackId])
|
|
viewModel.toggleShuffle()
|
|
|
|
XCTAssertTrue(viewModel.isShuffleEnabled)
|
|
XCTAssertEqual(viewModel.nowPlaying.trackID, secondTrack.trackId)
|
|
XCTAssertEqual(viewModel.nowPlaying.queueTrackIDs.first, secondTrack.trackId)
|
|
XCTAssertEqual(Set(viewModel.nowPlaying.queueTrackIDs), originalTrackIDs)
|
|
XCTAssertEqual(Set(viewModel.nowPlaying.queueTrackIDs).count, originalTrackIDs.count)
|
|
}
|
|
|
|
func testRepeatCyclesCorrectly() async throws {
|
|
let track = makeRemoteTrack(
|
|
trackId: "remote-repeat-cycle",
|
|
assetId: "asset-repeat-cycle",
|
|
title: "Loop Study"
|
|
)
|
|
let viewModel = makeViewModel(
|
|
remoteTracks: [track],
|
|
downloadStates: [makeDownloadedState(for: track)],
|
|
audioFiles: [localFilePath(for: track): Data([0x1])]
|
|
)
|
|
|
|
await viewModel.loadIfNeeded()
|
|
viewModel.togglePlayback(trackID: track.trackId)
|
|
|
|
XCTAssertEqual(viewModel.repeatMode, .off)
|
|
|
|
viewModel.cycleRepeatMode()
|
|
XCTAssertEqual(viewModel.repeatMode, .one)
|
|
|
|
viewModel.cycleRepeatMode()
|
|
XCTAssertEqual(viewModel.repeatMode, .all)
|
|
|
|
viewModel.cycleRepeatMode()
|
|
XCTAssertEqual(viewModel.repeatMode, .off)
|
|
}
|
|
|
|
func testNextTrackRespectsRepeatOne() async throws {
|
|
let firstTrack = makeRemoteTrack(
|
|
trackId: "remote-repeat-one-first",
|
|
assetId: "asset-repeat-one-first",
|
|
title: "Single Orbit"
|
|
)
|
|
let secondTrack = makeRemoteTrack(
|
|
trackId: "remote-repeat-one-second",
|
|
assetId: "asset-repeat-one-second",
|
|
title: "Outer Ring"
|
|
)
|
|
let player = TestPlayer()
|
|
let viewModel = makeViewModel(
|
|
remoteTracks: [firstTrack, secondTrack],
|
|
downloadStates: [
|
|
makeDownloadedState(for: firstTrack),
|
|
makeDownloadedState(for: secondTrack),
|
|
],
|
|
audioFiles: [
|
|
localFilePath(for: firstTrack): Data([0x1]),
|
|
localFilePath(for: secondTrack): Data([0x2]),
|
|
],
|
|
player: player
|
|
)
|
|
|
|
await viewModel.loadIfNeeded()
|
|
viewModel.togglePlayback(trackID: firstTrack.trackId)
|
|
viewModel.seekPlayback(to: 37)
|
|
viewModel.cycleRepeatMode()
|
|
viewModel.nextTrack()
|
|
|
|
XCTAssertEqual(viewModel.repeatMode, .one)
|
|
XCTAssertEqual(viewModel.nowPlaying.trackID, firstTrack.trackId)
|
|
XCTAssertEqual(viewModel.nowPlaying.title, firstTrack.title)
|
|
XCTAssertEqual(viewModel.nowPlaying.currentTime, 0)
|
|
XCTAssertEqual(viewModel.nowPlaying.playbackState, .playing)
|
|
}
|
|
|
|
func testQueueControlsPreserveFavoriteState() async throws {
|
|
let firstTrack = makeRemoteTrack(
|
|
trackId: "remote-favorite-queue-first",
|
|
assetId: "asset-favorite-queue-first",
|
|
title: "Anchor Point"
|
|
)
|
|
let secondTrack = makeRemoteTrack(
|
|
trackId: "remote-favorite-queue-second",
|
|
assetId: "asset-favorite-queue-second",
|
|
title: "Signal Path"
|
|
)
|
|
let thirdTrack = makeRemoteTrack(
|
|
trackId: "remote-favorite-queue-third",
|
|
assetId: "asset-favorite-queue-third",
|
|
title: "Safe Harbor"
|
|
)
|
|
let favoriteStore = InMemoryFavoriteTrackStore(
|
|
tracks: [
|
|
FavoriteTrackRecord(
|
|
remoteTrackId: firstTrack.trackId,
|
|
favoritedAt: Date(timeIntervalSince1970: 1_000)
|
|
),
|
|
FavoriteTrackRecord(
|
|
remoteTrackId: thirdTrack.trackId,
|
|
favoritedAt: Date(timeIntervalSince1970: 2_000)
|
|
),
|
|
]
|
|
)
|
|
let viewModel = makeViewModel(
|
|
remoteTracks: [firstTrack, secondTrack, thirdTrack],
|
|
downloadStates: [
|
|
makeDownloadedState(for: firstTrack),
|
|
makeDownloadedState(for: secondTrack),
|
|
makeDownloadedState(for: thirdTrack),
|
|
],
|
|
favoriteTrackStore: favoriteStore,
|
|
audioFiles: [
|
|
localFilePath(for: firstTrack): Data([0x1]),
|
|
localFilePath(for: secondTrack): Data([0x2]),
|
|
localFilePath(for: thirdTrack): Data([0x3]),
|
|
]
|
|
)
|
|
|
|
await viewModel.loadIfNeeded()
|
|
viewModel.togglePlayback(trackID: secondTrack.trackId)
|
|
viewModel.toggleShuffle()
|
|
viewModel.nextTrack()
|
|
viewModel.previousTrack()
|
|
|
|
XCTAssertTrue(try XCTUnwrap(remoteRow(in: viewModel, trackID: firstTrack.trackId)).isFavorite)
|
|
XCTAssertFalse(try XCTUnwrap(remoteRow(in: viewModel, trackID: secondTrack.trackId)).isFavorite)
|
|
XCTAssertTrue(try XCTUnwrap(remoteRow(in: viewModel, trackID: thirdTrack.trackId)).isFavorite)
|
|
XCTAssertTrue(try XCTUnwrap(offlineRow(in: viewModel, trackID: firstTrack.trackId)).isFavorite)
|
|
XCTAssertFalse(try XCTUnwrap(offlineRow(in: viewModel, trackID: secondTrack.trackId)).isFavorite)
|
|
XCTAssertTrue(try XCTUnwrap(offlineRow(in: viewModel, trackID: thirdTrack.trackId)).isFavorite)
|
|
}
|
|
|
|
func testQueueControlsPreserveOfflineAndDownloadedState() async throws {
|
|
let firstTrack = makeRemoteTrack(
|
|
trackId: "remote-offline-first",
|
|
assetId: "asset-offline-first",
|
|
title: "Stored Echo"
|
|
)
|
|
let secondTrack = makeRemoteTrack(
|
|
trackId: "remote-offline-second",
|
|
assetId: "asset-offline-second",
|
|
title: "Airplane Mode"
|
|
)
|
|
let viewModel = makeViewModel(
|
|
remoteTracks: [firstTrack, secondTrack],
|
|
downloadStates: [
|
|
makeDownloadedState(for: firstTrack),
|
|
makeDownloadedState(for: secondTrack),
|
|
],
|
|
audioFiles: [
|
|
localFilePath(for: firstTrack): Data([0x1, 0x2]),
|
|
localFilePath(for: secondTrack): Data([0x3, 0x4]),
|
|
]
|
|
)
|
|
|
|
await viewModel.loadIfNeeded()
|
|
viewModel.togglePlayback(trackID: firstTrack.trackId)
|
|
viewModel.toggleShuffle()
|
|
viewModel.nextTrack()
|
|
viewModel.previousTrack()
|
|
|
|
XCTAssertEqual(try XCTUnwrap(remoteRow(in: viewModel, trackID: firstTrack.trackId)).status, .downloaded)
|
|
XCTAssertEqual(try XCTUnwrap(remoteRow(in: viewModel, trackID: secondTrack.trackId)).status, .downloaded)
|
|
XCTAssertEqual(try XCTUnwrap(offlineRow(in: viewModel, trackID: firstTrack.trackId)).statusBadgeTitle, "Downloaded")
|
|
XCTAssertEqual(try XCTUnwrap(offlineRow(in: viewModel, trackID: secondTrack.trackId)).statusBadgeTitle, "Downloaded")
|
|
XCTAssertEqual(viewModel.availableOfflineTracks.map(\.id), [firstTrack.trackId, secondTrack.trackId])
|
|
}
|
|
|
|
func testRelaunchRestoresMetadataShuffleAndRepeatWithoutAutoplay() {
|
|
let suiteName = "de.diyaa.velody.tests.\(UUID().uuidString)"
|
|
let defaults = UserDefaults(suiteName: suiteName)!
|
|
defer {
|
|
defaults.removePersistentDomain(forName: suiteName)
|
|
}
|
|
|
|
let sessionStore = iPhonePlaybackSessionStore(
|
|
userDefaults: defaults,
|
|
storageKey: "playback"
|
|
)
|
|
let firstTrack = LibraryTrack(
|
|
id: "remote-restore-first",
|
|
title: "Restore Point",
|
|
artist: "Velody Artist",
|
|
durationSeconds: 245,
|
|
localFilePath: "/in-memory/asset-restore-first.mp3",
|
|
remoteTrackId: "remote-restore-first"
|
|
)
|
|
let secondTrack = LibraryTrack(
|
|
id: "remote-restore-second",
|
|
title: "Resume Thread",
|
|
artist: "Velody Artist",
|
|
durationSeconds: 188,
|
|
localFilePath: "/in-memory/asset-restore-second.mp3",
|
|
remoteTrackId: "remote-restore-second"
|
|
)
|
|
|
|
let firstEngine = FakePlaybackEngine()
|
|
firstEngine.durationByPath[firstTrack.localFilePath] = 245
|
|
firstEngine.durationByPath[secondTrack.localFilePath] = 188
|
|
let firstPlayer = iPhonePlaybackControllerPlayer(
|
|
controller: PlaybackController(
|
|
engine: firstEngine,
|
|
sessionStore: sessionStore
|
|
)
|
|
)
|
|
firstPlayer.setCatalogTracks([firstTrack, secondTrack])
|
|
firstPlayer.play(trackID: secondTrack.id)
|
|
firstPlayer.toggleShuffle()
|
|
firstPlayer.cycleRepeatMode()
|
|
firstPlayer.cycleRepeatMode()
|
|
firstEngine.currentTime = 48
|
|
firstPlayer.pause()
|
|
|
|
let storedSession = sessionStore.loadSession()
|
|
XCTAssertEqual(storedSession?.currentTrackID, secondTrack.id)
|
|
XCTAssertEqual(storedSession?.currentTime, 48)
|
|
XCTAssertEqual(storedSession?.queueTrackIDs, [secondTrack.id, firstTrack.id])
|
|
XCTAssertEqual(storedSession?.isShuffleEnabled, true)
|
|
XCTAssertEqual(storedSession?.repeatMode, .all)
|
|
|
|
let secondEngine = FakePlaybackEngine()
|
|
secondEngine.durationByPath[firstTrack.localFilePath] = 245
|
|
secondEngine.durationByPath[secondTrack.localFilePath] = 188
|
|
let relaunchedPlayer = iPhonePlaybackControllerPlayer(
|
|
controller: PlaybackController(
|
|
engine: secondEngine,
|
|
sessionStore: sessionStore
|
|
)
|
|
)
|
|
relaunchedPlayer.setCatalogTracks([firstTrack, secondTrack])
|
|
|
|
XCTAssertEqual(relaunchedPlayer.state.trackID, secondTrack.id)
|
|
XCTAssertEqual(relaunchedPlayer.state.title, secondTrack.title)
|
|
XCTAssertEqual(relaunchedPlayer.state.artist, secondTrack.artist)
|
|
XCTAssertEqual(relaunchedPlayer.state.queueTrackIDs, [secondTrack.id, firstTrack.id])
|
|
XCTAssertTrue(relaunchedPlayer.state.isShuffleEnabled)
|
|
XCTAssertEqual(relaunchedPlayer.state.repeatMode, .all)
|
|
XCTAssertEqual(relaunchedPlayer.state.currentTime, 48)
|
|
XCTAssertEqual(relaunchedPlayer.state.playbackState, .paused)
|
|
XCTAssertFalse(relaunchedPlayer.state.isPlaying)
|
|
XCTAssertNil(relaunchedPlayer.state.errorMessage)
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
final class iPhoneLibraryViewModelPolishTests: XCTestCase {
|
|
func testSyncingStatePreventsDuplicateRefreshCalls() async {
|
|
let track = makeRemoteTrack(
|
|
trackId: "remote-sync-dedupe",
|
|
assetId: "asset-sync-dedupe",
|
|
title: "Sync Once"
|
|
)
|
|
let counter = RegisterCallCounter()
|
|
let apiClient = TestRegisterAPIClient(
|
|
counter: counter,
|
|
delayNanoseconds: 200_000_000
|
|
)
|
|
let viewModel = makeViewModel(
|
|
remoteTracks: [track],
|
|
apiClient: apiClient
|
|
)
|
|
|
|
await viewModel.loadIfNeeded()
|
|
|
|
async let firstRefresh: Void = viewModel.refreshSync()
|
|
async let secondRefresh: Void = viewModel.refreshSync()
|
|
_ = await (firstRefresh, secondRefresh)
|
|
|
|
let syncCallCount = await counter.count
|
|
XCTAssertEqual(syncCallCount, 1)
|
|
XCTAssertFalse(viewModel.isSyncing)
|
|
}
|
|
|
|
func testUserFacingConnectionErrorDoesNotExposeRawExceptionDetails() async {
|
|
let rawErrorText = "socket closed for 10.0.0.8:3017"
|
|
let apiClient = TestRegisterAPIClient(
|
|
counter: RegisterCallCounter(),
|
|
registerError: VelodyAPIError.requestFailed(rawErrorText)
|
|
)
|
|
let viewModel = makeViewModel(
|
|
remoteTracks: [],
|
|
apiClient: apiClient
|
|
)
|
|
|
|
await viewModel.loadIfNeeded()
|
|
await viewModel.refreshSync()
|
|
|
|
XCTAssertEqual(
|
|
viewModel.syncStatus,
|
|
"Could not reach the backend. Check that the server is running and try again."
|
|
)
|
|
XCTAssertEqual(viewModel.remoteEmptyStateMessage?.title, "Connection failed")
|
|
XCTAssertEqual(
|
|
viewModel.remoteEmptyStateMessage?.body,
|
|
"Could not reach the backend. Check that the server is running and try again."
|
|
)
|
|
XCTAssertFalse(viewModel.syncStatus.contains(rawErrorText))
|
|
|
|
guard case let .networkError(debugMessage) = viewModel.state else {
|
|
return XCTFail("Expected network error state.")
|
|
}
|
|
|
|
XCTAssertTrue(debugMessage.contains(rawErrorText))
|
|
}
|
|
|
|
func testSectionTitlesReflectFilteredAndUnfilteredCounts() async {
|
|
let firstTrack = makeRemoteTrack(
|
|
trackId: "remote-counts-first",
|
|
assetId: "asset-counts-first",
|
|
title: "Trap Door"
|
|
)
|
|
let secondTrack = makeRemoteTrack(
|
|
trackId: "remote-counts-second",
|
|
assetId: "asset-counts-second",
|
|
title: "Harbor Lights"
|
|
)
|
|
let viewModel = makeViewModel(
|
|
remoteTracks: [firstTrack, secondTrack],
|
|
downloadStates: [
|
|
makeDownloadedState(for: firstTrack),
|
|
makeDownloadedState(for: secondTrack),
|
|
],
|
|
audioFiles: [
|
|
localFilePath(for: firstTrack): Data([0x1]),
|
|
localFilePath(for: secondTrack): Data([0x2]),
|
|
]
|
|
)
|
|
|
|
await viewModel.loadIfNeeded()
|
|
|
|
XCTAssertEqual(viewModel.remoteSectionTitle, "Remote Library (2)")
|
|
XCTAssertEqual(viewModel.availableOfflineSectionTitle, "Available Offline (2)")
|
|
|
|
viewModel.searchText = "Trap"
|
|
|
|
XCTAssertEqual(viewModel.remoteSectionTitle, "Remote Library Results (1)")
|
|
XCTAssertEqual(viewModel.availableOfflineSectionTitle, "Offline Results (1)")
|
|
|
|
viewModel.searchText = "zzz"
|
|
|
|
XCTAssertEqual(viewModel.remoteSectionTitle, "Remote Library Results (0)")
|
|
XCTAssertEqual(viewModel.availableOfflineSectionTitle, "Offline Results (0)")
|
|
}
|
|
|
|
func testSearchEmptyStateUsesFriendlyCopyAndKeepsNowPlaying() async throws {
|
|
let firstTrack = makeRemoteTrack(
|
|
trackId: "remote-empty-search-first",
|
|
assetId: "asset-empty-search-first",
|
|
title: "Light Trap"
|
|
)
|
|
let secondTrack = makeRemoteTrack(
|
|
trackId: "remote-empty-search-second",
|
|
assetId: "asset-empty-search-second",
|
|
title: "Harbor Lights"
|
|
)
|
|
let player = TestPlayer()
|
|
let viewModel = makeViewModel(
|
|
remoteTracks: [firstTrack, secondTrack],
|
|
downloadStates: [
|
|
makeDownloadedState(for: firstTrack),
|
|
makeDownloadedState(for: secondTrack),
|
|
],
|
|
audioFiles: [
|
|
localFilePath(for: firstTrack): Data([0x1]),
|
|
localFilePath(for: secondTrack): Data([0x2]),
|
|
],
|
|
player: player
|
|
)
|
|
|
|
await viewModel.loadIfNeeded()
|
|
viewModel.togglePlayback(trackID: firstTrack.trackId)
|
|
viewModel.searchText = "zzz"
|
|
|
|
XCTAssertEqual(viewModel.remoteEmptyStateMessage?.title, "No matching tracks")
|
|
XCTAssertEqual(viewModel.remoteEmptyStateMessage?.body, "Try a different title or artist.")
|
|
XCTAssertEqual(viewModel.availableOfflineEmptyStateMessage?.title, "No matching tracks")
|
|
XCTAssertEqual(try XCTUnwrap(viewModel.nowPlayingCard).trackID, firstTrack.trackId)
|
|
}
|
|
|
|
func testDisplayRowsDoNotExposeDebugIdentifiers() async throws {
|
|
let track = makeRemoteTrack(
|
|
trackId: "remote-polish-row",
|
|
assetId: "asset-polish-row",
|
|
title: "Clean Display"
|
|
)
|
|
let viewModel = makeViewModel(
|
|
remoteTracks: [track],
|
|
downloadStates: [makeDownloadedState(for: track)],
|
|
audioFiles: [localFilePath(for: track): Data([0x1])]
|
|
)
|
|
|
|
await viewModel.loadIfNeeded()
|
|
|
|
let remoteLabels = Set(Mirror(reflecting: try XCTUnwrap(remoteRow(in: viewModel, trackID: track.trackId))).children.compactMap(\.label))
|
|
let offlineLabels = Set(Mirror(reflecting: try XCTUnwrap(offlineRow(in: viewModel, trackID: track.trackId))).children.compactMap(\.label))
|
|
|
|
XCTAssertFalse(remoteLabels.contains("remoteTrackID"))
|
|
XCTAssertFalse(remoteLabels.contains("assetID"))
|
|
XCTAssertFalse(remoteLabels.contains("lastDownloadError"))
|
|
XCTAssertFalse(offlineLabels.contains("remoteTrackID"))
|
|
XCTAssertFalse(offlineLabels.contains("assetID"))
|
|
}
|
|
|
|
func testMissingTracksStayOutOfAvailableOffline() async throws {
|
|
let track = makeRemoteTrack(
|
|
trackId: "remote-missing-offline",
|
|
assetId: "asset-missing-offline",
|
|
title: "Lost File"
|
|
)
|
|
let viewModel = makeViewModel(
|
|
remoteTracks: [track],
|
|
downloadStates: [makeDownloadedState(for: track)]
|
|
)
|
|
|
|
await viewModel.loadIfNeeded()
|
|
|
|
let remoteTrack = try XCTUnwrap(remoteRow(in: viewModel, trackID: track.trackId))
|
|
XCTAssertEqual(remoteTrack.status, .missing)
|
|
XCTAssertEqual(remoteTrack.statusBadgeTitle, "Missing")
|
|
XCTAssertTrue(remoteTrack.canDownload)
|
|
XCTAssertEqual(remoteTrack.downloadButtonTitle, "Re-download")
|
|
XCTAssertFalse(remoteTrack.canPlay)
|
|
XCTAssertTrue(viewModel.availableOfflineTracks.isEmpty)
|
|
XCTAssertEqual(viewModel.availableOfflineSectionTitle, "Available Offline (0)")
|
|
}
|
|
|
|
func testDownloadedTrackAppearsInAvailableOfflineState() async throws {
|
|
let track = makeRemoteTrack(
|
|
trackId: "remote-recovered-download",
|
|
assetId: "asset-recovered-download",
|
|
title: "Recovered Download"
|
|
)
|
|
let viewModel = makeViewModel(
|
|
remoteTracks: [track],
|
|
downloadStates: [makeDownloadedState(for: track)],
|
|
audioFiles: [localFilePath(for: track): Data([0x1, 0x2, 0x3])]
|
|
)
|
|
|
|
await viewModel.loadIfNeeded()
|
|
|
|
let remoteTrack = try XCTUnwrap(remoteRow(in: viewModel, trackID: track.trackId))
|
|
let offlineTrack = try XCTUnwrap(offlineRow(in: viewModel, trackID: track.trackId))
|
|
|
|
XCTAssertEqual(remoteTrack.status, .downloaded)
|
|
XCTAssertEqual(remoteTrack.statusBadgeTitle, "Downloaded")
|
|
XCTAssertEqual(offlineTrack.statusBadgeTitle, "Downloaded")
|
|
XCTAssertEqual(viewModel.availableOfflineSectionTitle, "Available Offline (1)")
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
final class iPhoneLibraryViewModelDeviceAuthTests: XCTestCase {
|
|
func testRefreshSyncStoresDeviceAccessTokenWhenRegistrationRuns() async throws {
|
|
let keychain = MemoryKeychainService()
|
|
let counter = RegisterCallCounter()
|
|
let viewModel = makeViewModel(
|
|
remoteTracks: [
|
|
makeRemoteTrack(
|
|
trackId: "remote-auth-store",
|
|
assetId: "asset-auth-store",
|
|
title: "Store Token"
|
|
),
|
|
],
|
|
apiClient: TestRegisterAPIClient(counter: counter),
|
|
keychainService: keychain
|
|
)
|
|
|
|
await viewModel.loadIfNeeded()
|
|
await viewModel.refreshSync()
|
|
|
|
let storedDeviceID = try await keychain.loadValue(forKey: "velody.iphone.device-id")
|
|
let storedDeviceAccessToken = try await keychain.loadValue(
|
|
forKey: "velody.iphone.device-access-token"
|
|
)
|
|
|
|
let registerCount = await counter.currentCount()
|
|
XCTAssertEqual(registerCount, 1)
|
|
XCTAssertFalse((storedDeviceID ?? "").isEmpty)
|
|
XCTAssertFalse((storedDeviceAccessToken ?? "").isEmpty)
|
|
}
|
|
|
|
func testExistingDeviceWithoutAccessTokenReregistersCleanly() async throws {
|
|
let keychain = MemoryKeychainService()
|
|
let legacyDeviceID = "legacy-device-id"
|
|
try await keychain.save(legacyDeviceID, forKey: "velody.iphone.device-id")
|
|
try await keychain.save(
|
|
"legacy-bootstrap-token",
|
|
forKey: "velody.iphone.bootstrap-token"
|
|
)
|
|
let counter = RegisterCallCounter()
|
|
let viewModel = makeViewModel(
|
|
remoteTracks: [
|
|
makeRemoteTrack(
|
|
trackId: "remote-auth-reregister",
|
|
assetId: "asset-auth-reregister",
|
|
title: "Re-register Token"
|
|
),
|
|
],
|
|
apiClient: TestRegisterAPIClient(counter: counter),
|
|
keychainService: keychain
|
|
)
|
|
|
|
await viewModel.loadIfNeeded()
|
|
await viewModel.refreshSync()
|
|
|
|
let storedDeviceID = try await keychain.loadValue(forKey: "velody.iphone.device-id")
|
|
let storedDeviceAccessToken = try await keychain.loadValue(
|
|
forKey: "velody.iphone.device-access-token"
|
|
)
|
|
|
|
let registerCount = await counter.currentCount()
|
|
XCTAssertEqual(registerCount, 1)
|
|
XCTAssertNotEqual(storedDeviceID, legacyDeviceID)
|
|
XCTAssertFalse((storedDeviceAccessToken ?? "").isEmpty)
|
|
}
|
|
}
|
|
|
|
private actor RegisterCallCounter {
|
|
private(set) var count = 0
|
|
|
|
func increment() {
|
|
count += 1
|
|
}
|
|
|
|
func currentCount() -> Int {
|
|
count
|
|
}
|
|
}
|
|
|
|
private struct TestRegisterAPIClient: VelodyAPIClient {
|
|
let counter: RegisterCallCounter
|
|
var delayNanoseconds: UInt64 = 0
|
|
var registerError: VelodyAPIError?
|
|
|
|
private let environment = ServerEnvironment(
|
|
baseURL: ServerEnvironment.defaultLocalBaseURL,
|
|
appVersion: "Tests"
|
|
)
|
|
|
|
func registerDevice(
|
|
_ payload: DeviceRegistrationPayload
|
|
) async throws -> DeviceRegistrationResponse {
|
|
await counter.increment()
|
|
|
|
if delayNanoseconds > 0 {
|
|
try? await Task.sleep(nanoseconds: delayNanoseconds)
|
|
}
|
|
|
|
if let registerError {
|
|
throw registerError
|
|
}
|
|
|
|
return try await stubClient.registerDevice(payload)
|
|
}
|
|
|
|
func sendHeartbeat(
|
|
_ payload: DeviceHeartbeatPayload
|
|
) async throws -> DeviceHeartbeatResponse {
|
|
try await stubClient.sendHeartbeat(payload)
|
|
}
|
|
|
|
func fetchSyncBootstrap() async throws -> SyncBootstrapResponse {
|
|
try await stubClient.fetchSyncBootstrap()
|
|
}
|
|
|
|
func fetchSyncChanges(
|
|
cursor: SyncCursor
|
|
) async throws -> SyncChangesResponse {
|
|
try await stubClient.fetchSyncChanges(cursor: cursor)
|
|
}
|
|
|
|
func fetchRemoteLibrary(
|
|
deviceId: String
|
|
) async throws -> RemoteLibraryResponseDTO {
|
|
try await stubClient.fetchRemoteLibrary(deviceId: deviceId)
|
|
}
|
|
|
|
func downloadAudioAsset(
|
|
assetId: String,
|
|
deviceId: String
|
|
) async throws -> Data {
|
|
try await stubClient.downloadAudioAsset(assetId: assetId, deviceId: deviceId)
|
|
}
|
|
|
|
func downloadArtwork(
|
|
artworkId: String,
|
|
deviceId: String
|
|
) async throws -> Data {
|
|
try await stubClient.downloadArtwork(artworkId: artworkId, deviceId: deviceId)
|
|
}
|
|
|
|
func prepareUpload(
|
|
_ payload: UploadPrepareRequest
|
|
) async throws -> UploadPrepareResponse {
|
|
try await stubClient.prepareUpload(payload)
|
|
}
|
|
|
|
func fetchUploadStatus(
|
|
uploadId: String
|
|
) async throws -> UploadSessionStatusResponse {
|
|
try await stubClient.fetchUploadStatus(uploadId: uploadId)
|
|
}
|
|
|
|
func uploadFile(
|
|
uploadId: String,
|
|
fileURL: URL,
|
|
mimeType: String
|
|
) async throws -> UploadSessionStatusResponse {
|
|
try await stubClient.uploadFile(
|
|
uploadId: uploadId,
|
|
fileURL: fileURL,
|
|
mimeType: mimeType
|
|
)
|
|
}
|
|
|
|
func finalizeUpload(
|
|
uploadId: String,
|
|
payload: UploadFinalizeRequest
|
|
) async throws -> UploadFinalizeResponse {
|
|
try await stubClient.finalizeUpload(uploadId: uploadId, payload: payload)
|
|
}
|
|
|
|
private var stubClient: StubVelodyAPIClient {
|
|
StubVelodyAPIClient(environment: environment)
|
|
}
|
|
}
|
|
|
|
@MainActor
|
|
private final class FakePlaybackEngine: PlaybackEngine {
|
|
var onEvent: (@MainActor @Sendable (PlaybackEngineEvent) -> Void)?
|
|
var currentTime: Double = 0
|
|
var duration: Double = 0
|
|
var isPlaying = false
|
|
var durationByPath: [String: Double] = [:]
|
|
|
|
func loadTrack(at fileURL: URL, startTime: Double) throws {
|
|
currentTime = startTime
|
|
duration = durationByPath[fileURL.path] ?? 0
|
|
isPlaying = false
|
|
}
|
|
|
|
func play() throws {
|
|
isPlaying = true
|
|
}
|
|
|
|
func pause() {
|
|
isPlaying = false
|
|
}
|
|
|
|
func stop() {
|
|
isPlaying = false
|
|
currentTime = 0
|
|
}
|
|
|
|
func seek(to time: Double) throws {
|
|
currentTime = min(max(time, 0), duration)
|
|
}
|
|
}
|