389 lines
14 KiB
Swift
389 lines
14 KiB
Swift
import Foundation
|
|
import SwiftUI
|
|
import VelodyDomain
|
|
|
|
struct MacLibraryView: View {
|
|
@State private var viewModel = MacLibraryViewModel()
|
|
@State private var scrubbedPlaybackTime: Double?
|
|
|
|
var body: some View {
|
|
VStack(alignment: .leading, spacing: 16) {
|
|
Text("Private Library Foundation")
|
|
.font(.largeTitle)
|
|
|
|
Text("Backend Connection")
|
|
.font(.title2)
|
|
|
|
TextField("http://localhost:3007", text: $viewModel.serverURLString)
|
|
.textFieldStyle(.roundedBorder)
|
|
.onSubmit {
|
|
viewModel.persistServerURLSelection()
|
|
}
|
|
|
|
VStack(alignment: .leading, spacing: 8) {
|
|
statusRow(title: "Server URL", value: viewModel.serverURLString)
|
|
statusRow(title: "Registration", value: viewModel.deviceRegistrationStatus)
|
|
statusRow(
|
|
title: "Device ID",
|
|
value: viewModel.registeredDeviceId ?? "Not registered yet."
|
|
)
|
|
statusRow(title: "Last heartbeat", value: viewModel.lastHeartbeatStatus)
|
|
statusRow(title: "Last bootstrap", value: viewModel.lastSyncBootstrapStatus)
|
|
statusRow(title: "Last upload", value: viewModel.lastUploadStatus)
|
|
|
|
if let lastBootstrapTrackCount = viewModel.lastBootstrapTrackCount {
|
|
statusRow(title: "Bootstrap tracks", value: "\(lastBootstrapTrackCount)")
|
|
}
|
|
|
|
if let lastBootstrapCursor = viewModel.lastBootstrapCursor {
|
|
statusRow(title: "Next cursor", value: lastBootstrapCursor)
|
|
}
|
|
}
|
|
|
|
HStack(spacing: 12) {
|
|
Button("Register this Mac") {
|
|
Task {
|
|
await viewModel.registerThisMac()
|
|
}
|
|
}
|
|
.disabled(viewModel.isRegisteringDevice)
|
|
|
|
Button("Send Heartbeat") {
|
|
Task {
|
|
await viewModel.sendHeartbeat()
|
|
}
|
|
}
|
|
.disabled(viewModel.registeredDeviceId == nil || viewModel.isSendingHeartbeat)
|
|
|
|
Button("Sync Bootstrap") {
|
|
Task {
|
|
await viewModel.syncBootstrap()
|
|
}
|
|
}
|
|
.disabled(viewModel.isRunningSyncBootstrap)
|
|
}
|
|
|
|
Text("Selected folder")
|
|
.font(.headline)
|
|
|
|
Text(viewModel.selectedFolderPath)
|
|
.textSelection(.enabled)
|
|
.foregroundStyle(.secondary)
|
|
|
|
HStack(spacing: 12) {
|
|
Button("Choose Music Folder") {
|
|
viewModel.chooseFolder()
|
|
}
|
|
|
|
Button("Scan MP3 Files") {
|
|
Task {
|
|
await viewModel.scanMP3Files()
|
|
}
|
|
}
|
|
.disabled(viewModel.selectedFolderPath == "No folder selected" || viewModel.isScanning)
|
|
}
|
|
|
|
HStack(spacing: 12) {
|
|
Text("Discovered tracks: \(viewModel.discoveredTrackCount)")
|
|
.font(.headline)
|
|
|
|
if viewModel.isScanning {
|
|
ProgressView()
|
|
.controlSize(.small)
|
|
}
|
|
}
|
|
|
|
Text(viewModel.scanStatus)
|
|
.foregroundStyle(.secondary)
|
|
|
|
HStack(spacing: 12) {
|
|
Button("Upload Selected Track") {
|
|
Task {
|
|
await viewModel.uploadSelectedTrack()
|
|
}
|
|
}
|
|
.disabled(
|
|
viewModel.selectedTrackID == nil
|
|
|| viewModel.registeredDeviceId == nil
|
|
|| viewModel.isUploadingAnyTrack
|
|
|| viewModel.isUploadingAllTracks
|
|
)
|
|
|
|
Button("Upload All Local Tracks") {
|
|
Task {
|
|
await viewModel.uploadAllLocalTracks()
|
|
}
|
|
}
|
|
.disabled(
|
|
viewModel.tracks.isEmpty
|
|
|| viewModel.registeredDeviceId == nil
|
|
|| viewModel.isUploadingAnyTrack
|
|
|| viewModel.isUploadingAllTracks
|
|
)
|
|
|
|
if viewModel.isUploadingAnyTrack || viewModel.isUploadingAllTracks {
|
|
ProgressView()
|
|
.controlSize(.small)
|
|
}
|
|
}
|
|
|
|
List(selection: $viewModel.selectedTrackID) {
|
|
ForEach(viewModel.tracks, id: \.id) { track in
|
|
HStack(alignment: .top, spacing: 12) {
|
|
Button {
|
|
viewModel.togglePlayback(for: track)
|
|
} label: {
|
|
Image(systemName: viewModel.playbackButtonSymbol(for: track))
|
|
.font(.title2)
|
|
}
|
|
.buttonStyle(.borderless)
|
|
|
|
VStack(alignment: .leading, spacing: 6) {
|
|
HStack(alignment: .center, spacing: 8) {
|
|
Text(track.title)
|
|
.font(.headline)
|
|
|
|
Text(viewModel.uploadStatusLabel(for: track))
|
|
.font(.caption)
|
|
.foregroundStyle(uploadBadgeColor(for: track))
|
|
.padding(.horizontal, 8)
|
|
.padding(.vertical, 4)
|
|
.background(uploadBadgeColor(for: track).opacity(0.12), in: Capsule())
|
|
}
|
|
|
|
Text(track.artist)
|
|
.foregroundStyle(.secondary)
|
|
|
|
if let album = track.album {
|
|
Text(album)
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
|
|
if let durationSeconds = track.durationSeconds {
|
|
Text("Duration: \(format(durationSeconds: durationSeconds))")
|
|
.font(.caption2)
|
|
.foregroundStyle(.tertiary)
|
|
}
|
|
|
|
if let remoteTrackId = track.remoteTrackId, !remoteTrackId.isEmpty {
|
|
Text("Remote track ID: \(remoteTrackId)")
|
|
.font(.caption2)
|
|
.foregroundStyle(.secondary)
|
|
.textSelection(.enabled)
|
|
}
|
|
|
|
if let uploadProgress = viewModel.uploadProgress(for: track) {
|
|
ProgressView(value: uploadProgress)
|
|
.frame(maxWidth: 220)
|
|
} else if [.preparing, .uploading].contains(viewModel.uploadStatus(for: track)) {
|
|
ProgressView()
|
|
.frame(maxWidth: 220, alignment: .leading)
|
|
}
|
|
|
|
if let lastUploadError = track.lastUploadError, !lastUploadError.isEmpty {
|
|
Text(lastUploadError)
|
|
.font(.caption2)
|
|
.foregroundStyle(.red)
|
|
}
|
|
|
|
Text(track.localFilePath)
|
|
.font(.caption2)
|
|
.foregroundStyle(.tertiary)
|
|
.lineLimit(1)
|
|
}
|
|
|
|
Spacer(minLength: 12)
|
|
|
|
if viewModel.isCurrentTrack(track) {
|
|
Text(viewModel.nowPlayingState.isPlaying ? "Playing" : "Paused")
|
|
.font(.caption)
|
|
.foregroundStyle(Color.accentColor)
|
|
.padding(.horizontal, 8)
|
|
.padding(.vertical, 4)
|
|
.background(.quaternary, in: Capsule())
|
|
}
|
|
}
|
|
.padding(.vertical, 4)
|
|
.tag(track.id)
|
|
}
|
|
}
|
|
.overlay {
|
|
if viewModel.tracks.isEmpty && !viewModel.isScanning {
|
|
ContentUnavailableView(
|
|
"No MP3 Files Discovered",
|
|
systemImage: "music.note.list",
|
|
description: Text("Choose a folder, then run a manual scan.")
|
|
)
|
|
}
|
|
}
|
|
|
|
miniPlayer
|
|
}
|
|
.padding(24)
|
|
.task {
|
|
await viewModel.loadIfNeeded()
|
|
}
|
|
}
|
|
|
|
private func format(durationSeconds: Double) -> String {
|
|
let totalSeconds = Int(durationSeconds.rounded())
|
|
let minutes = totalSeconds / 60
|
|
let seconds = totalSeconds % 60
|
|
return String(format: "%d:%02d", minutes, seconds)
|
|
}
|
|
|
|
private func uploadBadgeColor(for track: LibraryTrack) -> Color {
|
|
switch viewModel.uploadStatus(for: track) {
|
|
case .localOnly:
|
|
return .secondary
|
|
case .preparing:
|
|
return .orange
|
|
case .uploading:
|
|
return .blue
|
|
case .uploaded:
|
|
return .green
|
|
case .failed:
|
|
return .red
|
|
}
|
|
}
|
|
|
|
private var displayedPlaybackTime: Double {
|
|
scrubbedPlaybackTime ?? viewModel.nowPlayingState.currentTime
|
|
}
|
|
|
|
private var playbackDuration: Double {
|
|
max(
|
|
viewModel.nowPlayingState.duration,
|
|
viewModel.nowPlayingState.currentTrack?.durationSeconds ?? 0
|
|
)
|
|
}
|
|
|
|
private var miniPlayer: some View {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
HStack(alignment: .top) {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text(viewModel.nowPlayingState.currentTrack?.title ?? "No track selected")
|
|
.font(.headline)
|
|
|
|
Text(
|
|
viewModel.nowPlayingState.currentTrack?.artist
|
|
?? "Select a scanned MP3 to begin local playback."
|
|
)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
|
|
Spacer(minLength: 16)
|
|
|
|
if let playbackErrorMessage = viewModel.playbackErrorMessage {
|
|
Text(playbackErrorMessage)
|
|
.font(.caption)
|
|
.foregroundStyle(.red)
|
|
.multilineTextAlignment(.trailing)
|
|
}
|
|
}
|
|
|
|
HStack(spacing: 12) {
|
|
Button {
|
|
viewModel.toggleShuffle()
|
|
} label: {
|
|
Image(systemName: "shuffle")
|
|
}
|
|
.foregroundStyle(
|
|
viewModel.nowPlayingState.isShuffleEnabled
|
|
? Color.accentColor
|
|
: Color.secondary
|
|
)
|
|
.disabled(viewModel.tracks.isEmpty)
|
|
|
|
Button {
|
|
viewModel.playPreviousTrack()
|
|
} label: {
|
|
Image(systemName: "backward.fill")
|
|
}
|
|
.disabled(viewModel.tracks.isEmpty)
|
|
|
|
Button {
|
|
viewModel.togglePlayPause()
|
|
} label: {
|
|
Image(systemName: viewModel.nowPlayingState.isPlaying ? "pause.fill" : "play.fill")
|
|
}
|
|
.buttonStyle(.borderedProminent)
|
|
.disabled(viewModel.tracks.isEmpty)
|
|
|
|
Button {
|
|
viewModel.stopPlayback()
|
|
} label: {
|
|
Image(systemName: "stop.fill")
|
|
}
|
|
.disabled(viewModel.nowPlayingState.currentTrack == nil)
|
|
|
|
Button {
|
|
viewModel.playNextTrack()
|
|
} label: {
|
|
Image(systemName: "forward.fill")
|
|
}
|
|
.disabled(viewModel.tracks.isEmpty)
|
|
|
|
Button {
|
|
viewModel.cycleRepeatMode()
|
|
} label: {
|
|
Image(systemName: viewModel.repeatButtonSymbol)
|
|
}
|
|
.foregroundStyle(
|
|
viewModel.nowPlayingState.repeatMode == .off
|
|
? .secondary
|
|
: Color.accentColor
|
|
)
|
|
.disabled(viewModel.tracks.isEmpty)
|
|
}
|
|
.buttonStyle(.borderless)
|
|
|
|
HStack(spacing: 12) {
|
|
Text(format(durationSeconds: displayedPlaybackTime))
|
|
.font(.caption)
|
|
.monospacedDigit()
|
|
.foregroundStyle(.secondary)
|
|
|
|
Slider(
|
|
value: Binding(
|
|
get: {
|
|
displayedPlaybackTime
|
|
},
|
|
set: { newValue in
|
|
scrubbedPlaybackTime = newValue
|
|
}
|
|
),
|
|
in: 0...max(playbackDuration, 1),
|
|
onEditingChanged: { isEditing in
|
|
if !isEditing {
|
|
let targetTime = scrubbedPlaybackTime ?? viewModel.nowPlayingState.currentTime
|
|
scrubbedPlaybackTime = nil
|
|
viewModel.seekPlayback(to: targetTime)
|
|
}
|
|
}
|
|
)
|
|
.disabled(viewModel.nowPlayingState.currentTrack == nil || playbackDuration <= 0)
|
|
|
|
Text(format(durationSeconds: playbackDuration))
|
|
.font(.caption)
|
|
.monospacedDigit()
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
.padding(16)
|
|
.background(.quaternary.opacity(0.4), in: RoundedRectangle(cornerRadius: 16))
|
|
}
|
|
|
|
@ViewBuilder
|
|
private func statusRow(title: String, value: String) -> some View {
|
|
VStack(alignment: .leading, spacing: 2) {
|
|
Text(title)
|
|
.font(.headline)
|
|
Text(value)
|
|
.textSelection(.enabled)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
}
|