velody/apps/apple/VelodyiPhone/Sources/iPhoneLibraryView.swift
2026-06-16 02:11:24 +02:00

1089 lines
36 KiB
Swift

import SwiftUI
import VelodyDomain
#if canImport(UIKit)
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 {
private enum LayoutMetrics {
static let horizontalPadding: CGFloat = 16
static let contentSpacing: CGFloat = 20
static let topPadding: CGFloat = 16
static let bottomPadding: CGFloat = 24
static let compactContentThreshold: CGFloat = 350
static let rowArtworkSize: CGFloat = 60
static let controlButtonHeight: CGFloat = 36
static let controlButtonMinWidth: CGFloat = 72
static let controlButtonHorizontalPadding: CGFloat = 14
static let controlButtonSymbolSize: CGFloat = 13
static let controlButtonSpacing: CGFloat = 6
static let badgeMinHeight: CGFloat = 28
static let badgeHorizontalPadding: CGFloat = 12
static let badgeVerticalPadding: CGFloat = 6
}
private enum ActionButtonRole {
case primary
case secondary
}
private struct ActionButtonColors {
let foreground: Color
let background: Color
let border: Color
}
private struct RootLayout {
let rootWidth: CGFloat
var horizontalPadding: CGFloat {
LayoutMetrics.horizontalPadding
}
var contentWidth: CGFloat {
max(rootWidth - (horizontalPadding * 2), 0)
}
var isCompact: Bool {
contentWidth < LayoutMetrics.compactContentThreshold
}
var sectionPadding: CGFloat {
isCompact ? 16 : 18
}
var cardPadding: CGFloat {
isCompact ? 16 : 20
}
var rowSpacing: CGFloat {
isCompact ? 12 : 14
}
var trackRowPadding: CGFloat {
isCompact ? 12 : 14
}
var nowPlayingArtworkHeight: CGFloat {
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 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)
ZStack {
Color(uiColor: .systemGroupedBackground)
.ignoresSafeArea()
ScrollView {
VStack(alignment: .leading, spacing: LayoutMetrics.contentSpacing) {
titleSection
syncSection
searchSection
if let nowPlayingCard = viewModel.nowPlayingCard {
nowPlayingCardView(nowPlayingCard, layout: layout)
}
remoteLibrarySection(layout: layout)
availableOfflineSection(layout: layout)
}
.frame(width: layout.contentWidth, alignment: .leading)
.padding(.horizontal, layout.horizontalPadding)
.padding(.top, LayoutMetrics.topPadding)
.padding(.bottom, LayoutMetrics.bottomPadding)
}
.frame(width: proxy.size.width, height: proxy.size.height, alignment: .top)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
}
.task {
await viewModel.loadIfNeeded()
}
}
private var titleSection: some View {
Text("Velody")
.font(.largeTitle.weight(.bold))
.frame(maxWidth: .infinity, alignment: .leading)
}
private var syncSection: some View {
VStack(alignment: .leading, spacing: 10) {
Button(viewModel.syncButtonTitle) {
Task {
await viewModel.refreshSync()
}
}
.buttonStyle(.borderedProminent)
.disabled(viewModel.isSyncing)
if viewModel.isSyncing {
HStack(spacing: 10) {
ProgressView()
Text(viewModel.syncStatus)
.font(.footnote)
.foregroundStyle(.secondary)
}
} else if let errorMessage = viewModel.inlineSyncErrorMessage {
messageCard(errorMessage)
} else {
Text(viewModel.syncStatus)
.font(.footnote)
.foregroundStyle(.secondary)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
}
private var searchSection: some View {
VStack(alignment: .leading, spacing: 10) {
Text("Search Library")
.font(.headline)
HStack(spacing: 10) {
Image(systemName: "magnifyingglass")
.foregroundStyle(.secondary)
TextField(
"Search Library",
text: Binding(
get: { viewModel.searchText },
set: { viewModel.searchText = $0 }
)
)
.textInputAutocapitalization(.never)
.autocorrectionDisabled()
.submitLabel(.search)
if !viewModel.searchText.isEmpty {
Button {
viewModel.searchText = ""
} label: {
Image(systemName: "xmark.circle.fill")
.foregroundStyle(.tertiary)
}
.buttonStyle(.plain)
.accessibilityLabel("Clear Search")
}
}
.padding(.horizontal, 14)
.padding(.vertical, 12)
.background(
RoundedRectangle(cornerRadius: 16, style: .continuous)
.fill(Color(uiColor: .secondarySystemGroupedBackground))
)
}
}
@ViewBuilder
private func nowPlayingCardView(_ card: NowPlayingCardViewData, layout: RootLayout) -> some View {
VStack(alignment: .leading, spacing: 14) {
ViewThatFits(in: .vertical) {
HStack(alignment: .top, spacing: 12) {
nowPlayingHeaderText
Spacer(minLength: 12)
statusBadge(title: card.downloadBadge.title, color: badgeColor(for: card.downloadBadge))
}
VStack(alignment: .leading, spacing: 10) {
nowPlayingHeaderText
statusBadge(title: card.downloadBadge.title, color: badgeColor(for: card.downloadBadge))
}
}
nowPlayingMetadataText(card)
nowPlayingArtworkView(localFilePath: card.artworkLocalFilePath, layout: layout)
VStack(spacing: 8) {
Slider(
value: Binding(
get: {
scrubbedPlaybackTime ?? card.currentTime
},
set: { newValue in
scrubbedPlaybackTime = newValue
}
),
in: 0...max(card.duration, 1),
onEditingChanged: { isEditing in
if !isEditing {
let targetTime = scrubbedPlaybackTime ?? card.currentTime
scrubbedPlaybackTime = nil
viewModel.seekPlayback(to: targetTime)
}
}
)
.disabled(!card.canSeek)
HStack {
Text(card.currentTimeText)
Spacer()
Text(card.durationText)
}
.font(.caption)
.monospacedDigit()
.foregroundStyle(.secondary)
}
if let errorMessage = card.errorMessage {
Text(errorMessage)
.font(.footnote)
.foregroundStyle(.red)
}
nowPlayingControls(card, layout: layout)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(layout.cardPadding)
.background(
RoundedRectangle(cornerRadius: 24, style: .continuous)
.fill(Color(uiColor: .secondarySystemGroupedBackground))
)
.onChange(of: card.trackID) { _, _ in
scrubbedPlaybackTime = nil
}
}
@ViewBuilder
private func remoteLibrarySection(layout: RootLayout) -> some View {
sectionCard(title: viewModel.remoteSectionTitle, layout: layout) {
if viewModel.isSyncing && viewModel.remoteTracks.isEmpty && !viewModel.hasCachedRemoteTracks && !viewModel.hasActiveSearch {
loadingStateView("Syncing your remote library...")
} else if let message = viewModel.remoteEmptyStateMessage {
messageCard(message)
} else {
VStack(alignment: .leading, spacing: 12) {
ForEach(Array(viewModel.remoteTracks.enumerated()), id: \.element.id) { index, track in
if index > 0 {
Divider()
.padding(.vertical, 2)
}
remoteTrackRow(track, layout: layout)
}
}
}
}
}
@ViewBuilder
private func availableOfflineSection(layout: RootLayout) -> some View {
sectionCard(title: viewModel.availableOfflineSectionTitle, layout: layout) {
if let message = viewModel.availableOfflineEmptyStateMessage {
messageCard(message)
} else {
VStack(alignment: .leading, spacing: 12) {
ForEach(Array(viewModel.availableOfflineTracks.enumerated()), id: \.element.id) { index, track in
if index > 0 {
Divider()
.padding(.vertical, 2)
}
availableOfflineTrackRow(track, layout: layout)
}
}
}
}
}
private func sectionCard<Content: View>(
title: String,
layout: RootLayout,
@ViewBuilder content: () -> Content
) -> some View {
VStack(alignment: .leading, spacing: 14) {
Text(title)
.font(.title3.weight(.semibold))
.fixedSize(horizontal: false, vertical: true)
content()
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(layout.sectionPadding)
.background(
RoundedRectangle(cornerRadius: 20, style: .continuous)
.fill(Color(uiColor: .secondarySystemGroupedBackground))
)
}
private func remoteTrackRow(_ track: RemoteTrackRowViewData, layout: RootLayout) -> some View {
libraryTrackRow(
artworkLocalFilePath: track.artworkLocalFilePath,
title: track.title,
artist: track.artist,
durationText: track.durationText,
isFavorite: track.isFavorite,
badgeTitle: track.statusBadgeTitle,
badgeColor: statusColor(for: track.status),
detailText: track.statusDetailText,
actionTitle: track.canPlay ? track.playButtonTitle : track.downloadButtonTitle,
actionSystemImage: remoteTrackActionSystemImage(for: track),
actionRole: remoteTrackActionRole(for: track),
actionEnabled: track.canPlay || track.canDownload,
favoriteAction: {
Task {
await viewModel.toggleFavorite(trackID: track.id)
}
},
action: {
if track.canPlay {
viewModel.togglePlayback(trackID: track.id)
} else {
Task {
await viewModel.downloadTrack(trackID: track.id)
}
}
},
layout: layout
)
}
private func availableOfflineTrackRow(_ track: AvailableOfflineTrackRowViewData, layout: RootLayout) -> some View {
libraryTrackRow(
artworkLocalFilePath: track.artworkLocalFilePath,
title: track.title,
artist: track.artist,
durationText: track.durationText,
isFavorite: track.isFavorite,
badgeTitle: track.statusBadgeTitle,
badgeColor: .green,
detailText: nil,
actionTitle: track.playButtonTitle,
actionSystemImage: playbackActionSystemImage(isPlaying: track.playButtonTitle == "Pause"),
actionRole: .primary,
actionEnabled: true,
favoriteAction: {
Task {
await viewModel.toggleFavorite(trackID: track.id)
}
},
action: {
viewModel.togglePlayback(trackID: track.id)
},
layout: layout
)
}
private func libraryTrackRow(
artworkLocalFilePath: String?,
title: String,
artist: String,
durationText: String,
isFavorite: Bool,
badgeTitle: String,
badgeColor: Color,
detailText: String?,
actionTitle: String,
actionSystemImage: String,
actionRole: ActionButtonRole,
actionEnabled: Bool,
favoriteAction: @escaping () -> Void,
action: @escaping () -> Void,
layout: RootLayout
) -> some View {
VStack(alignment: .leading, spacing: 12) {
if layout.isCompact {
VStack(alignment: .leading, spacing: 12) {
HStack(alignment: .top, spacing: layout.rowSpacing) {
ArtworkThumbnailView(localFilePath: artworkLocalFilePath)
libraryTrackText(
title: title,
artist: artist,
durationText: durationText
)
trackFavoriteButton(action: favoriteAction, isFavorite: isFavorite)
}
HStack(alignment: .center, spacing: 12) {
statusBadge(title: badgeTitle, color: badgeColor)
Spacer(minLength: 12)
rowActionButton(
title: actionTitle,
systemImage: actionSystemImage,
role: actionRole,
isEnabled: actionEnabled,
action: action
)
}
}
} else {
HStack(alignment: .top, spacing: layout.rowSpacing) {
ArtworkThumbnailView(localFilePath: artworkLocalFilePath)
libraryTrackText(
title: title,
artist: artist,
durationText: durationText
)
VStack(alignment: .trailing, spacing: 10) {
trackFavoriteButton(action: favoriteAction, isFavorite: isFavorite)
statusBadge(title: badgeTitle, color: badgeColor)
rowActionButton(
title: actionTitle,
systemImage: actionSystemImage,
role: actionRole,
isEnabled: actionEnabled,
action: action
)
}
.fixedSize(horizontal: true, vertical: false)
}
.frame(maxWidth: .infinity, alignment: .leading)
}
if let detailText {
Text(detailText)
.font(.footnote)
.foregroundStyle(.secondary)
.fixedSize(horizontal: false, vertical: true)
.frame(maxWidth: .infinity, alignment: .leading)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(layout.trackRowPadding)
.background(
RoundedRectangle(cornerRadius: 18, style: .continuous)
.fill(Color(uiColor: .tertiarySystemGroupedBackground))
)
}
@ViewBuilder
private func rowActionButton(
title: String,
systemImage: String,
role: ActionButtonRole,
isEnabled: Bool,
action: @escaping () -> Void
) -> some View {
Button(action: action) {
actionButtonLabel(
title: title,
systemImage: systemImage,
role: role,
isEnabled: isEnabled
)
}
.buttonStyle(.plain)
.disabled(!isEnabled)
}
private func actionButtonLabel(
title: String,
systemImage: String,
role: ActionButtonRole,
isEnabled: Bool
) -> some View {
let colors = actionButtonColors(for: role, isEnabled: isEnabled)
return HStack(spacing: LayoutMetrics.controlButtonSpacing) {
Image(systemName: systemImage)
.font(.system(size: LayoutMetrics.controlButtonSymbolSize, weight: .semibold))
Text(title)
.font(.subheadline.weight(.semibold))
.lineLimit(1)
.minimumScaleFactor(0.82)
.allowsTightening(true)
}
.foregroundStyle(colors.foreground)
.padding(.horizontal, LayoutMetrics.controlButtonHorizontalPadding)
.frame(
minWidth: LayoutMetrics.controlButtonMinWidth,
minHeight: LayoutMetrics.controlButtonHeight
)
.background(
Capsule()
.fill(colors.background)
)
.overlay(
Capsule()
.strokeBorder(colors.border, lineWidth: 1)
)
}
private func loadingStateView(_ title: String) -> some View {
HStack(spacing: 12) {
ProgressView()
Text(title)
.font(.subheadline)
.foregroundStyle(.secondary)
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(16)
.background(
RoundedRectangle(cornerRadius: 16, style: .continuous)
.fill(Color(uiColor: .tertiarySystemGroupedBackground))
)
}
private func messageCard(_ message: LibrarySectionMessage) -> some View {
HStack(alignment: .top, spacing: 12) {
Image(systemName: message.systemImage)
.font(.title3.weight(.semibold))
.foregroundStyle(.secondary)
.frame(width: 28)
VStack(alignment: .leading, spacing: 4) {
Text(message.title)
.font(.headline)
.foregroundStyle(.primary)
Text(message.body)
.font(.subheadline)
.foregroundStyle(.secondary)
}
}
.frame(maxWidth: .infinity, alignment: .leading)
.padding(16)
.background(
RoundedRectangle(cornerRadius: 16, style: .continuous)
.fill(Color(uiColor: .tertiarySystemGroupedBackground))
)
}
private func statusColor(for status: OfflineLibraryRemoteTrackStatus) -> Color {
switch status {
case .notDownloaded:
return .blue
case .downloading:
return .cyan
case .downloaded:
return .green
case .missing:
return .orange
case .failed:
return .red
}
}
private func badgeColor(for badge: NowPlayingDownloadBadge) -> Color {
switch badge {
case .downloaded:
return .green
case .missing:
return .orange
case .offline:
return .blue
}
}
private func playbackStateColor(for state: iPhonePlaybackState) -> Color {
switch state {
case .playing:
return .green
case .paused, .stopped:
return .secondary
case .missingFile:
return .orange
case .failed:
return .red
}
}
private func statusBadge(title: String, color: Color) -> some View {
Text(title)
.font(.caption.weight(.semibold))
.foregroundStyle(color)
.lineLimit(1)
.minimumScaleFactor(0.75)
.allowsTightening(true)
.padding(.horizontal, LayoutMetrics.badgeHorizontalPadding)
.padding(.vertical, LayoutMetrics.badgeVerticalPadding)
.frame(minHeight: LayoutMetrics.badgeMinHeight)
.fixedSize(horizontal: true, vertical: false)
.background(color.opacity(0.14), in: Capsule())
.overlay(
Capsule()
.strokeBorder(color.opacity(0.18), lineWidth: 1)
)
}
private var nowPlayingHeaderText: some View {
Text("Now Playing")
.font(.headline)
}
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)
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)
.layoutPriority(1)
}
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,
controlsLayout: NowPlayingCardLayoutMetrics.ControlsLayout
) -> some View {
compactNowPlayingButton(
systemImage: playbackActionSystemImage(isPlaying: card.isPlaying),
accessibilityLabel: card.isPlaying ? "Pause" : "Play",
role: .primary,
isEnabled: true,
isActive: true,
controlSize: controlsLayout.primaryButtonSize,
symbolSize: controlsLayout.primarySymbolSize
) {
viewModel.togglePlayback(trackID: card.trackID)
}
}
private func nowPlayingShuffleButton(
controlsLayout: NowPlayingCardLayoutMetrics.ControlsLayout
) -> some View {
compactNowPlayingButton(
systemImage: "shuffle",
accessibilityLabel: viewModel.isShuffleEnabled ? "Disable Shuffle" : "Enable Shuffle",
role: .secondary,
isEnabled: !viewModel.nowPlaying.queueTrackIDs.isEmpty,
isActive: viewModel.isShuffleEnabled,
controlSize: controlsLayout.secondaryButtonSize,
symbolSize: controlsLayout.secondarySymbolSize
) {
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()
}
}
private func libraryTrackText(
title: String,
artist: String,
durationText: String
) -> some View {
VStack(alignment: .leading, spacing: 6) {
Text(title)
.font(.headline)
.foregroundStyle(.primary)
.lineLimit(2)
.fixedSize(horizontal: false, vertical: true)
Text(artist)
.font(.subheadline)
.foregroundStyle(.secondary)
.lineLimit(1)
.minimumScaleFactor(0.95)
Label(durationText, systemImage: "clock")
.font(.caption)
.foregroundStyle(.secondary)
.lineLimit(1)
}
.frame(maxWidth: .infinity, alignment: .leading)
.layoutPriority(1)
}
private func trackFavoriteButton(
action: @escaping () -> Void,
isFavorite: Bool
) -> some View {
favoriteControlButton(isFavorite: isFavorite, action: action)
}
private func nowPlayingArtworkView(
localFilePath: String?,
layout: RootLayout
) -> some View {
NowPlayingArtworkView(
localFilePath: localFilePath,
height: layout.nowPlayingArtworkHeight
)
}
private func favoriteControlButton(
isFavorite: Bool,
action: @escaping () -> Void
) -> some View {
Button(action: action) {
Image(systemName: isFavorite ? "heart.fill" : "heart")
.font(.system(size: NowPlayingCardLayoutMetrics.favoriteButtonSymbolSize, weight: .semibold))
.foregroundStyle(isFavorite ? .red : .gray)
.frame(
width: NowPlayingCardLayoutMetrics.favoriteButtonSize,
height: NowPlayingCardLayoutMetrics.favoriteButtonSize
)
.background(
Capsule()
.fill(Color(uiColor: .tertiarySystemFill))
)
.overlay(
Capsule()
.strokeBorder(Color(uiColor: .separator).opacity(0.12), lineWidth: 1)
)
}
.buttonStyle(.plain)
.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:
return ActionButtonColors(
foreground: .white.opacity(isEnabled ? 1 : 0.92),
background: Color.blue.opacity(isEnabled ? 1 : 0.42),
border: Color.blue.opacity(isEnabled ? 0.22 : 0.12)
)
case .secondary:
return ActionButtonColors(
foreground: isEnabled ? .primary : .secondary,
background: Color(uiColor: .systemGray5).opacity(isEnabled ? 1 : 0.78),
border: Color(uiColor: .separator).opacity(isEnabled ? 0.14 : 0.08)
)
}
}
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"
}
private func remoteTrackActionSystemImage(for track: RemoteTrackRowViewData) -> String {
if track.canPlay {
return playbackActionSystemImage(isPlaying: track.playButtonTitle == "Pause")
}
switch track.status {
case .notDownloaded, .downloading:
return "arrow.down"
case .downloaded:
return "play.fill"
case .missing, .failed:
return "arrow.clockwise"
}
}
private func remoteTrackActionRole(for track: RemoteTrackRowViewData) -> ActionButtonRole {
if track.canPlay || track.status == .missing || track.status == .failed {
return .primary
}
return .secondary
}
}
private struct NowPlayingArtworkView: View {
let localFilePath: String?
let height: CGFloat
private let cornerRadius: CGFloat = 24
private let placeholderIconSize: CGFloat = 54
var body: some View {
ZStack {
if let artworkImage {
Image(uiImage: artworkImage)
.resizable()
.scaledToFill()
.frame(maxWidth: .infinity, maxHeight: .infinity)
} else {
artworkPlaceholder(cornerRadius: cornerRadius, iconSize: placeholderIconSize)
}
}
.frame(maxWidth: .infinity)
.frame(height: height)
.clipped()
.clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous))
}
private var artworkImage: UIImage? {
guard let localFilePath, !localFilePath.isEmpty else {
return nil
}
return UIImage(contentsOfFile: localFilePath)
}
}
private struct ArtworkThumbnailView: View {
let localFilePath: String?
var body: some View {
Group {
if let artworkImage {
Image(uiImage: artworkImage)
.resizable()
.scaledToFill()
} else {
artworkPlaceholder(cornerRadius: 12, iconSize: 18)
}
}
.frame(width: 60, height: 60)
.clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous))
}
private var artworkImage: UIImage? {
guard let localFilePath, !localFilePath.isEmpty else {
return nil
}
return UIImage(contentsOfFile: localFilePath)
}
}
private func artworkPlaceholder(cornerRadius: CGFloat, iconSize: CGFloat) -> some View {
VStack(spacing: 8) {
Spacer(minLength: 0)
Image(systemName: "music.note")
.font(.system(size: iconSize, weight: .semibold))
.foregroundStyle(Color.primary.opacity(0.35))
Spacer(minLength: 0)
}
.frame(maxWidth: .infinity, maxHeight: .infinity)
.background(
RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)
.fill(
LinearGradient(
colors: [
Color.blue.opacity(0.18),
Color.cyan.opacity(0.14),
Color.mint.opacity(0.08),
],
startPoint: .topLeading,
endPoint: .bottomTrailing
)
)
)
}