Expose iPhone playback controls

This commit is contained in:
diyaa
2026-06-16 02:11:24 +02:00
parent 295c6c1d9b
commit e147ce38be
7 changed files with 993 additions and 131 deletions
@@ -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: