900 lines
30 KiB
Swift
900 lines
30 KiB
Swift
import SwiftUI
|
|
import VelodyDomain
|
|
#if canImport(UIKit)
|
|
import UIKit
|
|
#endif
|
|
|
|
enum NowPlayingCardLayoutMetrics {
|
|
static func artworkHeight(for contentWidth: CGFloat) -> CGFloat {
|
|
min(max(contentWidth * 0.42, 148), 176)
|
|
}
|
|
}
|
|
|
|
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 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 {
|
|
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)
|
|
}
|
|
|
|
}
|
|
|
|
@State private var viewModel = iPhoneLibraryViewModel()
|
|
@State private var scrubbedPlaybackTime: Double?
|
|
|
|
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: 16) {
|
|
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))
|
|
}
|
|
}
|
|
|
|
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)
|
|
|
|
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)
|
|
}
|
|
.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 nowPlayingControls(_ card: NowPlayingCardViewData) -> some View {
|
|
ViewThatFits(in: .vertical) {
|
|
HStack(spacing: LayoutMetrics.controlGroupSpacing) {
|
|
if viewModel.nowPlayingFavoriteTrackID == card.trackID {
|
|
nowPlayingFavoriteButton(trackID: card.trackID)
|
|
}
|
|
|
|
nowPlayingToggleButton(card)
|
|
nowPlayingStopButton
|
|
}
|
|
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
if viewModel.nowPlayingFavoriteTrackID == card.trackID {
|
|
nowPlayingFavoriteButton(trackID: card.trackID)
|
|
}
|
|
|
|
HStack(spacing: LayoutMetrics.controlGroupSpacing) {
|
|
nowPlayingToggleButton(card)
|
|
nowPlayingStopButton
|
|
}
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
}
|
|
|
|
private func nowPlayingFavoriteButton(trackID: String) -> some View {
|
|
favoriteControlButton(isFavorite: viewModel.isNowPlayingTrackFavorite) {
|
|
Task {
|
|
await viewModel.toggleFavorite(trackID: trackID)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func nowPlayingToggleButton(_ card: NowPlayingCardViewData) -> some View {
|
|
rowActionButton(
|
|
title: card.isPlaying ? "Pause" : "Play",
|
|
systemImage: playbackActionSystemImage(isPlaying: card.isPlaying),
|
|
role: .primary,
|
|
isEnabled: true
|
|
) {
|
|
viewModel.togglePlayback(trackID: card.trackID)
|
|
}
|
|
}
|
|
|
|
private var nowPlayingStopButton: some View {
|
|
rowActionButton(
|
|
title: "Stop",
|
|
systemImage: "stop.fill",
|
|
role: .secondary,
|
|
isEnabled: true
|
|
) {
|
|
viewModel.stopPlayback()
|
|
}
|
|
}
|
|
|
|
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: LayoutMetrics.favoriteButtonSymbolSize, weight: .semibold))
|
|
.foregroundStyle(isFavorite ? .red : .gray)
|
|
.frame(
|
|
width: LayoutMetrics.favoriteButtonSize,
|
|
height: LayoutMetrics.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 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 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
|
|
)
|
|
)
|
|
)
|
|
}
|