velody/apps/apple/VelodyiPhone/Sources/iPhoneLibraryView.swift
2026-05-30 09:43:14 +02:00

273 lines
11 KiB
Swift

import SwiftUI
import VelodyDomain
#if canImport(UIKit)
import UIKit
#endif
struct iPhoneLibraryView: View {
@State private var viewModel = iPhoneLibraryViewModel()
var body: some View {
NavigationStack {
List {
if let currentTitle = viewModel.nowPlaying.title {
Section("Now Playing") {
HStack(alignment: .center) {
VStack(alignment: .leading, spacing: 4) {
Text(currentTitle)
.font(.headline)
if let artist = viewModel.nowPlaying.artist {
Text(artist)
.foregroundStyle(.secondary)
}
Text(viewModel.nowPlaying.isPlaying ? "Playing offline" : "Paused")
.font(.caption)
.foregroundStyle(.secondary)
}
Spacer()
if let trackID = viewModel.nowPlaying.trackID {
Button(viewModel.nowPlaying.isPlaying ? "Pause" : "Play") {
viewModel.togglePlayback(trackID: trackID)
}
.buttonStyle(.borderedProminent)
}
}
}
}
Section("Remote Library: \(viewModel.remoteTracks.count)") {
ForEach(viewModel.remoteTracks) { track in
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()
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)
}
}
Section("Available Offline: \(viewModel.availableOfflineTracks.count)") {
if viewModel.availableOfflineTracks.isEmpty {
Text("Downloaded tracks with a verified local MP3 will appear here.")
.font(.subheadline)
.foregroundStyle(.secondary)
} else {
ForEach(viewModel.availableOfflineTracks) { track in
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()
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)
}
}
}
}
.overlay {
overlayView
}
.navigationTitle("Velody")
.toolbar {
ToolbarItem(placement: .topBarTrailing) {
Button("Sync Remote Library") {
Task {
await viewModel.refreshSync()
}
}
.disabled(viewModel.state == .loading)
}
}
.safeAreaInset(edge: .bottom) {
VStack(alignment: .leading, spacing: 4) {
if let playbackError = viewModel.nowPlaying.errorMessage {
Text(playbackError)
.font(.footnote)
.foregroundStyle(.red)
}
Text(viewModel.syncStatus)
.font(.footnote)
.foregroundStyle(.secondary)
}
.padding()
.frame(maxWidth: .infinity, alignment: .leading)
.background(.ultraThinMaterial)
}
}
.task {
await viewModel.loadIfNeeded()
}
}
@ViewBuilder
private var overlayView: some View {
switch viewModel.state {
case .idle:
if viewModel.remoteTracks.isEmpty {
ContentUnavailableView(
"No Remote Library Yet",
systemImage: "music.note.list",
description: Text("Tap Sync Remote Library to fetch metadata from the backend.")
)
}
case .loading:
ProgressView("Syncing remote library...")
case .success:
EmptyView()
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.remoteTracks.isEmpty {
ContentUnavailableView(
"Network Error",
systemImage: "wifi.exclamationmark",
description: Text(message)
)
}
}
}
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 struct ArtworkThumbnailView: View {
let localFilePath: String?
var body: some View {
Group {
if let artworkImage {
Image(uiImage: artworkImage)
.resizable()
.scaledToFill()
} else {
ZStack {
RoundedRectangle(cornerRadius: 10, style: .continuous)
.fill(Color.gray.opacity(0.14))
Image(systemName: "music.note")
.font(.headline)
.foregroundStyle(.secondary)
}
}
}
.frame(width: 52, height: 52)
.clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous))
.overlay(
RoundedRectangle(cornerRadius: 10, style: .continuous)
.stroke(Color.secondary.opacity(0.12), lineWidth: 1)
)
}
private var artworkImage: UIImage? {
guard let localFilePath, !localFilePath.isEmpty else {
return nil
}
return UIImage(contentsOfFile: localFilePath)
}
}