Home screen, library-wide search, splash with orbital particles, macOS download fix
- Add Netflix-style Home with recent audiobooks/podcasts, list/grid toggle shared with Library - Add searchable libraries — audiobooks filter on title/author; podcasts also index and search episodes across the whole library - Add unified context menu (download / progress remove) across Home, Library, and PodcastDetail - Rework cover badges: listened checkmark top-right, download indicator bottom-right; hide progress bar when finished - Add AirPlay picker in PlayerBar - Replace splash with four-phase animation: orbital particles → attract → flash → fade; cross-platform iOS+macOS, runtime AppIcon lookup - Fix macOS parallel downloads: align connection settings with iOS, raise timeoutIntervalForRequest to 300s - Fix progress deletion using server-side progress UUID (DELETE /api/me/progress/:id) - Bump CFBundleVersion to 8 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -8,6 +8,21 @@ struct PodcastDetailView: View {
|
||||
@State private var podcastDetail: LibraryItem?
|
||||
@State private var isLoading: Bool = true
|
||||
@State private var errorMessage: String?
|
||||
@State private var searchText: String = ""
|
||||
|
||||
/// Bevorzugt die nach `fetchEpisodes` aufgefrischte Detail-Version
|
||||
/// des Podcasts (mit Cover, Description etc.), fällt auf den initial
|
||||
/// übergebenen Container zurück solange der Fetch noch läuft oder
|
||||
/// fehlgeschlagen ist.
|
||||
private var resolvedPodcast: LibraryItem { podcastDetail ?? podcast }
|
||||
|
||||
private var filteredEpisodes: [PodcastEpisode] {
|
||||
let q = searchText.trimmingCharacters(in: .whitespacesAndNewlines)
|
||||
guard !q.isEmpty else { return episodes }
|
||||
return episodes.filter { ep in
|
||||
ep.title.localizedCaseInsensitiveContains(q)
|
||||
}
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
VStack(spacing: 0) {
|
||||
@@ -15,10 +30,11 @@ struct PodcastDetailView: View {
|
||||
Divider()
|
||||
content
|
||||
}
|
||||
.navigationTitle(podcastDetail?.title ?? podcast.title)
|
||||
.navigationTitle(resolvedPodcast.title)
|
||||
#if os(iOS)
|
||||
.navigationBarTitleDisplayMode(.inline)
|
||||
#endif
|
||||
.searchable(text: $searchText, placement: .automatic, prompt: "Folge suchen")
|
||||
.task { await load() }
|
||||
}
|
||||
|
||||
@@ -59,14 +75,16 @@ struct PodcastDetailView: View {
|
||||
ContentUnavailableView("Fehler", systemImage: "exclamationmark.triangle", description: Text(err))
|
||||
} else if episodes.isEmpty {
|
||||
ContentUnavailableView("Keine Folgen", systemImage: "music.note.list", description: Text("Dieser Podcast enthält noch keine Folgen."))
|
||||
} else if filteredEpisodes.isEmpty {
|
||||
ContentUnavailableView.search(text: searchText)
|
||||
} else {
|
||||
#if os(iOS)
|
||||
List {
|
||||
ForEach(episodes, id: \.id) { ep in
|
||||
EpisodeRow(podcast: podcastDetail ?? podcast, episode: ep)
|
||||
ForEach(filteredEpisodes, id: \.id) { ep in
|
||||
EpisodeRow(podcast: resolvedPodcast, episode: ep)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
Task { await app.play(podcast: podcastDetail ?? podcast, episode: ep) }
|
||||
Task { await app.play(podcast: resolvedPodcast, episode: ep) }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -74,13 +92,13 @@ struct PodcastDetailView: View {
|
||||
#else
|
||||
ScrollView {
|
||||
LazyVStack(spacing: 0) {
|
||||
ForEach(Array(episodes.enumerated()), id: \.element.id) { idx, ep in
|
||||
EpisodeRow(podcast: podcastDetail ?? podcast, episode: ep)
|
||||
ForEach(Array(filteredEpisodes.enumerated()), id: \.element.id) { idx, ep in
|
||||
EpisodeRow(podcast: resolvedPodcast, episode: ep)
|
||||
.contentShape(Rectangle())
|
||||
.onTapGesture {
|
||||
Task { await app.play(podcast: podcastDetail ?? podcast, episode: ep) }
|
||||
Task { await app.play(podcast: resolvedPodcast, episode: ep) }
|
||||
}
|
||||
if idx < episodes.count - 1 {
|
||||
if idx < filteredEpisodes.count - 1 {
|
||||
Divider().padding(.leading, 16)
|
||||
}
|
||||
}
|
||||
@@ -122,6 +140,10 @@ private struct EpisodeRow: View {
|
||||
return item
|
||||
}
|
||||
|
||||
private var isListened: Bool {
|
||||
app.progressCache[syntheticItem.syncKey]?.isFinished ?? false
|
||||
}
|
||||
|
||||
var body: some View {
|
||||
HStack(alignment: .top, spacing: 12) {
|
||||
Image(systemName: "play.circle.fill")
|
||||
@@ -163,7 +185,7 @@ private struct EpisodeRow: View {
|
||||
#endif
|
||||
}
|
||||
let frac = app.progressFraction(itemId: podcast.id, episodeId: episode.id)
|
||||
if frac > 0 {
|
||||
if frac > 0 && !isListened {
|
||||
GeometryReader { geo in
|
||||
ZStack(alignment: .leading) {
|
||||
RoundedRectangle(cornerRadius: 1.5).fill(Color.gray.opacity(0.3))
|
||||
@@ -179,6 +201,14 @@ private struct EpisodeRow: View {
|
||||
}
|
||||
}
|
||||
Spacer(minLength: 0)
|
||||
if isListened {
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
.foregroundStyle(.white, .green)
|
||||
.font(.title3)
|
||||
#if os(macOS)
|
||||
.padding(.top, 4)
|
||||
#endif
|
||||
}
|
||||
downloadButton
|
||||
#if os(macOS)
|
||||
.frame(width: 32)
|
||||
@@ -191,7 +221,7 @@ private struct EpisodeRow: View {
|
||||
#else
|
||||
.padding(.vertical, 4)
|
||||
#endif
|
||||
.contextMenu { contextMenuItems }
|
||||
.contextMenu { mediaItemContextMenu(for: syntheticItem, app: app) }
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
@@ -216,7 +246,9 @@ private struct EpisodeRow: View {
|
||||
.frame(width: 22, height: 22)
|
||||
.onTapGesture { app.downloads.cancel(downloadKey: key) }
|
||||
case .downloaded:
|
||||
Image(systemName: "checkmark.circle.fill")
|
||||
// Down-arrow = downloaded; checkmark is reserved for the listened
|
||||
// indicator that may sit next to this in the row trailing area.
|
||||
Image(systemName: "arrow.down.circle.fill")
|
||||
.foregroundStyle(.white, .green)
|
||||
.font(.title3)
|
||||
case .failed(let msg):
|
||||
@@ -232,32 +264,6 @@ private struct EpisodeRow: View {
|
||||
}
|
||||
}
|
||||
|
||||
@ViewBuilder
|
||||
private var contextMenuItems: some View {
|
||||
let key = syntheticItem.downloadKey
|
||||
let state = app.downloads.state(for: key)
|
||||
switch state {
|
||||
case .notDownloaded, .failed:
|
||||
Button {
|
||||
app.downloads.startDownload(item: syntheticItem)
|
||||
} label: {
|
||||
Label("Folge herunterladen", systemImage: "arrow.down.circle")
|
||||
}
|
||||
case .downloading:
|
||||
Button {
|
||||
app.downloads.cancel(downloadKey: key)
|
||||
} label: {
|
||||
Label("Download abbrechen", systemImage: "xmark.circle")
|
||||
}
|
||||
case .downloaded:
|
||||
Button(role: .destructive) {
|
||||
app.downloads.delete(downloadKey: key)
|
||||
} label: {
|
||||
Label("Heruntergeladene Folge löschen", systemImage: "trash")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func formatDuration(_ seconds: Double) -> String {
|
||||
guard seconds.isFinite, seconds > 0 else { return "" }
|
||||
let total = Int(seconds)
|
||||
|
||||
Reference in New Issue
Block a user