Implement iPhone now playing experience
This commit is contained in:
@@ -6,246 +6,330 @@ import UIKit
|
||||
|
||||
struct iPhoneLibraryView: View {
|
||||
@State private var viewModel = iPhoneLibraryViewModel()
|
||||
@State private var scrubbedPlaybackTime: Double?
|
||||
|
||||
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)
|
||||
}
|
||||
ScrollView {
|
||||
VStack(alignment: .leading, spacing: 20) {
|
||||
titleSection
|
||||
syncSection
|
||||
searchSection
|
||||
|
||||
Spacer()
|
||||
|
||||
if let trackID = viewModel.nowPlaying.trackID {
|
||||
HStack(spacing: 12) {
|
||||
if viewModel.nowPlayingFavoriteTrackID == trackID {
|
||||
favoriteButton(isFavorite: viewModel.isNowPlayingTrackFavorite) {
|
||||
Task {
|
||||
await viewModel.toggleFavorite(trackID: trackID)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Button(viewModel.nowPlaying.isPlaying ? "Pause" : "Play") {
|
||||
viewModel.togglePlayback(trackID: trackID)
|
||||
}
|
||||
.buttonStyle(.borderedProminent)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let nowPlayingCard = viewModel.nowPlayingCard {
|
||||
nowPlayingCardView(nowPlayingCard)
|
||||
}
|
||||
|
||||
Section("Remote Library: \(viewModel.remoteTracks.count)") {
|
||||
if viewModel.remoteTracks.isEmpty {
|
||||
if viewModel.hasActiveSearch && viewModel.hasCachedRemoteTracks {
|
||||
Text("No matching tracks found.")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
}
|
||||
} else {
|
||||
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()
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Section("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 {
|
||||
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()
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.overlay {
|
||||
overlayView
|
||||
}
|
||||
.searchable(
|
||||
text: Binding(
|
||||
get: { viewModel.searchText },
|
||||
set: { viewModel.searchText = $0 }
|
||||
),
|
||||
placement: .navigationBarDrawer(displayMode: .always),
|
||||
prompt: "Search Library"
|
||||
)
|
||||
.autocorrectionDisabled()
|
||||
.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)
|
||||
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 var overlayView: some View {
|
||||
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 {
|
||||
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 .loading:
|
||||
ProgressView("Syncing remote library...")
|
||||
case .success:
|
||||
EmptyView()
|
||||
Text("Remote tracks will appear here after syncing.")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
case .empty:
|
||||
ContentUnavailableView(
|
||||
"Empty Remote Library",
|
||||
@@ -253,7 +337,11 @@ struct iPhoneLibraryView: View {
|
||||
description: Text("The backend returned no remote tracks for this iPhone.")
|
||||
)
|
||||
case .networkError(let message):
|
||||
if !viewModel.hasCachedRemoteTracks {
|
||||
if viewModel.hasCachedRemoteTracks {
|
||||
Text("Remote tracks will appear here after syncing.")
|
||||
.font(.subheadline)
|
||||
.foregroundStyle(.secondary)
|
||||
} else {
|
||||
ContentUnavailableView(
|
||||
"Network Error",
|
||||
systemImage: "wifi.exclamationmark",
|
||||
@@ -263,6 +351,91 @@ struct iPhoneLibraryView: View {
|
||||
}
|
||||
}
|
||||
|
||||
@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:
|
||||
@@ -290,6 +463,82 @@ struct iPhoneLibraryView: View {
|
||||
.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 {
|
||||
@@ -302,21 +551,19 @@ private struct ArtworkThumbnailView: View {
|
||||
.resizable()
|
||||
.scaledToFill()
|
||||
} else {
|
||||
ZStack {
|
||||
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||
.fill(Color.gray.opacity(0.14))
|
||||
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))
|
||||
.overlay(
|
||||
RoundedRectangle(cornerRadius: 10, style: .continuous)
|
||||
.stroke(Color.secondary.opacity(0.12), lineWidth: 1)
|
||||
)
|
||||
}
|
||||
|
||||
private var artworkImage: UIImage? {
|
||||
|
||||
@@ -2,11 +2,11 @@ import Foundation
|
||||
import Observation
|
||||
import VelodyDomain
|
||||
import VelodyNetworking
|
||||
import VelodyPlayback
|
||||
import VelodyPersistence
|
||||
import VelodySync
|
||||
import VelodyUtilities
|
||||
#if canImport(UIKit)
|
||||
import AVFoundation
|
||||
import UIKit
|
||||
#endif
|
||||
|
||||
@@ -15,154 +15,282 @@ protocol iPhoneLocalAudioPlaying: AnyObject {
|
||||
var onStateChange: ((iPhoneNowPlayingState) -> Void)? { get set }
|
||||
var state: iPhoneNowPlayingState { get }
|
||||
|
||||
func play(
|
||||
trackID: String,
|
||||
title: String,
|
||||
artist: String,
|
||||
fileURL: URL
|
||||
) throws
|
||||
func resume() throws
|
||||
func setCatalogTracks(_ tracks: [LibraryTrack])
|
||||
func play(trackID: String)
|
||||
func pause()
|
||||
func stop()
|
||||
func seek(to time: Double)
|
||||
}
|
||||
|
||||
enum iPhonePlaybackState: Equatable {
|
||||
case playing
|
||||
case paused
|
||||
case stopped
|
||||
case missingFile
|
||||
case failed
|
||||
|
||||
var displayText: String {
|
||||
switch self {
|
||||
case .playing:
|
||||
return "Playing"
|
||||
case .paused:
|
||||
return "Paused"
|
||||
case .stopped:
|
||||
return "Stopped"
|
||||
case .missingFile:
|
||||
return "Missing file"
|
||||
case .failed:
|
||||
return "Failed"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct iPhoneNowPlayingState: Equatable {
|
||||
var trackID: String?
|
||||
var title: String?
|
||||
var artist: String?
|
||||
var isPlaying: Bool
|
||||
var playbackState: iPhonePlaybackState
|
||||
var currentTime: Double
|
||||
var duration: Double
|
||||
var errorMessage: String?
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class iPhoneLocalAudioPlayer: NSObject, iPhoneLocalAudioPlaying, AVAudioPlayerDelegate {
|
||||
var onStateChange: ((iPhoneNowPlayingState) -> Void)?
|
||||
private(set) var state = iPhoneNowPlayingState(
|
||||
static let empty = iPhoneNowPlayingState(
|
||||
trackID: nil,
|
||||
title: nil,
|
||||
artist: nil,
|
||||
isPlaying: false,
|
||||
playbackState: .stopped,
|
||||
currentTime: 0,
|
||||
duration: 0,
|
||||
errorMessage: nil
|
||||
)
|
||||
|
||||
var isPlaying: Bool {
|
||||
playbackState == .playing
|
||||
}
|
||||
|
||||
var hasTrack: Bool {
|
||||
trackID != nil
|
||||
}
|
||||
|
||||
var canSeek: Bool {
|
||||
hasTrack && duration > 0 && playbackState != .missingFile && playbackState != .failed
|
||||
}
|
||||
}
|
||||
|
||||
private struct StorediPhonePlaybackSession: Codable {
|
||||
var currentTrackID: String?
|
||||
var currentTime: Double
|
||||
}
|
||||
|
||||
struct iPhonePlaybackSessionStore: PlaybackSessionStore, @unchecked Sendable {
|
||||
private let userDefaults: UserDefaults
|
||||
private let storageKey: String
|
||||
private let encoder = JSONEncoder()
|
||||
private let decoder = JSONDecoder()
|
||||
|
||||
init(
|
||||
userDefaults: UserDefaults = .standard,
|
||||
storageKey: String = "velody.iphone.playback.session"
|
||||
) {
|
||||
self.userDefaults = userDefaults
|
||||
self.storageKey = storageKey
|
||||
}
|
||||
|
||||
func loadSession() -> PlaybackSessionSnapshot? {
|
||||
guard let data = userDefaults.data(forKey: storageKey),
|
||||
let storedSession = try? decoder.decode(StorediPhonePlaybackSession.self, from: data)
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
|
||||
return PlaybackSessionSnapshot(
|
||||
queueTrackIDs: [],
|
||||
currentTrackID: storedSession.currentTrackID,
|
||||
currentTime: storedSession.currentTime,
|
||||
isShuffleEnabled: false,
|
||||
repeatMode: .off
|
||||
)
|
||||
}
|
||||
|
||||
func saveSession(_ session: PlaybackSessionSnapshot) {
|
||||
guard let currentTrackID = session.currentTrackID,
|
||||
!currentTrackID.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty
|
||||
else {
|
||||
clearSession()
|
||||
return
|
||||
}
|
||||
|
||||
let storedSession = StorediPhonePlaybackSession(
|
||||
currentTrackID: currentTrackID,
|
||||
currentTime: session.currentTime
|
||||
)
|
||||
|
||||
guard let data = try? encoder.encode(storedSession) else {
|
||||
return
|
||||
}
|
||||
|
||||
userDefaults.set(data, forKey: storageKey)
|
||||
}
|
||||
|
||||
func clearSession() {
|
||||
userDefaults.removeObject(forKey: storageKey)
|
||||
}
|
||||
}
|
||||
|
||||
@MainActor
|
||||
final class iPhonePlaybackControllerPlayer: iPhoneLocalAudioPlaying {
|
||||
var onStateChange: ((iPhoneNowPlayingState) -> Void)?
|
||||
|
||||
private(set) var state: iPhoneNowPlayingState = .empty {
|
||||
didSet {
|
||||
onStateChange?(state)
|
||||
}
|
||||
}
|
||||
|
||||
private var audioPlayer: AVAudioPlayer?
|
||||
private let controller: PlaybackController
|
||||
private var idleStateHint: iPhonePlaybackState = .stopped
|
||||
|
||||
func play(
|
||||
trackID: String,
|
||||
title: String,
|
||||
artist: String,
|
||||
fileURL: URL
|
||||
) throws {
|
||||
guard FileManager.default.fileExists(atPath: fileURL.path) else {
|
||||
state = iPhoneNowPlayingState(
|
||||
trackID: trackID,
|
||||
title: title,
|
||||
artist: artist,
|
||||
isPlaying: false,
|
||||
errorMessage: "The downloaded file could not be found."
|
||||
)
|
||||
throw NSError(
|
||||
domain: "VelodyiPhonePlayback",
|
||||
code: 1,
|
||||
userInfo: [NSLocalizedDescriptionKey: "The downloaded file could not be found."]
|
||||
)
|
||||
}
|
||||
|
||||
do {
|
||||
try configureAudioSession()
|
||||
audioPlayer?.stop()
|
||||
let audioPlayer = try AVAudioPlayer(contentsOf: fileURL)
|
||||
audioPlayer.delegate = self
|
||||
audioPlayer.prepareToPlay()
|
||||
self.audioPlayer = audioPlayer
|
||||
|
||||
guard audioPlayer.play() else {
|
||||
state = iPhoneNowPlayingState(
|
||||
trackID: trackID,
|
||||
title: title,
|
||||
artist: artist,
|
||||
isPlaying: false,
|
||||
errorMessage: "Playback could not be started."
|
||||
)
|
||||
throw NSError(
|
||||
domain: "VelodyiPhonePlayback",
|
||||
code: 2,
|
||||
userInfo: [NSLocalizedDescriptionKey: "Playback could not be started."]
|
||||
)
|
||||
}
|
||||
|
||||
state = iPhoneNowPlayingState(
|
||||
trackID: trackID,
|
||||
title: title,
|
||||
artist: artist,
|
||||
isPlaying: true,
|
||||
errorMessage: nil
|
||||
)
|
||||
} catch {
|
||||
if state.trackID == nil {
|
||||
state = iPhoneNowPlayingState(
|
||||
trackID: trackID,
|
||||
title: title,
|
||||
artist: artist,
|
||||
isPlaying: false,
|
||||
errorMessage: "The downloaded audio file could not be opened."
|
||||
)
|
||||
}
|
||||
throw error
|
||||
init(
|
||||
controller: PlaybackController? = nil
|
||||
) {
|
||||
self.controller = controller ?? PlaybackController(
|
||||
sessionStore: iPhonePlaybackSessionStore()
|
||||
)
|
||||
self.state = Self.makeState(
|
||||
from: self.controller.nowPlayingState,
|
||||
idleStateHint: .stopped
|
||||
)
|
||||
self.controller.onStateChange = { [weak self] state in
|
||||
self?.apply(state)
|
||||
}
|
||||
}
|
||||
|
||||
func resume() throws {
|
||||
guard let audioPlayer else {
|
||||
state.errorMessage = "No downloaded track is loaded."
|
||||
throw NSError(
|
||||
domain: "VelodyiPhonePlayback",
|
||||
code: 3,
|
||||
userInfo: [NSLocalizedDescriptionKey: "No downloaded track is loaded."]
|
||||
)
|
||||
}
|
||||
func setCatalogTracks(_ tracks: [LibraryTrack]) {
|
||||
controller.setCatalogTracks(tracks)
|
||||
apply(controller.nowPlayingState)
|
||||
}
|
||||
|
||||
guard audioPlayer.play() else {
|
||||
state.errorMessage = "Playback could not be resumed."
|
||||
throw NSError(
|
||||
domain: "VelodyiPhonePlayback",
|
||||
code: 4,
|
||||
userInfo: [NSLocalizedDescriptionKey: "Playback could not be resumed."]
|
||||
)
|
||||
}
|
||||
|
||||
state.isPlaying = true
|
||||
state.errorMessage = nil
|
||||
func play(trackID: String) {
|
||||
idleStateHint = .paused
|
||||
controller.play(trackID: trackID)
|
||||
apply(controller.nowPlayingState)
|
||||
}
|
||||
|
||||
func pause() {
|
||||
audioPlayer?.pause()
|
||||
state.isPlaying = false
|
||||
idleStateHint = .paused
|
||||
controller.pause()
|
||||
apply(controller.nowPlayingState)
|
||||
}
|
||||
|
||||
nonisolated func audioPlayerDidFinishPlaying(
|
||||
_ player: AVAudioPlayer,
|
||||
successfully flag: Bool
|
||||
) {
|
||||
guard flag else {
|
||||
return
|
||||
func stop() {
|
||||
idleStateHint = .stopped
|
||||
controller.stop()
|
||||
apply(controller.nowPlayingState)
|
||||
}
|
||||
|
||||
func seek(to time: Double) {
|
||||
controller.seek(to: time)
|
||||
apply(controller.nowPlayingState)
|
||||
}
|
||||
|
||||
private func apply(_ nowPlayingState: NowPlayingState) {
|
||||
let effectiveDuration = max(
|
||||
nowPlayingState.duration,
|
||||
nowPlayingState.currentTrack?.durationSeconds ?? 0
|
||||
)
|
||||
|
||||
if nowPlayingState.isPlaying {
|
||||
idleStateHint = .paused
|
||||
} else if nowPlayingState.currentTrack == nil {
|
||||
idleStateHint = .stopped
|
||||
} else if effectiveDuration > 0,
|
||||
nowPlayingState.currentTime >= max(effectiveDuration - 0.25, 0) {
|
||||
idleStateHint = .stopped
|
||||
}
|
||||
|
||||
Task { @MainActor [weak self] in
|
||||
self?.state.isPlaying = false
|
||||
}
|
||||
state = Self.makeState(
|
||||
from: nowPlayingState,
|
||||
idleStateHint: idleStateHint
|
||||
)
|
||||
}
|
||||
|
||||
private func configureAudioSession() throws {
|
||||
let session = AVAudioSession.sharedInstance()
|
||||
try session.setCategory(.playback, mode: .default)
|
||||
try session.setActive(true)
|
||||
private static func makeState(
|
||||
from nowPlayingState: NowPlayingState,
|
||||
idleStateHint: iPhonePlaybackState
|
||||
) -> iPhoneNowPlayingState {
|
||||
let effectiveDuration = max(
|
||||
nowPlayingState.duration,
|
||||
nowPlayingState.currentTrack?.durationSeconds ?? 0
|
||||
)
|
||||
let playbackState: iPhonePlaybackState
|
||||
|
||||
if let playbackError = nowPlayingState.error {
|
||||
switch playbackError {
|
||||
case .missingLocalFile:
|
||||
playbackState = .missingFile
|
||||
default:
|
||||
playbackState = .failed
|
||||
}
|
||||
} else if nowPlayingState.isPlaying {
|
||||
playbackState = .playing
|
||||
} else if nowPlayingState.currentTrack != nil {
|
||||
if nowPlayingState.currentTime > 0,
|
||||
effectiveDuration > 0,
|
||||
nowPlayingState.currentTime < effectiveDuration {
|
||||
playbackState = .paused
|
||||
} else {
|
||||
playbackState = idleStateHint
|
||||
}
|
||||
} else {
|
||||
playbackState = .stopped
|
||||
}
|
||||
|
||||
return iPhoneNowPlayingState(
|
||||
trackID: nowPlayingState.currentTrackID,
|
||||
title: nowPlayingState.currentTrack?.title,
|
||||
artist: nowPlayingState.currentTrack?.artist,
|
||||
playbackState: playbackState,
|
||||
currentTime: nowPlayingState.currentTime,
|
||||
duration: effectiveDuration,
|
||||
errorMessage: nowPlayingState.error?.localizedDescription
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
enum NowPlayingDownloadBadge: Equatable {
|
||||
case downloaded
|
||||
case missing
|
||||
case offline
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
case .downloaded:
|
||||
return "Downloaded"
|
||||
case .missing:
|
||||
return "Missing"
|
||||
case .offline:
|
||||
return "Offline"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
struct NowPlayingCardViewData: Equatable {
|
||||
let trackID: String
|
||||
let title: String
|
||||
let artist: String
|
||||
let artworkLocalFilePath: String?
|
||||
let playbackStateText: String
|
||||
let downloadBadge: NowPlayingDownloadBadge
|
||||
let currentTime: Double
|
||||
let duration: Double
|
||||
let currentTimeText: String
|
||||
let durationText: String
|
||||
let progress: Double
|
||||
let isPlaying: Bool
|
||||
let canSeek: Bool
|
||||
let errorMessage: String?
|
||||
}
|
||||
|
||||
@MainActor
|
||||
@Observable
|
||||
final class iPhoneLibraryViewModel {
|
||||
@@ -187,13 +315,7 @@ final class iPhoneLibraryViewModel {
|
||||
}
|
||||
var syncStatus = "Remote library not synced yet."
|
||||
var state: ViewState = .idle
|
||||
var nowPlaying = iPhoneNowPlayingState(
|
||||
trackID: nil,
|
||||
title: nil,
|
||||
artist: nil,
|
||||
isPlaying: false,
|
||||
errorMessage: nil
|
||||
)
|
||||
var nowPlaying: iPhoneNowPlayingState = .empty
|
||||
var nowPlayingFavoriteTrackID: String?
|
||||
var isNowPlayingTrackFavorite = false
|
||||
|
||||
@@ -218,6 +340,60 @@ final class iPhoneLibraryViewModel {
|
||||
!cachedRemoteLibraryTracks.isEmpty
|
||||
}
|
||||
|
||||
var nowPlayingCard: NowPlayingCardViewData? {
|
||||
guard let trackID = nowPlaying.trackID,
|
||||
let title = nowPlaying.title
|
||||
else {
|
||||
return nil
|
||||
}
|
||||
|
||||
let remoteTrack = cachedRemoteLibraryTracks.first(where: {
|
||||
$0.remoteTrack.trackId == trackID
|
||||
})
|
||||
let fallbackOfflineTrack = cachedAvailableOfflineTracks.first(where: {
|
||||
$0.remoteTrackId == trackID
|
||||
})
|
||||
let artist = nowPlaying.artist
|
||||
?? remoteTrack?.remoteTrack.artist
|
||||
?? fallbackOfflineTrack?.artist
|
||||
?? "Unknown Artist"
|
||||
let duration = max(
|
||||
nowPlaying.duration,
|
||||
Double(remoteTrack?.remoteTrack.durationSeconds ?? fallbackOfflineTrack?.durationSeconds ?? 0)
|
||||
)
|
||||
let clampedCurrentTime: Double
|
||||
|
||||
if duration > 0 {
|
||||
clampedCurrentTime = min(max(nowPlaying.currentTime, 0), duration)
|
||||
} else {
|
||||
clampedCurrentTime = max(nowPlaying.currentTime, 0)
|
||||
}
|
||||
let progress: Double
|
||||
|
||||
if duration > 0 {
|
||||
progress = min(max(clampedCurrentTime / duration, 0), 1)
|
||||
} else {
|
||||
progress = 0
|
||||
}
|
||||
|
||||
return NowPlayingCardViewData(
|
||||
trackID: trackID,
|
||||
title: title,
|
||||
artist: artist,
|
||||
artworkLocalFilePath: remoteTrack?.localArtworkFilePath ?? fallbackOfflineTrack?.localArtworkFilePath,
|
||||
playbackStateText: nowPlaying.playbackState.displayText,
|
||||
downloadBadge: nowPlayingDownloadBadge(for: trackID),
|
||||
currentTime: clampedCurrentTime,
|
||||
duration: duration,
|
||||
currentTimeText: Self.formatPlaybackTime(clampedCurrentTime),
|
||||
durationText: Self.formatPlaybackTime(duration),
|
||||
progress: progress,
|
||||
isPlaying: nowPlaying.isPlaying,
|
||||
canSeek: nowPlaying.canSeek && duration > 0,
|
||||
errorMessage: nowPlaying.errorMessage
|
||||
)
|
||||
}
|
||||
|
||||
convenience init(
|
||||
player: (any iPhoneLocalAudioPlaying)? = nil,
|
||||
keychainService: any KeychainService = SystemKeychainService(
|
||||
@@ -256,7 +432,7 @@ final class iPhoneLibraryViewModel {
|
||||
syncService: syncService,
|
||||
offlineLibraryService: offlineLibraryService,
|
||||
favoriteTrackStore: favoriteTrackStore,
|
||||
player: player ?? iPhoneLocalAudioPlayer(),
|
||||
player: player ?? iPhonePlaybackControllerPlayer(),
|
||||
keychainService: keychainService
|
||||
)
|
||||
}
|
||||
@@ -277,6 +453,7 @@ final class iPhoneLibraryViewModel {
|
||||
self.favoriteTrackStore = favoriteTrackStore
|
||||
self.keychainService = keychainService
|
||||
self.player = player
|
||||
self.nowPlaying = player.state
|
||||
self.player.onStateChange = { [weak self] state in
|
||||
self?.handleNowPlayingStateChange(state)
|
||||
}
|
||||
@@ -356,40 +533,25 @@ final class iPhoneLibraryViewModel {
|
||||
return
|
||||
}
|
||||
|
||||
if nowPlaying.trackID == track.trackId, !nowPlaying.isPlaying {
|
||||
do {
|
||||
try player.resume()
|
||||
} catch {
|
||||
syncStatus = "Playback failed for \(track.title): \(error.localizedDescription)"
|
||||
refreshOfflineLibraryInBackground()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
guard let offlineTrack = cachedAvailableOfflineTracks
|
||||
.first(where: { $0.remoteTrackId == track.trackId })
|
||||
else {
|
||||
if cachedRemoteLibraryTracks.first(where: { $0.remoteTrack.trackId == track.trackId })?.status == .missing {
|
||||
guard canAttemptPlayback(for: track.trackId) else {
|
||||
if remoteTrackStatus(for: track.trackId) == .missing {
|
||||
syncStatus = "The downloaded file for \(track.title) is missing."
|
||||
} else {
|
||||
syncStatus = "Download the track before playing it offline."
|
||||
}
|
||||
refreshOfflineLibraryInBackground()
|
||||
return
|
||||
}
|
||||
|
||||
let fileURL = URL(fileURLWithPath: offlineTrack.localFilePath)
|
||||
do {
|
||||
try player.play(
|
||||
trackID: track.trackId,
|
||||
title: track.title,
|
||||
artist: track.artist,
|
||||
fileURL: fileURL
|
||||
)
|
||||
} catch {
|
||||
syncStatus = "Playback failed for \(track.title): \(error.localizedDescription)"
|
||||
refreshOfflineLibraryInBackground()
|
||||
}
|
||||
player.play(trackID: track.trackId)
|
||||
handlePlaybackResult(for: track)
|
||||
}
|
||||
|
||||
func stopPlayback() {
|
||||
player.stop()
|
||||
}
|
||||
|
||||
func seekPlayback(to time: Double) {
|
||||
player.seek(to: time)
|
||||
}
|
||||
|
||||
func toggleFavorite(trackID: String) async {
|
||||
@@ -521,6 +683,7 @@ final class iPhoneLibraryViewModel {
|
||||
cachedRemoteTracksByID = Dictionary(
|
||||
uniqueKeysWithValues: snapshot.remoteTracks.map { ($0.remoteTrack.trackId, $0.remoteTrack) }
|
||||
)
|
||||
player.setCatalogTracks(Self.makePlaybackCatalog(from: snapshot.remoteTracks))
|
||||
rebuildRows()
|
||||
|
||||
return snapshot
|
||||
@@ -561,6 +724,45 @@ final class iPhoneLibraryViewModel {
|
||||
rebuildRows()
|
||||
}
|
||||
|
||||
private func handlePlaybackResult(for track: RemoteTrack) {
|
||||
switch nowPlaying.playbackState {
|
||||
case .missingFile:
|
||||
syncStatus = "The downloaded file for \(track.title) is missing."
|
||||
refreshOfflineLibraryInBackground()
|
||||
case .failed:
|
||||
syncStatus = "Playback failed for \(track.title): \(nowPlaying.errorMessage ?? "Unknown error.")"
|
||||
case .playing, .paused, .stopped:
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
private func canAttemptPlayback(for trackID: String) -> Bool {
|
||||
guard let remoteTrack = cachedRemoteLibraryTracks.first(where: {
|
||||
$0.remoteTrack.trackId == trackID
|
||||
}) else {
|
||||
return false
|
||||
}
|
||||
|
||||
return remoteTrack.isFileAvailable || remoteTrack.status == .missing
|
||||
}
|
||||
|
||||
private func remoteTrackStatus(for trackID: String) -> OfflineLibraryRemoteTrackStatus? {
|
||||
cachedRemoteLibraryTracks.first(where: {
|
||||
$0.remoteTrack.trackId == trackID
|
||||
})?.status
|
||||
}
|
||||
|
||||
private func nowPlayingDownloadBadge(for trackID: String) -> NowPlayingDownloadBadge {
|
||||
switch remoteTrackStatus(for: trackID) {
|
||||
case .downloaded:
|
||||
return .downloaded
|
||||
case .missing:
|
||||
return .missing
|
||||
default:
|
||||
return .offline
|
||||
}
|
||||
}
|
||||
|
||||
private func updateRemoteTrack(
|
||||
trackID: String,
|
||||
transform: (OfflineLibraryRemoteTrack) -> OfflineLibraryRemoteTrack
|
||||
@@ -588,6 +790,32 @@ final class iPhoneLibraryViewModel {
|
||||
}
|
||||
}
|
||||
|
||||
private static func makePlaybackCatalog(
|
||||
from tracks: [OfflineLibraryRemoteTrack]
|
||||
) -> [LibraryTrack] {
|
||||
tracks.map { track in
|
||||
LibraryTrack(
|
||||
id: track.remoteTrack.trackId,
|
||||
title: track.remoteTrack.title,
|
||||
artist: track.remoteTrack.artist,
|
||||
durationSeconds: Double(track.remoteTrack.durationSeconds),
|
||||
localFilePath: track.localFilePath,
|
||||
remoteTrackId: track.remoteTrack.trackId
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private static func formatPlaybackTime(_ seconds: Double) -> String {
|
||||
guard seconds.isFinite, seconds > 0 else {
|
||||
return "0:00"
|
||||
}
|
||||
|
||||
let totalSeconds = Int(seconds.rounded(.towardZero))
|
||||
let minutes = totalSeconds / 60
|
||||
let remainingSeconds = totalSeconds % 60
|
||||
return "\(minutes):\(String(format: "%02d", remainingSeconds))"
|
||||
}
|
||||
|
||||
#if canImport(UIKit)
|
||||
private static var currentDeviceName: String {
|
||||
UIDevice.current.name
|
||||
|
||||
Reference in New Issue
Block a user