Expose iPhone playback controls
This commit is contained in:
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
Reference in New Issue
Block a user