577 lines
21 KiB
Swift
577 lines
21 KiB
Swift
import SwiftUI
|
|
import VelodyDomain
|
|
#if canImport(UIKit)
|
|
import UIKit
|
|
#endif
|
|
|
|
struct iPhoneLibraryView: View {
|
|
@State private var viewModel = iPhoneLibraryViewModel()
|
|
@State private var scrubbedPlaybackTime: Double?
|
|
|
|
var body: some View {
|
|
ScrollView {
|
|
VStack(alignment: .leading, spacing: 20) {
|
|
titleSection
|
|
syncSection
|
|
searchSection
|
|
|
|
if let nowPlayingCard = viewModel.nowPlayingCard {
|
|
nowPlayingCardView(nowPlayingCard)
|
|
}
|
|
|
|
remoteLibrarySection
|
|
availableOfflineSection
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.padding(.horizontal, 16)
|
|
.padding(.top, 16)
|
|
.padding(.bottom, 24)
|
|
}
|
|
.background(Color(uiColor: .systemGroupedBackground))
|
|
.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("Sync Remote Library") {
|
|
Task {
|
|
await viewModel.refreshSync()
|
|
}
|
|
}
|
|
.buttonStyle(.borderedProminent)
|
|
.disabled(viewModel.state == .loading)
|
|
|
|
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) -> some View {
|
|
VStack(alignment: .leading, spacing: 16) {
|
|
HStack(alignment: .top, spacing: 12) {
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
Text("Now Playing")
|
|
.font(.headline)
|
|
Text(card.playbackStateText)
|
|
.font(.subheadline)
|
|
.foregroundStyle(playbackStateColor(for: viewModel.nowPlaying.playbackState))
|
|
}
|
|
|
|
Spacer(minLength: 12)
|
|
|
|
statusBadge(title: card.downloadBadge.title, color: badgeColor(for: card.downloadBadge))
|
|
}
|
|
|
|
ArtworkCoverView(localFilePath: card.artworkLocalFilePath)
|
|
.frame(maxWidth: .infinity)
|
|
.frame(height: 220)
|
|
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
Text(card.title)
|
|
.font(.title3.weight(.semibold))
|
|
.foregroundStyle(.primary)
|
|
.lineLimit(2)
|
|
Text(card.artist)
|
|
.font(.subheadline)
|
|
.foregroundStyle(.secondary)
|
|
.lineLimit(1)
|
|
}
|
|
|
|
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)
|
|
}
|
|
|
|
HStack(spacing: 12) {
|
|
if viewModel.nowPlayingFavoriteTrackID == card.trackID {
|
|
favoriteButton(isFavorite: viewModel.isNowPlayingTrackFavorite) {
|
|
Task {
|
|
await viewModel.toggleFavorite(trackID: card.trackID)
|
|
}
|
|
}
|
|
.frame(width: 48, height: 48)
|
|
.background(Color.secondary.opacity(0.08), in: RoundedRectangle(cornerRadius: 14, style: .continuous))
|
|
}
|
|
|
|
Button {
|
|
viewModel.togglePlayback(trackID: card.trackID)
|
|
} label: {
|
|
HStack(spacing: 8) {
|
|
Image(systemName: card.isPlaying ? "pause.fill" : "play.fill")
|
|
Text(card.isPlaying ? "Pause" : "Play")
|
|
.lineLimit(1)
|
|
}
|
|
.font(.headline)
|
|
.frame(maxWidth: .infinity, minHeight: 48)
|
|
}
|
|
.buttonStyle(.borderedProminent)
|
|
|
|
Button {
|
|
viewModel.stopPlayback()
|
|
} label: {
|
|
HStack(spacing: 8) {
|
|
Image(systemName: "stop.fill")
|
|
Text("Stop")
|
|
.lineLimit(1)
|
|
}
|
|
.font(.headline)
|
|
.frame(maxWidth: .infinity, minHeight: 48)
|
|
}
|
|
.buttonStyle(.bordered)
|
|
}
|
|
}
|
|
.padding(20)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 24, style: .continuous)
|
|
.fill(Color(uiColor: .secondarySystemGroupedBackground))
|
|
)
|
|
.onChange(of: card.trackID) { _, _ in
|
|
scrubbedPlaybackTime = nil
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var remoteLibrarySection: some View {
|
|
sectionCard(title: "Remote Library: \(viewModel.remoteTracks.count)") {
|
|
if viewModel.remoteTracks.isEmpty {
|
|
if viewModel.hasActiveSearch && viewModel.hasCachedRemoteTracks {
|
|
Text("No matching tracks found.")
|
|
.font(.subheadline)
|
|
.foregroundStyle(.secondary)
|
|
} else {
|
|
remoteLibraryEmptyState
|
|
}
|
|
} else {
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
ForEach(Array(viewModel.remoteTracks.enumerated()), id: \.element.id) { index, track in
|
|
if index > 0 {
|
|
Divider()
|
|
.padding(.vertical, 12)
|
|
}
|
|
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
HStack(alignment: .top, spacing: 12) {
|
|
ArtworkThumbnailView(localFilePath: track.artworkLocalFilePath)
|
|
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
HStack(alignment: .top) {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text(track.title)
|
|
.font(.headline)
|
|
Text(track.artist)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
|
|
Spacer()
|
|
|
|
favoriteButton(isFavorite: track.isFavorite) {
|
|
Task {
|
|
await viewModel.toggleFavorite(trackID: track.id)
|
|
}
|
|
}
|
|
|
|
Text(track.statusText)
|
|
.font(.caption.weight(.semibold))
|
|
.padding(.horizontal, 10)
|
|
.padding(.vertical, 4)
|
|
.background(statusColor(for: track.status), in: Capsule())
|
|
.foregroundStyle(.white)
|
|
}
|
|
}
|
|
}
|
|
|
|
Text("Duration: \(track.durationText)")
|
|
.font(.subheadline)
|
|
.foregroundStyle(.secondary)
|
|
Text("Remote track ID: \(track.remoteTrackID)")
|
|
.font(.caption)
|
|
.foregroundStyle(.tertiary)
|
|
.textSelection(.enabled)
|
|
Text("Asset ID: \(track.assetID)")
|
|
.font(.caption)
|
|
.foregroundStyle(.tertiary)
|
|
.textSelection(.enabled)
|
|
|
|
HStack {
|
|
Button(track.downloadButtonTitle) {
|
|
Task {
|
|
await viewModel.downloadTrack(trackID: track.id)
|
|
}
|
|
}
|
|
.buttonStyle(.bordered)
|
|
.disabled(!track.canDownload)
|
|
|
|
if track.canPlay {
|
|
Button(track.playButtonTitle) {
|
|
viewModel.togglePlayback(trackID: track.id)
|
|
}
|
|
.buttonStyle(.borderedProminent)
|
|
}
|
|
}
|
|
|
|
if let error = track.lastDownloadError,
|
|
(track.status == .failed || track.status == .missing)
|
|
{
|
|
Text(error)
|
|
.font(.caption)
|
|
.foregroundStyle(.red)
|
|
}
|
|
}
|
|
.padding(.vertical, 4)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var remoteLibraryEmptyState: some View {
|
|
switch viewModel.state {
|
|
case .loading:
|
|
ProgressView("Syncing remote library...")
|
|
.frame(maxWidth: .infinity, alignment: .center)
|
|
.padding(.vertical, 12)
|
|
case .idle:
|
|
if viewModel.hasCachedRemoteTracks {
|
|
Text("Remote tracks will appear here after syncing.")
|
|
.font(.subheadline)
|
|
.foregroundStyle(.secondary)
|
|
} else {
|
|
ContentUnavailableView(
|
|
"No Remote Library Yet",
|
|
systemImage: "music.note.list",
|
|
description: Text("Tap Sync Remote Library to fetch metadata from the backend.")
|
|
)
|
|
}
|
|
case .success:
|
|
Text("Remote tracks will appear here after syncing.")
|
|
.font(.subheadline)
|
|
.foregroundStyle(.secondary)
|
|
case .empty:
|
|
ContentUnavailableView(
|
|
"Empty Remote Library",
|
|
systemImage: "music.note.list",
|
|
description: Text("The backend returned no remote tracks for this iPhone.")
|
|
)
|
|
case .networkError(let message):
|
|
if viewModel.hasCachedRemoteTracks {
|
|
Text("Remote tracks will appear here after syncing.")
|
|
.font(.subheadline)
|
|
.foregroundStyle(.secondary)
|
|
} else {
|
|
ContentUnavailableView(
|
|
"Network Error",
|
|
systemImage: "wifi.exclamationmark",
|
|
description: Text(message)
|
|
)
|
|
}
|
|
}
|
|
}
|
|
|
|
@ViewBuilder
|
|
private var availableOfflineSection: some View {
|
|
sectionCard(title: "Available Offline: \(viewModel.availableOfflineTracks.count)") {
|
|
if viewModel.availableOfflineTracks.isEmpty {
|
|
Text(
|
|
viewModel.hasActiveSearch
|
|
? "No matching tracks found."
|
|
: "Downloaded tracks with a verified local MP3 will appear here."
|
|
)
|
|
.font(.subheadline)
|
|
.foregroundStyle(.secondary)
|
|
} else {
|
|
VStack(alignment: .leading, spacing: 0) {
|
|
ForEach(Array(viewModel.availableOfflineTracks.enumerated()), id: \.element.id) { index, track in
|
|
if index > 0 {
|
|
Divider()
|
|
.padding(.vertical, 12)
|
|
}
|
|
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
HStack(alignment: .top, spacing: 12) {
|
|
ArtworkThumbnailView(localFilePath: track.artworkLocalFilePath)
|
|
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
HStack(alignment: .top) {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text(track.title)
|
|
.font(.headline)
|
|
Text(track.artist)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
|
|
Spacer()
|
|
|
|
favoriteButton(isFavorite: track.isFavorite) {
|
|
Task {
|
|
await viewModel.toggleFavorite(trackID: track.id)
|
|
}
|
|
}
|
|
|
|
Button(track.playButtonTitle) {
|
|
viewModel.togglePlayback(trackID: track.id)
|
|
}
|
|
.buttonStyle(.borderedProminent)
|
|
}
|
|
}
|
|
}
|
|
|
|
Text("Duration: \(track.durationText)")
|
|
.font(.subheadline)
|
|
.foregroundStyle(.secondary)
|
|
Text("Remote track ID: \(track.remoteTrackID)")
|
|
.font(.caption)
|
|
.foregroundStyle(.tertiary)
|
|
.textSelection(.enabled)
|
|
Text("Asset ID: \(track.assetID)")
|
|
.font(.caption)
|
|
.foregroundStyle(.tertiary)
|
|
.textSelection(.enabled)
|
|
}
|
|
.padding(.vertical, 4)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func sectionCard<Content: View>(
|
|
title: String,
|
|
@ViewBuilder content: () -> Content
|
|
) -> some View {
|
|
VStack(alignment: .leading, spacing: 14) {
|
|
Text(title)
|
|
.font(.headline)
|
|
|
|
content()
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.padding(18)
|
|
.background(
|
|
RoundedRectangle(cornerRadius: 20, style: .continuous)
|
|
.fill(Color(uiColor: .secondarySystemGroupedBackground))
|
|
)
|
|
}
|
|
|
|
private func statusColor(for status: OfflineLibraryRemoteTrackStatus) -> Color {
|
|
switch status {
|
|
case .notDownloaded:
|
|
return .gray
|
|
case .downloading:
|
|
return .blue
|
|
case .downloaded:
|
|
return .green
|
|
case .missing:
|
|
return .orange
|
|
case .failed:
|
|
return .red
|
|
}
|
|
}
|
|
|
|
private func favoriteButton(
|
|
isFavorite: Bool,
|
|
action: @escaping () -> Void
|
|
) -> some View {
|
|
Button(action: action) {
|
|
Image(systemName: isFavorite ? "heart.fill" : "heart")
|
|
.font(.title3.weight(.semibold))
|
|
.foregroundStyle(isFavorite ? .red : .secondary)
|
|
}
|
|
.buttonStyle(.borderless)
|
|
.accessibilityLabel(isFavorite ? "Remove Favorite" : "Add Favorite")
|
|
}
|
|
|
|
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)
|
|
.padding(.horizontal, 10)
|
|
.padding(.vertical, 5)
|
|
.background(color.opacity(0.14), in: Capsule())
|
|
}
|
|
}
|
|
|
|
private struct ArtworkCoverView: View {
|
|
let localFilePath: String?
|
|
|
|
var body: some View {
|
|
Group {
|
|
if let artworkImage {
|
|
Image(uiImage: artworkImage)
|
|
.resizable()
|
|
.scaledToFill()
|
|
} else {
|
|
VStack {
|
|
Spacer(minLength: 0)
|
|
Image(systemName: "music.note")
|
|
.font(.system(size: 56, weight: .semibold))
|
|
.foregroundStyle(.secondary)
|
|
Spacer(minLength: 0)
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
.background(
|
|
LinearGradient(
|
|
colors: [
|
|
Color.blue.opacity(0.18),
|
|
Color.cyan.opacity(0.12),
|
|
Color.gray.opacity(0.18),
|
|
],
|
|
startPoint: .topLeading,
|
|
endPoint: .bottomTrailing
|
|
)
|
|
)
|
|
}
|
|
}
|
|
.clipShape(RoundedRectangle(cornerRadius: 24, 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 {
|
|
VStack {
|
|
Spacer(minLength: 0)
|
|
Image(systemName: "music.note")
|
|
.font(.headline)
|
|
.foregroundStyle(.secondary)
|
|
Spacer(minLength: 0)
|
|
}
|
|
.frame(maxWidth: .infinity, maxHeight: .infinity)
|
|
.background(Color.gray.opacity(0.14))
|
|
}
|
|
}
|
|
.frame(width: 52, height: 52)
|
|
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
|
|
}
|
|
|
|
private var artworkImage: UIImage? {
|
|
guard let localFilePath, !localFilePath.isEmpty else {
|
|
return nil
|
|
}
|
|
|
|
return UIImage(contentsOfFile: localFilePath)
|
|
}
|
|
}
|