Expose iPhone playback controls
This commit is contained in:
parent
295c6c1d9b
commit
e147ce38be
@ -5,9 +5,56 @@ import UIKit
|
|||||||
#endif
|
#endif
|
||||||
|
|
||||||
enum NowPlayingCardLayoutMetrics {
|
enum NowPlayingCardLayoutMetrics {
|
||||||
|
static let favoriteButtonSize: CGFloat = 36
|
||||||
|
static let favoriteButtonSymbolSize: CGFloat = 14
|
||||||
|
|
||||||
|
struct ControlsLayout: Equatable {
|
||||||
|
enum Style: Equatable {
|
||||||
|
case compact
|
||||||
|
case regular
|
||||||
|
}
|
||||||
|
|
||||||
|
let style: Style
|
||||||
|
let buttonSpacing: CGFloat
|
||||||
|
let secondaryButtonSize: CGFloat
|
||||||
|
let primaryButtonSize: CGFloat
|
||||||
|
let secondarySymbolSize: CGFloat
|
||||||
|
let primarySymbolSize: CGFloat
|
||||||
|
|
||||||
|
var firstRowWidth: CGFloat {
|
||||||
|
(secondaryButtonSize * 4)
|
||||||
|
+ primaryButtonSize
|
||||||
|
+ (buttonSpacing * 4)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
static func artworkHeight(for contentWidth: CGFloat) -> CGFloat {
|
static func artworkHeight(for contentWidth: CGFloat) -> CGFloat {
|
||||||
min(max(contentWidth * 0.42, 148), 176)
|
min(max(contentWidth * 0.42, 148), 176)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static func controlsLayout(for availableWidth: CGFloat) -> ControlsLayout {
|
||||||
|
let regular = ControlsLayout(
|
||||||
|
style: .regular,
|
||||||
|
buttonSpacing: 10,
|
||||||
|
secondaryButtonSize: 36,
|
||||||
|
primaryButtonSize: 42,
|
||||||
|
secondarySymbolSize: 14,
|
||||||
|
primarySymbolSize: 16
|
||||||
|
)
|
||||||
|
|
||||||
|
if availableWidth - regular.firstRowWidth >= 40 {
|
||||||
|
return regular
|
||||||
|
}
|
||||||
|
|
||||||
|
return ControlsLayout(
|
||||||
|
style: .compact,
|
||||||
|
buttonSpacing: 6,
|
||||||
|
secondaryButtonSize: 34,
|
||||||
|
primaryButtonSize: 40,
|
||||||
|
secondarySymbolSize: 14,
|
||||||
|
primarySymbolSize: 16
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
struct iPhoneLibraryView: View {
|
struct iPhoneLibraryView: View {
|
||||||
@ -23,12 +70,9 @@ struct iPhoneLibraryView: View {
|
|||||||
static let controlButtonHorizontalPadding: CGFloat = 14
|
static let controlButtonHorizontalPadding: CGFloat = 14
|
||||||
static let controlButtonSymbolSize: CGFloat = 13
|
static let controlButtonSymbolSize: CGFloat = 13
|
||||||
static let controlButtonSpacing: CGFloat = 6
|
static let controlButtonSpacing: CGFloat = 6
|
||||||
static let favoriteButtonSize: CGFloat = 36
|
|
||||||
static let favoriteButtonSymbolSize: CGFloat = 14
|
|
||||||
static let badgeMinHeight: CGFloat = 28
|
static let badgeMinHeight: CGFloat = 28
|
||||||
static let badgeHorizontalPadding: CGFloat = 12
|
static let badgeHorizontalPadding: CGFloat = 12
|
||||||
static let badgeVerticalPadding: CGFloat = 6
|
static let badgeVerticalPadding: CGFloat = 6
|
||||||
static let controlGroupSpacing: CGFloat = 8
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private enum ActionButtonRole {
|
private enum ActionButtonRole {
|
||||||
@ -77,11 +121,29 @@ struct iPhoneLibraryView: View {
|
|||||||
NowPlayingCardLayoutMetrics.artworkHeight(for: contentWidth)
|
NowPlayingCardLayoutMetrics.artworkHeight(for: contentWidth)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var nowPlayingCardContentWidth: CGFloat {
|
||||||
|
max(contentWidth - (cardPadding * 2), 0)
|
||||||
}
|
}
|
||||||
|
|
||||||
@State private var viewModel = iPhoneLibraryViewModel()
|
var nowPlayingControlsLayout: NowPlayingCardLayoutMetrics.ControlsLayout {
|
||||||
|
NowPlayingCardLayoutMetrics.controlsLayout(for: nowPlayingCardContentWidth)
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
@State private var viewModel: iPhoneLibraryViewModel
|
||||||
@State private var scrubbedPlaybackTime: Double?
|
@State private var scrubbedPlaybackTime: Double?
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
init() {
|
||||||
|
_viewModel = State(initialValue: iPhoneLibraryViewModel())
|
||||||
|
}
|
||||||
|
|
||||||
|
@MainActor
|
||||||
|
init(viewModel: iPhoneLibraryViewModel) {
|
||||||
|
_viewModel = State(initialValue: viewModel)
|
||||||
|
}
|
||||||
|
|
||||||
var body: some View {
|
var body: some View {
|
||||||
GeometryReader { proxy in
|
GeometryReader { proxy in
|
||||||
let layout = RootLayout(rootWidth: proxy.size.width)
|
let layout = RootLayout(rootWidth: proxy.size.width)
|
||||||
@ -193,7 +255,7 @@ struct iPhoneLibraryView: View {
|
|||||||
|
|
||||||
@ViewBuilder
|
@ViewBuilder
|
||||||
private func nowPlayingCardView(_ card: NowPlayingCardViewData, layout: RootLayout) -> some View {
|
private func nowPlayingCardView(_ card: NowPlayingCardViewData, layout: RootLayout) -> some View {
|
||||||
VStack(alignment: .leading, spacing: 16) {
|
VStack(alignment: .leading, spacing: 14) {
|
||||||
ViewThatFits(in: .vertical) {
|
ViewThatFits(in: .vertical) {
|
||||||
HStack(alignment: .top, spacing: 12) {
|
HStack(alignment: .top, spacing: 12) {
|
||||||
nowPlayingHeaderText
|
nowPlayingHeaderText
|
||||||
@ -209,24 +271,7 @@ struct iPhoneLibraryView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 8) {
|
nowPlayingMetadataText(card)
|
||||||
Text(card.title)
|
|
||||||
.font(.title2.weight(.bold))
|
|
||||||
.foregroundStyle(.primary)
|
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
|
||||||
|
|
||||||
Text(card.artist)
|
|
||||||
.font(.subheadline)
|
|
||||||
.foregroundStyle(.secondary)
|
|
||||||
.lineLimit(2)
|
|
||||||
.fixedSize(horizontal: false, vertical: true)
|
|
||||||
|
|
||||||
Text(card.playbackStateText)
|
|
||||||
.font(.footnote.weight(.medium))
|
|
||||||
.foregroundStyle(playbackStateColor(for: viewModel.nowPlaying.playbackState))
|
|
||||||
.lineLimit(1)
|
|
||||||
}
|
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
|
||||||
|
|
||||||
nowPlayingArtworkView(localFilePath: card.artworkLocalFilePath, layout: layout)
|
nowPlayingArtworkView(localFilePath: card.artworkLocalFilePath, layout: layout)
|
||||||
|
|
||||||
@ -267,7 +312,7 @@ struct iPhoneLibraryView: View {
|
|||||||
.foregroundStyle(.red)
|
.foregroundStyle(.red)
|
||||||
}
|
}
|
||||||
|
|
||||||
nowPlayingControls(card)
|
nowPlayingControls(card, layout: layout)
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
.padding(layout.cardPadding)
|
.padding(layout.cardPadding)
|
||||||
@ -645,58 +690,122 @@ struct iPhoneLibraryView: View {
|
|||||||
.font(.headline)
|
.font(.headline)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func nowPlayingControls(_ card: NowPlayingCardViewData) -> some View {
|
private func nowPlayingMetadataText(_ card: NowPlayingCardViewData) -> some View {
|
||||||
ViewThatFits(in: .vertical) {
|
VStack(alignment: .leading, spacing: 8) {
|
||||||
HStack(spacing: LayoutMetrics.controlGroupSpacing) {
|
Text(card.title)
|
||||||
if viewModel.nowPlayingFavoriteTrackID == card.trackID {
|
.font(.title2.weight(.bold))
|
||||||
nowPlayingFavoriteButton(trackID: card.trackID)
|
.foregroundStyle(.primary)
|
||||||
}
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
|
||||||
nowPlayingToggleButton(card)
|
Text(card.artist)
|
||||||
nowPlayingStopButton
|
.font(.subheadline)
|
||||||
}
|
.foregroundStyle(.secondary)
|
||||||
|
.lineLimit(2)
|
||||||
|
.fixedSize(horizontal: false, vertical: true)
|
||||||
|
|
||||||
VStack(alignment: .leading, spacing: 12) {
|
Text(card.playbackStateText)
|
||||||
if viewModel.nowPlayingFavoriteTrackID == card.trackID {
|
.font(.footnote.weight(.medium))
|
||||||
nowPlayingFavoriteButton(trackID: card.trackID)
|
.foregroundStyle(playbackStateColor(for: viewModel.nowPlaying.playbackState))
|
||||||
}
|
.lineLimit(1)
|
||||||
|
|
||||||
HStack(spacing: LayoutMetrics.controlGroupSpacing) {
|
|
||||||
nowPlayingToggleButton(card)
|
|
||||||
nowPlayingStopButton
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
.frame(maxWidth: .infinity, alignment: .leading)
|
.frame(maxWidth: .infinity, alignment: .leading)
|
||||||
|
.layoutPriority(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func nowPlayingFavoriteButton(trackID: String) -> some View {
|
private func nowPlayingControls(
|
||||||
favoriteControlButton(isFavorite: viewModel.isNowPlayingTrackFavorite) {
|
_ card: NowPlayingCardViewData,
|
||||||
Task {
|
layout: RootLayout
|
||||||
await viewModel.toggleFavorite(trackID: trackID)
|
) -> some View {
|
||||||
}
|
let controlsLayout = layout.nowPlayingControlsLayout
|
||||||
|
|
||||||
|
return HStack(alignment: .center, spacing: controlsLayout.buttonSpacing) {
|
||||||
|
nowPlayingShuffleButton(controlsLayout: controlsLayout)
|
||||||
|
nowPlayingPreviousButton(controlsLayout: controlsLayout)
|
||||||
|
nowPlayingToggleButton(card, controlsLayout: controlsLayout)
|
||||||
|
nowPlayingNextButton(controlsLayout: controlsLayout)
|
||||||
|
nowPlayingRepeatButton(controlsLayout: controlsLayout)
|
||||||
}
|
}
|
||||||
|
.frame(maxWidth: .infinity)
|
||||||
}
|
}
|
||||||
|
|
||||||
private func nowPlayingToggleButton(_ card: NowPlayingCardViewData) -> some View {
|
private func nowPlayingToggleButton(
|
||||||
rowActionButton(
|
_ card: NowPlayingCardViewData,
|
||||||
title: card.isPlaying ? "Pause" : "Play",
|
controlsLayout: NowPlayingCardLayoutMetrics.ControlsLayout
|
||||||
|
) -> some View {
|
||||||
|
compactNowPlayingButton(
|
||||||
systemImage: playbackActionSystemImage(isPlaying: card.isPlaying),
|
systemImage: playbackActionSystemImage(isPlaying: card.isPlaying),
|
||||||
|
accessibilityLabel: card.isPlaying ? "Pause" : "Play",
|
||||||
role: .primary,
|
role: .primary,
|
||||||
isEnabled: true
|
isEnabled: true,
|
||||||
|
isActive: true,
|
||||||
|
controlSize: controlsLayout.primaryButtonSize,
|
||||||
|
symbolSize: controlsLayout.primarySymbolSize
|
||||||
) {
|
) {
|
||||||
viewModel.togglePlayback(trackID: card.trackID)
|
viewModel.togglePlayback(trackID: card.trackID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private var nowPlayingStopButton: some View {
|
private func nowPlayingShuffleButton(
|
||||||
rowActionButton(
|
controlsLayout: NowPlayingCardLayoutMetrics.ControlsLayout
|
||||||
title: "Stop",
|
) -> some View {
|
||||||
systemImage: "stop.fill",
|
compactNowPlayingButton(
|
||||||
|
systemImage: "shuffle",
|
||||||
|
accessibilityLabel: viewModel.isShuffleEnabled ? "Disable Shuffle" : "Enable Shuffle",
|
||||||
role: .secondary,
|
role: .secondary,
|
||||||
isEnabled: true
|
isEnabled: !viewModel.nowPlaying.queueTrackIDs.isEmpty,
|
||||||
|
isActive: viewModel.isShuffleEnabled,
|
||||||
|
controlSize: controlsLayout.secondaryButtonSize,
|
||||||
|
symbolSize: controlsLayout.secondarySymbolSize
|
||||||
) {
|
) {
|
||||||
viewModel.stopPlayback()
|
viewModel.toggleShuffle()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func nowPlayingPreviousButton(
|
||||||
|
controlsLayout: NowPlayingCardLayoutMetrics.ControlsLayout
|
||||||
|
) -> some View {
|
||||||
|
compactNowPlayingButton(
|
||||||
|
systemImage: "backward.fill",
|
||||||
|
accessibilityLabel: "Previous Track",
|
||||||
|
role: .secondary,
|
||||||
|
isEnabled: viewModel.canGoPrevious,
|
||||||
|
isActive: false,
|
||||||
|
controlSize: controlsLayout.secondaryButtonSize,
|
||||||
|
symbolSize: controlsLayout.secondarySymbolSize
|
||||||
|
) {
|
||||||
|
viewModel.previousTrack()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func nowPlayingNextButton(
|
||||||
|
controlsLayout: NowPlayingCardLayoutMetrics.ControlsLayout
|
||||||
|
) -> some View {
|
||||||
|
compactNowPlayingButton(
|
||||||
|
systemImage: "forward.fill",
|
||||||
|
accessibilityLabel: "Next Track",
|
||||||
|
role: .secondary,
|
||||||
|
isEnabled: viewModel.canGoNext,
|
||||||
|
isActive: false,
|
||||||
|
controlSize: controlsLayout.secondaryButtonSize,
|
||||||
|
symbolSize: controlsLayout.secondarySymbolSize
|
||||||
|
) {
|
||||||
|
viewModel.nextTrack()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private func nowPlayingRepeatButton(
|
||||||
|
controlsLayout: NowPlayingCardLayoutMetrics.ControlsLayout
|
||||||
|
) -> some View {
|
||||||
|
compactNowPlayingButton(
|
||||||
|
systemImage: repeatButtonSymbol,
|
||||||
|
accessibilityLabel: repeatAccessibilityLabel,
|
||||||
|
role: .secondary,
|
||||||
|
isEnabled: !viewModel.nowPlaying.queueTrackIDs.isEmpty,
|
||||||
|
isActive: viewModel.repeatMode != .off,
|
||||||
|
controlSize: controlsLayout.secondaryButtonSize,
|
||||||
|
symbolSize: controlsLayout.secondarySymbolSize
|
||||||
|
) {
|
||||||
|
viewModel.cycleRepeatMode()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -750,11 +859,11 @@ struct iPhoneLibraryView: View {
|
|||||||
) -> some View {
|
) -> some View {
|
||||||
Button(action: action) {
|
Button(action: action) {
|
||||||
Image(systemName: isFavorite ? "heart.fill" : "heart")
|
Image(systemName: isFavorite ? "heart.fill" : "heart")
|
||||||
.font(.system(size: LayoutMetrics.favoriteButtonSymbolSize, weight: .semibold))
|
.font(.system(size: NowPlayingCardLayoutMetrics.favoriteButtonSymbolSize, weight: .semibold))
|
||||||
.foregroundStyle(isFavorite ? .red : .gray)
|
.foregroundStyle(isFavorite ? .red : .gray)
|
||||||
.frame(
|
.frame(
|
||||||
width: LayoutMetrics.favoriteButtonSize,
|
width: NowPlayingCardLayoutMetrics.favoriteButtonSize,
|
||||||
height: LayoutMetrics.favoriteButtonSize
|
height: NowPlayingCardLayoutMetrics.favoriteButtonSize
|
||||||
)
|
)
|
||||||
.background(
|
.background(
|
||||||
Capsule()
|
Capsule()
|
||||||
@ -769,6 +878,41 @@ struct iPhoneLibraryView: View {
|
|||||||
.accessibilityLabel(isFavorite ? "Remove Favorite" : "Add Favorite")
|
.accessibilityLabel(isFavorite ? "Remove Favorite" : "Add Favorite")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func compactNowPlayingButton(
|
||||||
|
systemImage: String,
|
||||||
|
accessibilityLabel: String,
|
||||||
|
role: ActionButtonRole,
|
||||||
|
isEnabled: Bool,
|
||||||
|
isActive: Bool,
|
||||||
|
controlSize: CGFloat,
|
||||||
|
symbolSize: CGFloat,
|
||||||
|
action: @escaping () -> Void
|
||||||
|
) -> some View {
|
||||||
|
let colors = compactNowPlayingButtonColors(
|
||||||
|
for: role,
|
||||||
|
isEnabled: isEnabled,
|
||||||
|
isActive: isActive
|
||||||
|
)
|
||||||
|
|
||||||
|
return Button(action: action) {
|
||||||
|
Image(systemName: systemImage)
|
||||||
|
.font(.system(size: symbolSize, weight: .semibold))
|
||||||
|
.foregroundStyle(colors.foreground)
|
||||||
|
.frame(width: controlSize, height: controlSize)
|
||||||
|
.background(
|
||||||
|
Circle()
|
||||||
|
.fill(colors.background)
|
||||||
|
)
|
||||||
|
.overlay(
|
||||||
|
Circle()
|
||||||
|
.strokeBorder(colors.border, lineWidth: 1)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.buttonStyle(.plain)
|
||||||
|
.disabled(!isEnabled)
|
||||||
|
.accessibilityLabel(accessibilityLabel)
|
||||||
|
}
|
||||||
|
|
||||||
private func actionButtonColors(for role: ActionButtonRole, isEnabled: Bool) -> ActionButtonColors {
|
private func actionButtonColors(for role: ActionButtonRole, isEnabled: Bool) -> ActionButtonColors {
|
||||||
switch role {
|
switch role {
|
||||||
case .primary:
|
case .primary:
|
||||||
@ -786,6 +930,51 @@ struct iPhoneLibraryView: View {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func compactNowPlayingButtonColors(
|
||||||
|
for role: ActionButtonRole,
|
||||||
|
isEnabled: Bool,
|
||||||
|
isActive: Bool
|
||||||
|
) -> ActionButtonColors {
|
||||||
|
switch role {
|
||||||
|
case .primary:
|
||||||
|
return actionButtonColors(for: .primary, isEnabled: isEnabled)
|
||||||
|
case .secondary:
|
||||||
|
if isActive {
|
||||||
|
return ActionButtonColors(
|
||||||
|
foreground: Color.blue.opacity(isEnabled ? 1 : 0.42),
|
||||||
|
background: Color.blue.opacity(isEnabled ? 0.12 : 0.06),
|
||||||
|
border: Color.blue.opacity(isEnabled ? 0.18 : 0.08)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
return ActionButtonColors(
|
||||||
|
foreground: isEnabled ? .primary : .secondary,
|
||||||
|
background: Color(uiColor: .tertiarySystemFill).opacity(isEnabled ? 1 : 0.74),
|
||||||
|
border: Color(uiColor: .separator).opacity(isEnabled ? 0.14 : 0.08)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var repeatButtonSymbol: String {
|
||||||
|
switch viewModel.repeatMode {
|
||||||
|
case .off, .all:
|
||||||
|
return "repeat"
|
||||||
|
case .one:
|
||||||
|
return "repeat.1"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private var repeatAccessibilityLabel: String {
|
||||||
|
switch viewModel.repeatMode {
|
||||||
|
case .off:
|
||||||
|
return "Repeat Off"
|
||||||
|
case .one:
|
||||||
|
return "Repeat One"
|
||||||
|
case .all:
|
||||||
|
return "Repeat All"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private func playbackActionSystemImage(isPlaying: Bool) -> String {
|
private func playbackActionSystemImage(isPlaying: Bool) -> String {
|
||||||
isPlaying ? "pause.fill" : "play.fill"
|
isPlaying ? "pause.fill" : "play.fill"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -20,6 +20,10 @@ protocol iPhoneLocalAudioPlaying: AnyObject {
|
|||||||
func pause()
|
func pause()
|
||||||
func stop()
|
func stop()
|
||||||
func seek(to time: Double)
|
func seek(to time: Double)
|
||||||
|
func previousTrack()
|
||||||
|
func nextTrack()
|
||||||
|
func toggleShuffle()
|
||||||
|
func cycleRepeatMode()
|
||||||
}
|
}
|
||||||
|
|
||||||
enum iPhonePlaybackState: Equatable {
|
enum iPhonePlaybackState: Equatable {
|
||||||
@ -49,6 +53,9 @@ struct iPhoneNowPlayingState: Equatable {
|
|||||||
var trackID: String?
|
var trackID: String?
|
||||||
var title: String?
|
var title: String?
|
||||||
var artist: String?
|
var artist: String?
|
||||||
|
var queueTrackIDs: [String]
|
||||||
|
var isShuffleEnabled: Bool
|
||||||
|
var repeatMode: PlaybackRepeatMode
|
||||||
var playbackState: iPhonePlaybackState
|
var playbackState: iPhonePlaybackState
|
||||||
var currentTime: Double
|
var currentTime: Double
|
||||||
var duration: Double
|
var duration: Double
|
||||||
@ -58,6 +65,9 @@ struct iPhoneNowPlayingState: Equatable {
|
|||||||
trackID: nil,
|
trackID: nil,
|
||||||
title: nil,
|
title: nil,
|
||||||
artist: nil,
|
artist: nil,
|
||||||
|
queueTrackIDs: [],
|
||||||
|
isShuffleEnabled: false,
|
||||||
|
repeatMode: .off,
|
||||||
playbackState: .stopped,
|
playbackState: .stopped,
|
||||||
currentTime: 0,
|
currentTime: 0,
|
||||||
duration: 0,
|
duration: 0,
|
||||||
@ -77,7 +87,7 @@ struct iPhoneNowPlayingState: Equatable {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private struct StorediPhonePlaybackSession: Codable {
|
private struct LegacyiPhonePlaybackSession: Codable {
|
||||||
var currentTrackID: String?
|
var currentTrackID: String?
|
||||||
var currentTime: Double
|
var currentTime: Double
|
||||||
}
|
}
|
||||||
@ -98,17 +108,22 @@ struct iPhonePlaybackSessionStore: PlaybackSessionStore, @unchecked Sendable {
|
|||||||
|
|
||||||
func loadSession() -> PlaybackSessionSnapshot? {
|
func loadSession() -> PlaybackSessionSnapshot? {
|
||||||
guard let data = userDefaults.data(forKey: storageKey),
|
guard let data = userDefaults.data(forKey: storageKey),
|
||||||
let storedSession = try? decoder.decode(StorediPhonePlaybackSession.self, from: data)
|
!data.isEmpty
|
||||||
else {
|
else {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if let storedSession = try? decoder.decode(PlaybackSessionSnapshot.self, from: data) {
|
||||||
|
return storedSession
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let legacySession = try? decoder.decode(LegacyiPhonePlaybackSession.self, from: data) else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
return PlaybackSessionSnapshot(
|
return PlaybackSessionSnapshot(
|
||||||
queueTrackIDs: [],
|
currentTrackID: legacySession.currentTrackID,
|
||||||
currentTrackID: storedSession.currentTrackID,
|
currentTime: legacySession.currentTime
|
||||||
currentTime: storedSession.currentTime,
|
|
||||||
isShuffleEnabled: false,
|
|
||||||
repeatMode: .off
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -120,9 +135,12 @@ struct iPhonePlaybackSessionStore: PlaybackSessionStore, @unchecked Sendable {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
let storedSession = StorediPhonePlaybackSession(
|
let storedSession = PlaybackSessionSnapshot(
|
||||||
|
queueTrackIDs: session.queueTrackIDs,
|
||||||
currentTrackID: currentTrackID,
|
currentTrackID: currentTrackID,
|
||||||
currentTime: session.currentTime
|
currentTime: session.currentTime,
|
||||||
|
isShuffleEnabled: session.isShuffleEnabled,
|
||||||
|
repeatMode: session.repeatMode
|
||||||
)
|
)
|
||||||
|
|
||||||
guard let data = try? encoder.encode(storedSession) else {
|
guard let data = try? encoder.encode(storedSession) else {
|
||||||
@ -193,6 +211,28 @@ final class iPhonePlaybackControllerPlayer: iPhoneLocalAudioPlaying {
|
|||||||
apply(controller.nowPlayingState)
|
apply(controller.nowPlayingState)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func previousTrack() {
|
||||||
|
idleStateHint = .paused
|
||||||
|
controller.previous()
|
||||||
|
apply(controller.nowPlayingState)
|
||||||
|
}
|
||||||
|
|
||||||
|
func nextTrack() {
|
||||||
|
idleStateHint = .stopped
|
||||||
|
controller.next()
|
||||||
|
apply(controller.nowPlayingState)
|
||||||
|
}
|
||||||
|
|
||||||
|
func toggleShuffle() {
|
||||||
|
controller.toggleShuffle()
|
||||||
|
apply(controller.nowPlayingState)
|
||||||
|
}
|
||||||
|
|
||||||
|
func cycleRepeatMode() {
|
||||||
|
controller.cycleRepeatMode()
|
||||||
|
apply(controller.nowPlayingState)
|
||||||
|
}
|
||||||
|
|
||||||
private func apply(_ nowPlayingState: NowPlayingState) {
|
private func apply(_ nowPlayingState: NowPlayingState) {
|
||||||
let effectiveDuration = max(
|
let effectiveDuration = max(
|
||||||
nowPlayingState.duration,
|
nowPlayingState.duration,
|
||||||
@ -249,6 +289,9 @@ final class iPhonePlaybackControllerPlayer: iPhoneLocalAudioPlaying {
|
|||||||
trackID: nowPlayingState.currentTrackID,
|
trackID: nowPlayingState.currentTrackID,
|
||||||
title: nowPlayingState.currentTrack?.title,
|
title: nowPlayingState.currentTrack?.title,
|
||||||
artist: nowPlayingState.currentTrack?.artist,
|
artist: nowPlayingState.currentTrack?.artist,
|
||||||
|
queueTrackIDs: nowPlayingState.queueTrackIDs,
|
||||||
|
isShuffleEnabled: nowPlayingState.isShuffleEnabled,
|
||||||
|
repeatMode: nowPlayingState.repeatMode,
|
||||||
playbackState: playbackState,
|
playbackState: playbackState,
|
||||||
currentTime: nowPlayingState.currentTime,
|
currentTime: nowPlayingState.currentTime,
|
||||||
duration: effectiveDuration,
|
duration: effectiveDuration,
|
||||||
@ -325,6 +368,34 @@ final class iPhoneLibraryViewModel {
|
|||||||
var nowPlayingFavoriteTrackID: String?
|
var nowPlayingFavoriteTrackID: String?
|
||||||
var isNowPlayingTrackFavorite = false
|
var isNowPlayingTrackFavorite = false
|
||||||
|
|
||||||
|
var isShuffleEnabled: Bool {
|
||||||
|
nowPlaying.isShuffleEnabled
|
||||||
|
}
|
||||||
|
|
||||||
|
var repeatMode: PlaybackRepeatMode {
|
||||||
|
nowPlaying.repeatMode
|
||||||
|
}
|
||||||
|
|
||||||
|
var canGoPrevious: Bool {
|
||||||
|
guard nowPlaying.hasTrack else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
if nowPlaying.currentTime > 0.25 {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
return playbackQueueForNowPlaying?.previousTrackID() != nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var canGoNext: Bool {
|
||||||
|
guard nowPlaying.hasTrack else {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
return playbackQueueForNowPlaying?.nextTrackID() != nil
|
||||||
|
}
|
||||||
|
|
||||||
private let environment: ServerEnvironment
|
private let environment: ServerEnvironment
|
||||||
private let apiClient: any VelodyAPIClient
|
private let apiClient: any VelodyAPIClient
|
||||||
private let syncService: RemoteLibrarySyncService
|
private let syncService: RemoteLibrarySyncService
|
||||||
@ -634,6 +705,40 @@ final class iPhoneLibraryViewModel {
|
|||||||
player.seek(to: time)
|
player.seek(to: time)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func previousTrack() {
|
||||||
|
guard nowPlaying.hasTrack else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
player.previousTrack()
|
||||||
|
handleCurrentPlaybackResult()
|
||||||
|
}
|
||||||
|
|
||||||
|
func nextTrack() {
|
||||||
|
guard nowPlaying.hasTrack else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
player.nextTrack()
|
||||||
|
handleCurrentPlaybackResult()
|
||||||
|
}
|
||||||
|
|
||||||
|
func toggleShuffle() {
|
||||||
|
guard !nowPlaying.queueTrackIDs.isEmpty else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
player.toggleShuffle()
|
||||||
|
}
|
||||||
|
|
||||||
|
func cycleRepeatMode() {
|
||||||
|
guard !nowPlaying.queueTrackIDs.isEmpty else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
player.cycleRepeatMode()
|
||||||
|
}
|
||||||
|
|
||||||
func toggleFavorite(trackID: String) async {
|
func toggleFavorite(trackID: String) async {
|
||||||
guard hasTrackInLibrarySnapshot(trackID) else {
|
guard hasTrackInLibrarySnapshot(trackID) else {
|
||||||
return
|
return
|
||||||
@ -820,6 +925,30 @@ final class iPhoneLibraryViewModel {
|
|||||||
rebuildRows()
|
rebuildRows()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private var playbackQueueForNowPlaying: PlaybackQueue? {
|
||||||
|
guard !nowPlaying.queueTrackIDs.isEmpty else {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return PlaybackQueue(
|
||||||
|
trackIDs: nowPlaying.queueTrackIDs,
|
||||||
|
currentTrackID: nowPlaying.trackID,
|
||||||
|
queuedTrackIDs: nowPlaying.queueTrackIDs,
|
||||||
|
isShuffleEnabled: nowPlaying.isShuffleEnabled,
|
||||||
|
repeatMode: nowPlaying.repeatMode
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
private func handleCurrentPlaybackResult() {
|
||||||
|
guard let trackID = nowPlaying.trackID,
|
||||||
|
let track = cachedRemoteTracksByID[trackID]
|
||||||
|
else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
handlePlaybackResult(for: track)
|
||||||
|
}
|
||||||
|
|
||||||
private func handlePlaybackResult(for track: RemoteTrack) {
|
private func handlePlaybackResult(for track: RemoteTrack) {
|
||||||
switch nowPlaying.playbackState {
|
switch nowPlaying.playbackState {
|
||||||
case .missingFile:
|
case .missingFile:
|
||||||
|
|||||||
@ -1,4 +1,10 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
|
#if canImport(SwiftUI)
|
||||||
|
import SwiftUI
|
||||||
|
#endif
|
||||||
|
#if canImport(UIKit)
|
||||||
|
import UIKit
|
||||||
|
#endif
|
||||||
import XCTest
|
import XCTest
|
||||||
import VelodyDomain
|
import VelodyDomain
|
||||||
import VelodyNetworking
|
import VelodyNetworking
|
||||||
@ -22,6 +28,128 @@ final class iPhoneLibraryViewModelPlaybackTests: XCTestCase {
|
|||||||
XCTAssertEqual(NowPlayingCardLayoutMetrics.artworkHeight(for: 520), 176)
|
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 {
|
func testNowPlayingCardShowsTitleArtistAndArtwork() async throws {
|
||||||
let artwork = RemoteArtwork(
|
let artwork = RemoteArtwork(
|
||||||
artworkId: "artwork-midnight",
|
artworkId: "artwork-midnight",
|
||||||
@ -137,6 +265,85 @@ final class iPhoneLibraryViewModelPlaybackTests: XCTestCase {
|
|||||||
XCTAssertEqual(viewModel.nowPlaying.duration, 245)
|
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 {
|
func testSearchDoesNotClearNowPlaying() async throws {
|
||||||
let firstTrack = makeRemoteTrack(
|
let firstTrack = makeRemoteTrack(
|
||||||
trackId: "remote-first-search",
|
trackId: "remote-first-search",
|
||||||
@ -217,7 +424,210 @@ final class iPhoneLibraryViewModelPlaybackTests: XCTestCase {
|
|||||||
XCTAssertEqual(viewModel.nowPlaying.duration, 245)
|
XCTAssertEqual(viewModel.nowPlaying.duration, 245)
|
||||||
}
|
}
|
||||||
|
|
||||||
func testRelaunchRestoresMetadataButDoesNotAutoplay() {
|
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 suiteName = "de.diyaa.velody.tests.\(UUID().uuidString)"
|
||||||
let defaults = UserDefaults(suiteName: suiteName)!
|
let defaults = UserDefaults(suiteName: suiteName)!
|
||||||
defer {
|
defer {
|
||||||
@ -228,46 +638,64 @@ final class iPhoneLibraryViewModelPlaybackTests: XCTestCase {
|
|||||||
userDefaults: defaults,
|
userDefaults: defaults,
|
||||||
storageKey: "playback"
|
storageKey: "playback"
|
||||||
)
|
)
|
||||||
let track = LibraryTrack(
|
let firstTrack = LibraryTrack(
|
||||||
id: "remote-restore",
|
id: "remote-restore-first",
|
||||||
title: "Restore Point",
|
title: "Restore Point",
|
||||||
artist: "Velody Artist",
|
artist: "Velody Artist",
|
||||||
durationSeconds: 245,
|
durationSeconds: 245,
|
||||||
localFilePath: "/in-memory/asset-restore.mp3",
|
localFilePath: "/in-memory/asset-restore-first.mp3",
|
||||||
remoteTrackId: "remote-restore"
|
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()
|
let firstEngine = FakePlaybackEngine()
|
||||||
firstEngine.durationByPath[track.localFilePath] = 245
|
firstEngine.durationByPath[firstTrack.localFilePath] = 245
|
||||||
|
firstEngine.durationByPath[secondTrack.localFilePath] = 188
|
||||||
let firstPlayer = iPhonePlaybackControllerPlayer(
|
let firstPlayer = iPhonePlaybackControllerPlayer(
|
||||||
controller: PlaybackController(
|
controller: PlaybackController(
|
||||||
engine: firstEngine,
|
engine: firstEngine,
|
||||||
sessionStore: sessionStore
|
sessionStore: sessionStore
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
firstPlayer.setCatalogTracks([track])
|
firstPlayer.setCatalogTracks([firstTrack, secondTrack])
|
||||||
firstPlayer.play(trackID: track.id)
|
firstPlayer.play(trackID: secondTrack.id)
|
||||||
|
firstPlayer.toggleShuffle()
|
||||||
|
firstPlayer.cycleRepeatMode()
|
||||||
|
firstPlayer.cycleRepeatMode()
|
||||||
firstEngine.currentTime = 48
|
firstEngine.currentTime = 48
|
||||||
firstPlayer.pause()
|
firstPlayer.pause()
|
||||||
|
|
||||||
let storedSession = sessionStore.loadSession()
|
let storedSession = sessionStore.loadSession()
|
||||||
XCTAssertEqual(storedSession?.currentTrackID, track.id)
|
XCTAssertEqual(storedSession?.currentTrackID, secondTrack.id)
|
||||||
XCTAssertEqual(storedSession?.currentTime, 48)
|
XCTAssertEqual(storedSession?.currentTime, 48)
|
||||||
XCTAssertEqual(storedSession?.queueTrackIDs, [])
|
XCTAssertEqual(storedSession?.queueTrackIDs, [secondTrack.id, firstTrack.id])
|
||||||
|
XCTAssertEqual(storedSession?.isShuffleEnabled, true)
|
||||||
|
XCTAssertEqual(storedSession?.repeatMode, .all)
|
||||||
|
|
||||||
let secondEngine = FakePlaybackEngine()
|
let secondEngine = FakePlaybackEngine()
|
||||||
secondEngine.durationByPath[track.localFilePath] = 245
|
secondEngine.durationByPath[firstTrack.localFilePath] = 245
|
||||||
|
secondEngine.durationByPath[secondTrack.localFilePath] = 188
|
||||||
let relaunchedPlayer = iPhonePlaybackControllerPlayer(
|
let relaunchedPlayer = iPhonePlaybackControllerPlayer(
|
||||||
controller: PlaybackController(
|
controller: PlaybackController(
|
||||||
engine: secondEngine,
|
engine: secondEngine,
|
||||||
sessionStore: sessionStore
|
sessionStore: sessionStore
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
relaunchedPlayer.setCatalogTracks([track])
|
relaunchedPlayer.setCatalogTracks([firstTrack, secondTrack])
|
||||||
|
|
||||||
XCTAssertEqual(relaunchedPlayer.state.trackID, track.id)
|
XCTAssertEqual(relaunchedPlayer.state.trackID, secondTrack.id)
|
||||||
XCTAssertEqual(relaunchedPlayer.state.title, track.title)
|
XCTAssertEqual(relaunchedPlayer.state.title, secondTrack.title)
|
||||||
XCTAssertEqual(relaunchedPlayer.state.artist, track.artist)
|
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.currentTime, 48)
|
||||||
XCTAssertEqual(relaunchedPlayer.state.playbackState, .paused)
|
XCTAssertEqual(relaunchedPlayer.state.playbackState, .paused)
|
||||||
XCTAssertFalse(relaunchedPlayer.state.isPlaying)
|
XCTAssertFalse(relaunchedPlayer.state.isPlaying)
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import Foundation
|
import Foundation
|
||||||
import VelodyDomain
|
import VelodyDomain
|
||||||
import VelodyNetworking
|
import VelodyNetworking
|
||||||
|
import VelodyPlayback
|
||||||
import VelodyPersistence
|
import VelodyPersistence
|
||||||
import VelodySync
|
import VelodySync
|
||||||
import VelodyUtilities
|
import VelodyUtilities
|
||||||
@ -15,57 +16,22 @@ final class TestPlayer: iPhoneLocalAudioPlaying {
|
|||||||
var missingTrackIDs = Set<String>()
|
var missingTrackIDs = Set<String>()
|
||||||
|
|
||||||
private var catalogTracksByID: [String: LibraryTrack] = [:]
|
private var catalogTracksByID: [String: LibraryTrack] = [:]
|
||||||
|
private var queue = PlaybackQueue()
|
||||||
|
|
||||||
func setCatalogTracks(_ tracks: [LibraryTrack]) {
|
func setCatalogTracks(_ tracks: [LibraryTrack]) {
|
||||||
catalogTracksByID = Dictionary(uniqueKeysWithValues: tracks.map { ($0.id, $0) })
|
catalogTracksByID = Dictionary(uniqueKeysWithValues: tracks.map { ($0.id, $0) })
|
||||||
|
queue.replaceTrackIDs(
|
||||||
guard let trackID = state.trackID,
|
tracks.map(\.id),
|
||||||
let currentTrack = catalogTracksByID[trackID]
|
currentTrackID: state.trackID,
|
||||||
else {
|
queuedTrackIDs: state.queueTrackIDs
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
state = Self.makeState(
|
|
||||||
for: currentTrack,
|
|
||||||
playbackState: state.playbackState,
|
|
||||||
currentTime: state.currentTime,
|
|
||||||
errorMessage: state.errorMessage
|
|
||||||
)
|
)
|
||||||
|
refreshState()
|
||||||
onStateChange?(state)
|
onStateChange?(state)
|
||||||
}
|
}
|
||||||
|
|
||||||
func play(trackID: String) {
|
func play(trackID: String) {
|
||||||
guard let track = catalogTracksByID[trackID] else {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
let currentTime = state.trackID == trackID ? state.currentTime : 0
|
let currentTime = state.trackID == trackID ? state.currentTime : 0
|
||||||
|
startPlayback(trackID: trackID, currentTime: currentTime)
|
||||||
if missingTrackIDs.contains(trackID) ||
|
|
||||||
track.localFilePath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
|
||||||
state = Self.makeState(
|
|
||||||
for: track,
|
|
||||||
playbackState: .missingFile,
|
|
||||||
currentTime: currentTime,
|
|
||||||
errorMessage: "The local file could not be found: \(track.localFilePath)"
|
|
||||||
)
|
|
||||||
} else if failingTrackIDs.contains(trackID) {
|
|
||||||
state = Self.makeState(
|
|
||||||
for: track,
|
|
||||||
playbackState: .failed,
|
|
||||||
currentTime: currentTime,
|
|
||||||
errorMessage: "Playback could not be started."
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
state = Self.makeState(
|
|
||||||
for: track,
|
|
||||||
playbackState: .playing,
|
|
||||||
currentTime: currentTime,
|
|
||||||
errorMessage: nil
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
onStateChange?(state)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func pause() {
|
func pause() {
|
||||||
@ -75,6 +41,7 @@ final class TestPlayer: iPhoneLocalAudioPlaying {
|
|||||||
|
|
||||||
state = Self.makeState(
|
state = Self.makeState(
|
||||||
for: currentTrack,
|
for: currentTrack,
|
||||||
|
queue: queue,
|
||||||
playbackState: .paused,
|
playbackState: .paused,
|
||||||
currentTime: state.currentTime,
|
currentTime: state.currentTime,
|
||||||
errorMessage: nil
|
errorMessage: nil
|
||||||
@ -84,13 +51,14 @@ final class TestPlayer: iPhoneLocalAudioPlaying {
|
|||||||
|
|
||||||
func stop() {
|
func stop() {
|
||||||
guard let currentTrack else {
|
guard let currentTrack else {
|
||||||
state = .empty
|
state = Self.makeEmptyState(queue: queue)
|
||||||
onStateChange?(state)
|
onStateChange?(state)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
state = Self.makeState(
|
state = Self.makeState(
|
||||||
for: currentTrack,
|
for: currentTrack,
|
||||||
|
queue: queue,
|
||||||
playbackState: .stopped,
|
playbackState: .stopped,
|
||||||
currentTime: 0,
|
currentTime: 0,
|
||||||
errorMessage: nil
|
errorMessage: nil
|
||||||
@ -105,6 +73,7 @@ final class TestPlayer: iPhoneLocalAudioPlaying {
|
|||||||
|
|
||||||
state = Self.makeState(
|
state = Self.makeState(
|
||||||
for: currentTrack,
|
for: currentTrack,
|
||||||
|
queue: queue,
|
||||||
playbackState: state.playbackState,
|
playbackState: state.playbackState,
|
||||||
currentTime: time,
|
currentTime: time,
|
||||||
errorMessage: state.errorMessage
|
errorMessage: state.errorMessage
|
||||||
@ -112,6 +81,41 @@ final class TestPlayer: iPhoneLocalAudioPlaying {
|
|||||||
onStateChange?(state)
|
onStateChange?(state)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func previousTrack() {
|
||||||
|
if state.currentTime > 5 {
|
||||||
|
seek(to: 0)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
guard let previousTrackID = queue.moveToPreviousTrack() else {
|
||||||
|
seek(to: 0)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
startPlayback(trackID: previousTrackID, currentTime: 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func nextTrack() {
|
||||||
|
guard let nextTrackID = queue.advanceToNextTrack() else {
|
||||||
|
stop()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
startPlayback(trackID: nextTrackID, currentTime: 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
func toggleShuffle() {
|
||||||
|
queue.toggleShuffle()
|
||||||
|
refreshState()
|
||||||
|
onStateChange?(state)
|
||||||
|
}
|
||||||
|
|
||||||
|
func cycleRepeatMode() {
|
||||||
|
queue.cycleRepeatMode()
|
||||||
|
refreshState()
|
||||||
|
onStateChange?(state)
|
||||||
|
}
|
||||||
|
|
||||||
func advanceProgress(by timeDelta: Double) {
|
func advanceProgress(by timeDelta: Double) {
|
||||||
guard state.isPlaying,
|
guard state.isPlaying,
|
||||||
let currentTrack
|
let currentTrack
|
||||||
@ -131,6 +135,7 @@ final class TestPlayer: iPhoneLocalAudioPlaying {
|
|||||||
|
|
||||||
state = Self.makeState(
|
state = Self.makeState(
|
||||||
for: currentTrack,
|
for: currentTrack,
|
||||||
|
queue: queue,
|
||||||
playbackState: playbackState,
|
playbackState: playbackState,
|
||||||
currentTime: updatedTime,
|
currentTime: updatedTime,
|
||||||
errorMessage: nil
|
errorMessage: nil
|
||||||
@ -138,6 +143,43 @@ final class TestPlayer: iPhoneLocalAudioPlaying {
|
|||||||
onStateChange?(state)
|
onStateChange?(state)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func startPlayback(trackID: String, currentTime: Double) {
|
||||||
|
guard let track = catalogTracksByID[trackID] else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
queue.selectTrack(trackID)
|
||||||
|
|
||||||
|
if missingTrackIDs.contains(trackID) ||
|
||||||
|
track.localFilePath.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
|
||||||
|
state = Self.makeState(
|
||||||
|
for: track,
|
||||||
|
queue: queue,
|
||||||
|
playbackState: .missingFile,
|
||||||
|
currentTime: currentTime,
|
||||||
|
errorMessage: "The local file could not be found: \(track.localFilePath)"
|
||||||
|
)
|
||||||
|
} else if failingTrackIDs.contains(trackID) {
|
||||||
|
state = Self.makeState(
|
||||||
|
for: track,
|
||||||
|
queue: queue,
|
||||||
|
playbackState: .failed,
|
||||||
|
currentTime: currentTime,
|
||||||
|
errorMessage: "Playback could not be started."
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
state = Self.makeState(
|
||||||
|
for: track,
|
||||||
|
queue: queue,
|
||||||
|
playbackState: .playing,
|
||||||
|
currentTime: currentTime,
|
||||||
|
errorMessage: nil
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
onStateChange?(state)
|
||||||
|
}
|
||||||
|
|
||||||
private var currentTrack: LibraryTrack? {
|
private var currentTrack: LibraryTrack? {
|
||||||
guard let trackID = state.trackID else {
|
guard let trackID = state.trackID else {
|
||||||
return nil
|
return nil
|
||||||
@ -146,8 +188,24 @@ final class TestPlayer: iPhoneLocalAudioPlaying {
|
|||||||
return catalogTracksByID[trackID]
|
return catalogTracksByID[trackID]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private func refreshState() {
|
||||||
|
guard let currentTrack else {
|
||||||
|
state = Self.makeEmptyState(queue: queue)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
state = Self.makeState(
|
||||||
|
for: currentTrack,
|
||||||
|
queue: queue,
|
||||||
|
playbackState: state.playbackState,
|
||||||
|
currentTime: state.currentTime,
|
||||||
|
errorMessage: state.errorMessage
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
private static func makeState(
|
private static func makeState(
|
||||||
for track: LibraryTrack,
|
for track: LibraryTrack,
|
||||||
|
queue: PlaybackQueue,
|
||||||
playbackState: iPhonePlaybackState,
|
playbackState: iPhonePlaybackState,
|
||||||
currentTime: Double,
|
currentTime: Double,
|
||||||
errorMessage: String?
|
errorMessage: String?
|
||||||
@ -165,12 +223,30 @@ final class TestPlayer: iPhoneLocalAudioPlaying {
|
|||||||
trackID: track.id,
|
trackID: track.id,
|
||||||
title: track.title,
|
title: track.title,
|
||||||
artist: track.artist,
|
artist: track.artist,
|
||||||
|
queueTrackIDs: queue.queuedTrackIDs,
|
||||||
|
isShuffleEnabled: queue.isShuffleEnabled,
|
||||||
|
repeatMode: queue.repeatMode,
|
||||||
playbackState: playbackState,
|
playbackState: playbackState,
|
||||||
currentTime: clampedTime,
|
currentTime: clampedTime,
|
||||||
duration: duration,
|
duration: duration,
|
||||||
errorMessage: errorMessage
|
errorMessage: errorMessage
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static func makeEmptyState(queue: PlaybackQueue) -> iPhoneNowPlayingState {
|
||||||
|
iPhoneNowPlayingState(
|
||||||
|
trackID: nil,
|
||||||
|
title: nil,
|
||||||
|
artist: nil,
|
||||||
|
queueTrackIDs: queue.queuedTrackIDs,
|
||||||
|
isShuffleEnabled: queue.isShuffleEnabled,
|
||||||
|
repeatMode: queue.repeatMode,
|
||||||
|
playbackState: .stopped,
|
||||||
|
currentTime: 0,
|
||||||
|
duration: 0,
|
||||||
|
errorMessage: nil
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private actor TestRemoteLibraryRepository: RemoteLibraryRepository {
|
private actor TestRemoteLibraryRepository: RemoteLibraryRepository {
|
||||||
|
|||||||
@ -105,10 +105,22 @@ public struct PlaybackQueue: Hashable, Sendable {
|
|||||||
queuedTrackIDs preferredQueuedTrackIDs: [String]? = nil
|
queuedTrackIDs preferredQueuedTrackIDs: [String]? = nil
|
||||||
) {
|
) {
|
||||||
isShuffleEnabled = isEnabled
|
isShuffleEnabled = isEnabled
|
||||||
|
let nextQueuedTrackIDs: [String]?
|
||||||
|
|
||||||
|
if isEnabled {
|
||||||
|
nextQueuedTrackIDs = preferredQueuedTrackIDs
|
||||||
|
?? Self.makeShuffledTrackIDs(
|
||||||
|
from: catalogTrackIDs,
|
||||||
|
currentTrackID: currentTrackID
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
nextQueuedTrackIDs = preferredQueuedTrackIDs
|
||||||
|
}
|
||||||
|
|
||||||
replaceTrackIDs(
|
replaceTrackIDs(
|
||||||
catalogTrackIDs,
|
catalogTrackIDs,
|
||||||
currentTrackID: currentTrackID,
|
currentTrackID: currentTrackID,
|
||||||
queuedTrackIDs: preferredQueuedTrackIDs
|
queuedTrackIDs: nextQueuedTrackIDs
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -8,10 +8,10 @@ public enum PlaybackRepeatMode: String, Codable, CaseIterable, Hashable, Sendabl
|
|||||||
public var nextMode: PlaybackRepeatMode {
|
public var nextMode: PlaybackRepeatMode {
|
||||||
switch self {
|
switch self {
|
||||||
case .off:
|
case .off:
|
||||||
.all
|
|
||||||
case .all:
|
|
||||||
.one
|
.one
|
||||||
case .one:
|
case .one:
|
||||||
|
.all
|
||||||
|
case .all:
|
||||||
.off
|
.off
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -41,6 +41,19 @@ final class PlaybackQueueTests: XCTestCase {
|
|||||||
XCTAssertEqual(queue.currentTrackID, "c")
|
XCTAssertEqual(queue.currentTrackID, "c")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testEnablingShuffleKeepsCurrentTrackSelectedAndAtQueueFront() {
|
||||||
|
var queue = PlaybackQueue(trackIDs: ["a", "b", "c", "d"])
|
||||||
|
queue.selectTrack("c")
|
||||||
|
|
||||||
|
queue.setShuffleEnabled(true)
|
||||||
|
|
||||||
|
XCTAssertTrue(queue.isShuffleEnabled)
|
||||||
|
XCTAssertEqual(queue.currentTrackID, "c")
|
||||||
|
XCTAssertEqual(queue.queuedTrackIDs.first, "c")
|
||||||
|
XCTAssertEqual(Set(queue.queuedTrackIDs), Set(["a", "b", "c", "d"]))
|
||||||
|
XCTAssertEqual(Set(queue.queuedTrackIDs).count, 4)
|
||||||
|
}
|
||||||
|
|
||||||
func testRepeatAllWrapsAroundQueueBoundaries() {
|
func testRepeatAllWrapsAroundQueueBoundaries() {
|
||||||
var queue = PlaybackQueue(trackIDs: ["a", "b", "c"])
|
var queue = PlaybackQueue(trackIDs: ["a", "b", "c"])
|
||||||
queue.selectTrack("c")
|
queue.selectTrack("c")
|
||||||
@ -63,6 +76,21 @@ final class PlaybackQueueTests: XCTestCase {
|
|||||||
XCTAssertEqual(queue.moveToPreviousTrack(), "b")
|
XCTAssertEqual(queue.moveToPreviousTrack(), "b")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func testCycleRepeatModeUsesOffOneAllOrder() {
|
||||||
|
var queue = PlaybackQueue(trackIDs: ["a", "b"])
|
||||||
|
|
||||||
|
XCTAssertEqual(queue.repeatMode, .off)
|
||||||
|
|
||||||
|
queue.cycleRepeatMode()
|
||||||
|
XCTAssertEqual(queue.repeatMode, .one)
|
||||||
|
|
||||||
|
queue.cycleRepeatMode()
|
||||||
|
XCTAssertEqual(queue.repeatMode, .all)
|
||||||
|
|
||||||
|
queue.cycleRepeatMode()
|
||||||
|
XCTAssertEqual(queue.repeatMode, .off)
|
||||||
|
}
|
||||||
|
|
||||||
func testReplacingTrackIDsDropsRemovedTracksFromQueue() {
|
func testReplacingTrackIDsDropsRemovedTracksFromQueue() {
|
||||||
var queue = PlaybackQueue(
|
var queue = PlaybackQueue(
|
||||||
trackIDs: ["a", "b", "c"],
|
trackIDs: ["a", "b", "c"],
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user