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

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)
}
}