From e147ce38be697eab4a1a8cbf7c4db7dd2fe5719d Mon Sep 17 00:00:00 2001 From: diyaa Date: Tue, 16 Jun 2026 02:11:24 +0200 Subject: [PATCH] Expose iPhone playback controls --- .../Sources/iPhoneLibraryView.swift | 311 +++++++++--- .../Sources/iPhoneLibraryViewModel.swift | 147 +++++- .../iPhoneLibraryViewModelPlaybackTests.swift | 458 +++++++++++++++++- .../iPhoneLibraryViewModelTestSupport.swift | 162 +++++-- .../VelodyPlayback/PlaybackQueue.swift | 14 +- .../VelodyPlayback/PlaybackRepeatMode.swift | 4 +- .../PlaybackQueueTests.swift | 28 ++ 7 files changed, 993 insertions(+), 131 deletions(-) diff --git a/apps/apple/VelodyiPhone/Sources/iPhoneLibraryView.swift b/apps/apple/VelodyiPhone/Sources/iPhoneLibraryView.swift index a7f6fbb..b290b88 100644 --- a/apps/apple/VelodyiPhone/Sources/iPhoneLibraryView.swift +++ b/apps/apple/VelodyiPhone/Sources/iPhoneLibraryView.swift @@ -5,9 +5,56 @@ import UIKit #endif 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 { 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 { @@ -23,12 +70,9 @@ struct iPhoneLibraryView: View { static let controlButtonHorizontalPadding: CGFloat = 14 static let controlButtonSymbolSize: CGFloat = 13 static let controlButtonSpacing: CGFloat = 6 - static let favoriteButtonSize: CGFloat = 36 - static let favoriteButtonSymbolSize: CGFloat = 14 static let badgeMinHeight: CGFloat = 28 static let badgeHorizontalPadding: CGFloat = 12 static let badgeVerticalPadding: CGFloat = 6 - static let controlGroupSpacing: CGFloat = 8 } private enum ActionButtonRole { @@ -77,11 +121,29 @@ struct iPhoneLibraryView: View { NowPlayingCardLayoutMetrics.artworkHeight(for: contentWidth) } + var nowPlayingCardContentWidth: CGFloat { + max(contentWidth - (cardPadding * 2), 0) + } + + var nowPlayingControlsLayout: NowPlayingCardLayoutMetrics.ControlsLayout { + NowPlayingCardLayoutMetrics.controlsLayout(for: nowPlayingCardContentWidth) + } + } - @State private var viewModel = iPhoneLibraryViewModel() + @State private var viewModel: iPhoneLibraryViewModel @State private var scrubbedPlaybackTime: Double? + @MainActor + init() { + _viewModel = State(initialValue: iPhoneLibraryViewModel()) + } + + @MainActor + init(viewModel: iPhoneLibraryViewModel) { + _viewModel = State(initialValue: viewModel) + } + var body: some View { GeometryReader { proxy in let layout = RootLayout(rootWidth: proxy.size.width) @@ -193,7 +255,7 @@ struct iPhoneLibraryView: View { @ViewBuilder private func nowPlayingCardView(_ card: NowPlayingCardViewData, layout: RootLayout) -> some View { - VStack(alignment: .leading, spacing: 16) { + VStack(alignment: .leading, spacing: 14) { ViewThatFits(in: .vertical) { HStack(alignment: .top, spacing: 12) { nowPlayingHeaderText @@ -209,24 +271,7 @@ struct iPhoneLibraryView: View { } } - VStack(alignment: .leading, spacing: 8) { - 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) + nowPlayingMetadataText(card) nowPlayingArtworkView(localFilePath: card.artworkLocalFilePath, layout: layout) @@ -267,7 +312,7 @@ struct iPhoneLibraryView: View { .foregroundStyle(.red) } - nowPlayingControls(card) + nowPlayingControls(card, layout: layout) } .frame(maxWidth: .infinity, alignment: .leading) .padding(layout.cardPadding) @@ -645,58 +690,122 @@ struct iPhoneLibraryView: View { .font(.headline) } - private func nowPlayingControls(_ card: NowPlayingCardViewData) -> some View { - ViewThatFits(in: .vertical) { - HStack(spacing: LayoutMetrics.controlGroupSpacing) { - if viewModel.nowPlayingFavoriteTrackID == card.trackID { - nowPlayingFavoriteButton(trackID: card.trackID) - } + private func nowPlayingMetadataText(_ card: NowPlayingCardViewData) -> some View { + VStack(alignment: .leading, spacing: 8) { + Text(card.title) + .font(.title2.weight(.bold)) + .foregroundStyle(.primary) + .fixedSize(horizontal: false, vertical: true) - nowPlayingToggleButton(card) - nowPlayingStopButton - } + Text(card.artist) + .font(.subheadline) + .foregroundStyle(.secondary) + .lineLimit(2) + .fixedSize(horizontal: false, vertical: true) - VStack(alignment: .leading, spacing: 12) { - if viewModel.nowPlayingFavoriteTrackID == card.trackID { - nowPlayingFavoriteButton(trackID: card.trackID) - } - - HStack(spacing: LayoutMetrics.controlGroupSpacing) { - nowPlayingToggleButton(card) - nowPlayingStopButton - } - } + Text(card.playbackStateText) + .font(.footnote.weight(.medium)) + .foregroundStyle(playbackStateColor(for: viewModel.nowPlaying.playbackState)) + .lineLimit(1) } .frame(maxWidth: .infinity, alignment: .leading) + .layoutPriority(1) } - private func nowPlayingFavoriteButton(trackID: String) -> some View { - favoriteControlButton(isFavorite: viewModel.isNowPlayingTrackFavorite) { - Task { - await viewModel.toggleFavorite(trackID: trackID) - } + private func nowPlayingControls( + _ card: NowPlayingCardViewData, + layout: RootLayout + ) -> 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 { - rowActionButton( - title: card.isPlaying ? "Pause" : "Play", + private func nowPlayingToggleButton( + _ card: NowPlayingCardViewData, + controlsLayout: NowPlayingCardLayoutMetrics.ControlsLayout + ) -> some View { + compactNowPlayingButton( systemImage: playbackActionSystemImage(isPlaying: card.isPlaying), + accessibilityLabel: card.isPlaying ? "Pause" : "Play", role: .primary, - isEnabled: true + isEnabled: true, + isActive: true, + controlSize: controlsLayout.primaryButtonSize, + symbolSize: controlsLayout.primarySymbolSize ) { viewModel.togglePlayback(trackID: card.trackID) } } - private var nowPlayingStopButton: some View { - rowActionButton( - title: "Stop", - systemImage: "stop.fill", + private func nowPlayingShuffleButton( + controlsLayout: NowPlayingCardLayoutMetrics.ControlsLayout + ) -> some View { + compactNowPlayingButton( + systemImage: "shuffle", + accessibilityLabel: viewModel.isShuffleEnabled ? "Disable Shuffle" : "Enable Shuffle", 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 { Button(action: action) { Image(systemName: isFavorite ? "heart.fill" : "heart") - .font(.system(size: LayoutMetrics.favoriteButtonSymbolSize, weight: .semibold)) + .font(.system(size: NowPlayingCardLayoutMetrics.favoriteButtonSymbolSize, weight: .semibold)) .foregroundStyle(isFavorite ? .red : .gray) .frame( - width: LayoutMetrics.favoriteButtonSize, - height: LayoutMetrics.favoriteButtonSize + width: NowPlayingCardLayoutMetrics.favoriteButtonSize, + height: NowPlayingCardLayoutMetrics.favoriteButtonSize ) .background( Capsule() @@ -769,6 +878,41 @@ struct iPhoneLibraryView: View { .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 { switch role { 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 { isPlaying ? "pause.fill" : "play.fill" } diff --git a/apps/apple/VelodyiPhone/Sources/iPhoneLibraryViewModel.swift b/apps/apple/VelodyiPhone/Sources/iPhoneLibraryViewModel.swift index 4b3aa9b..a24458d 100644 --- a/apps/apple/VelodyiPhone/Sources/iPhoneLibraryViewModel.swift +++ b/apps/apple/VelodyiPhone/Sources/iPhoneLibraryViewModel.swift @@ -20,6 +20,10 @@ protocol iPhoneLocalAudioPlaying: AnyObject { func pause() func stop() func seek(to time: Double) + func previousTrack() + func nextTrack() + func toggleShuffle() + func cycleRepeatMode() } enum iPhonePlaybackState: Equatable { @@ -49,6 +53,9 @@ struct iPhoneNowPlayingState: Equatable { var trackID: String? var title: String? var artist: String? + var queueTrackIDs: [String] + var isShuffleEnabled: Bool + var repeatMode: PlaybackRepeatMode var playbackState: iPhonePlaybackState var currentTime: Double var duration: Double @@ -58,6 +65,9 @@ struct iPhoneNowPlayingState: Equatable { trackID: nil, title: nil, artist: nil, + queueTrackIDs: [], + isShuffleEnabled: false, + repeatMode: .off, playbackState: .stopped, currentTime: 0, duration: 0, @@ -77,7 +87,7 @@ struct iPhoneNowPlayingState: Equatable { } } -private struct StorediPhonePlaybackSession: Codable { +private struct LegacyiPhonePlaybackSession: Codable { var currentTrackID: String? var currentTime: Double } @@ -98,17 +108,22 @@ struct iPhonePlaybackSessionStore: PlaybackSessionStore, @unchecked Sendable { func loadSession() -> PlaybackSessionSnapshot? { guard let data = userDefaults.data(forKey: storageKey), - let storedSession = try? decoder.decode(StorediPhonePlaybackSession.self, from: data) + !data.isEmpty else { 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( - queueTrackIDs: [], - currentTrackID: storedSession.currentTrackID, - currentTime: storedSession.currentTime, - isShuffleEnabled: false, - repeatMode: .off + currentTrackID: legacySession.currentTrackID, + currentTime: legacySession.currentTime ) } @@ -120,9 +135,12 @@ struct iPhonePlaybackSessionStore: PlaybackSessionStore, @unchecked Sendable { return } - let storedSession = StorediPhonePlaybackSession( + let storedSession = PlaybackSessionSnapshot( + queueTrackIDs: session.queueTrackIDs, currentTrackID: currentTrackID, - currentTime: session.currentTime + currentTime: session.currentTime, + isShuffleEnabled: session.isShuffleEnabled, + repeatMode: session.repeatMode ) guard let data = try? encoder.encode(storedSession) else { @@ -193,6 +211,28 @@ final class iPhonePlaybackControllerPlayer: iPhoneLocalAudioPlaying { 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) { let effectiveDuration = max( nowPlayingState.duration, @@ -249,6 +289,9 @@ final class iPhonePlaybackControllerPlayer: iPhoneLocalAudioPlaying { trackID: nowPlayingState.currentTrackID, title: nowPlayingState.currentTrack?.title, artist: nowPlayingState.currentTrack?.artist, + queueTrackIDs: nowPlayingState.queueTrackIDs, + isShuffleEnabled: nowPlayingState.isShuffleEnabled, + repeatMode: nowPlayingState.repeatMode, playbackState: playbackState, currentTime: nowPlayingState.currentTime, duration: effectiveDuration, @@ -325,6 +368,34 @@ final class iPhoneLibraryViewModel { var nowPlayingFavoriteTrackID: String? 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 apiClient: any VelodyAPIClient private let syncService: RemoteLibrarySyncService @@ -634,6 +705,40 @@ final class iPhoneLibraryViewModel { 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 { guard hasTrackInLibrarySnapshot(trackID) else { return @@ -820,6 +925,30 @@ final class iPhoneLibraryViewModel { 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) { switch nowPlaying.playbackState { case .missingFile: diff --git a/apps/apple/VelodyiPhone/Tests/iPhoneLibraryViewModelPlaybackTests.swift b/apps/apple/VelodyiPhone/Tests/iPhoneLibraryViewModelPlaybackTests.swift index e1e29c5..7046b9d 100644 --- a/apps/apple/VelodyiPhone/Tests/iPhoneLibraryViewModelPlaybackTests.swift +++ b/apps/apple/VelodyiPhone/Tests/iPhoneLibraryViewModelPlaybackTests.swift @@ -1,4 +1,10 @@ import Foundation +#if canImport(SwiftUI) +import SwiftUI +#endif +#if canImport(UIKit) +import UIKit +#endif import XCTest import VelodyDomain import VelodyNetworking @@ -22,6 +28,128 @@ final class iPhoneLibraryViewModelPlaybackTests: XCTestCase { 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", @@ -137,6 +265,85 @@ final class iPhoneLibraryViewModelPlaybackTests: XCTestCase { 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", @@ -217,7 +424,210 @@ final class iPhoneLibraryViewModelPlaybackTests: XCTestCase { 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 defaults = UserDefaults(suiteName: suiteName)! defer { @@ -228,46 +638,64 @@ final class iPhoneLibraryViewModelPlaybackTests: XCTestCase { userDefaults: defaults, storageKey: "playback" ) - let track = LibraryTrack( - id: "remote-restore", + let firstTrack = LibraryTrack( + id: "remote-restore-first", title: "Restore Point", artist: "Velody Artist", durationSeconds: 245, - localFilePath: "/in-memory/asset-restore.mp3", - remoteTrackId: "remote-restore" + 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[track.localFilePath] = 245 + firstEngine.durationByPath[firstTrack.localFilePath] = 245 + firstEngine.durationByPath[secondTrack.localFilePath] = 188 let firstPlayer = iPhonePlaybackControllerPlayer( controller: PlaybackController( engine: firstEngine, sessionStore: sessionStore ) ) - firstPlayer.setCatalogTracks([track]) - firstPlayer.play(trackID: track.id) + 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, track.id) + XCTAssertEqual(storedSession?.currentTrackID, secondTrack.id) 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() - secondEngine.durationByPath[track.localFilePath] = 245 + secondEngine.durationByPath[firstTrack.localFilePath] = 245 + secondEngine.durationByPath[secondTrack.localFilePath] = 188 let relaunchedPlayer = iPhonePlaybackControllerPlayer( controller: PlaybackController( engine: secondEngine, sessionStore: sessionStore ) ) - relaunchedPlayer.setCatalogTracks([track]) + relaunchedPlayer.setCatalogTracks([firstTrack, secondTrack]) - XCTAssertEqual(relaunchedPlayer.state.trackID, track.id) - XCTAssertEqual(relaunchedPlayer.state.title, track.title) - XCTAssertEqual(relaunchedPlayer.state.artist, track.artist) + 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) diff --git a/apps/apple/VelodyiPhone/Tests/iPhoneLibraryViewModelTestSupport.swift b/apps/apple/VelodyiPhone/Tests/iPhoneLibraryViewModelTestSupport.swift index d9682b3..e487887 100644 --- a/apps/apple/VelodyiPhone/Tests/iPhoneLibraryViewModelTestSupport.swift +++ b/apps/apple/VelodyiPhone/Tests/iPhoneLibraryViewModelTestSupport.swift @@ -1,6 +1,7 @@ import Foundation import VelodyDomain import VelodyNetworking +import VelodyPlayback import VelodyPersistence import VelodySync import VelodyUtilities @@ -15,57 +16,22 @@ final class TestPlayer: iPhoneLocalAudioPlaying { var missingTrackIDs = Set() private var catalogTracksByID: [String: LibraryTrack] = [:] + private var queue = PlaybackQueue() func setCatalogTracks(_ tracks: [LibraryTrack]) { catalogTracksByID = Dictionary(uniqueKeysWithValues: tracks.map { ($0.id, $0) }) - - guard let trackID = state.trackID, - let currentTrack = catalogTracksByID[trackID] - else { - return - } - - state = Self.makeState( - for: currentTrack, - playbackState: state.playbackState, - currentTime: state.currentTime, - errorMessage: state.errorMessage + queue.replaceTrackIDs( + tracks.map(\.id), + currentTrackID: state.trackID, + queuedTrackIDs: state.queueTrackIDs ) + refreshState() onStateChange?(state) } func play(trackID: String) { - guard let track = catalogTracksByID[trackID] else { - return - } - let currentTime = state.trackID == trackID ? state.currentTime : 0 - - 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) + startPlayback(trackID: trackID, currentTime: currentTime) } func pause() { @@ -75,6 +41,7 @@ final class TestPlayer: iPhoneLocalAudioPlaying { state = Self.makeState( for: currentTrack, + queue: queue, playbackState: .paused, currentTime: state.currentTime, errorMessage: nil @@ -84,13 +51,14 @@ final class TestPlayer: iPhoneLocalAudioPlaying { func stop() { guard let currentTrack else { - state = .empty + state = Self.makeEmptyState(queue: queue) onStateChange?(state) return } state = Self.makeState( for: currentTrack, + queue: queue, playbackState: .stopped, currentTime: 0, errorMessage: nil @@ -105,6 +73,7 @@ final class TestPlayer: iPhoneLocalAudioPlaying { state = Self.makeState( for: currentTrack, + queue: queue, playbackState: state.playbackState, currentTime: time, errorMessage: state.errorMessage @@ -112,6 +81,41 @@ final class TestPlayer: iPhoneLocalAudioPlaying { 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) { guard state.isPlaying, let currentTrack @@ -131,6 +135,7 @@ final class TestPlayer: iPhoneLocalAudioPlaying { state = Self.makeState( for: currentTrack, + queue: queue, playbackState: playbackState, currentTime: updatedTime, errorMessage: nil @@ -138,6 +143,43 @@ final class TestPlayer: iPhoneLocalAudioPlaying { 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? { guard let trackID = state.trackID else { return nil @@ -146,8 +188,24 @@ final class TestPlayer: iPhoneLocalAudioPlaying { 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( for track: LibraryTrack, + queue: PlaybackQueue, playbackState: iPhonePlaybackState, currentTime: Double, errorMessage: String? @@ -165,12 +223,30 @@ final class TestPlayer: iPhoneLocalAudioPlaying { trackID: track.id, title: track.title, artist: track.artist, + queueTrackIDs: queue.queuedTrackIDs, + isShuffleEnabled: queue.isShuffleEnabled, + repeatMode: queue.repeatMode, playbackState: playbackState, currentTime: clampedTime, duration: duration, 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 { diff --git a/packages/apple/VelodyPlayback/Sources/VelodyPlayback/PlaybackQueue.swift b/packages/apple/VelodyPlayback/Sources/VelodyPlayback/PlaybackQueue.swift index ef603c6..de9c8dc 100644 --- a/packages/apple/VelodyPlayback/Sources/VelodyPlayback/PlaybackQueue.swift +++ b/packages/apple/VelodyPlayback/Sources/VelodyPlayback/PlaybackQueue.swift @@ -105,10 +105,22 @@ public struct PlaybackQueue: Hashable, Sendable { queuedTrackIDs preferredQueuedTrackIDs: [String]? = nil ) { isShuffleEnabled = isEnabled + let nextQueuedTrackIDs: [String]? + + if isEnabled { + nextQueuedTrackIDs = preferredQueuedTrackIDs + ?? Self.makeShuffledTrackIDs( + from: catalogTrackIDs, + currentTrackID: currentTrackID + ) + } else { + nextQueuedTrackIDs = preferredQueuedTrackIDs + } + replaceTrackIDs( catalogTrackIDs, currentTrackID: currentTrackID, - queuedTrackIDs: preferredQueuedTrackIDs + queuedTrackIDs: nextQueuedTrackIDs ) } diff --git a/packages/apple/VelodyPlayback/Sources/VelodyPlayback/PlaybackRepeatMode.swift b/packages/apple/VelodyPlayback/Sources/VelodyPlayback/PlaybackRepeatMode.swift index c737dc0..be5a9f9 100644 --- a/packages/apple/VelodyPlayback/Sources/VelodyPlayback/PlaybackRepeatMode.swift +++ b/packages/apple/VelodyPlayback/Sources/VelodyPlayback/PlaybackRepeatMode.swift @@ -8,10 +8,10 @@ public enum PlaybackRepeatMode: String, Codable, CaseIterable, Hashable, Sendabl public var nextMode: PlaybackRepeatMode { switch self { case .off: - .all - case .all: .one case .one: + .all + case .all: .off } } diff --git a/packages/apple/VelodyPlayback/Tests/VelodyPlaybackTests/PlaybackQueueTests.swift b/packages/apple/VelodyPlayback/Tests/VelodyPlaybackTests/PlaybackQueueTests.swift index a026079..ea8a9ad 100644 --- a/packages/apple/VelodyPlayback/Tests/VelodyPlaybackTests/PlaybackQueueTests.swift +++ b/packages/apple/VelodyPlayback/Tests/VelodyPlaybackTests/PlaybackQueueTests.swift @@ -41,6 +41,19 @@ final class PlaybackQueueTests: XCTestCase { 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() { var queue = PlaybackQueue(trackIDs: ["a", "b", "c"]) queue.selectTrack("c") @@ -63,6 +76,21 @@ final class PlaybackQueueTests: XCTestCase { 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() { var queue = PlaybackQueue( trackIDs: ["a", "b", "c"],