Commit 8dcbe9fa authored by Kourser's avatar Kourser
Browse files

feat: unsubscribe, duplicate badges & inbox artwork; fix macOS search/detail



- macOS: fix crash when opening a podcast — two `.searchable` modifiers
  collided in the shared NavigationSplitView toolbar ("duplicate toolbar
  item"). The detail episode search is now inline on macOS; iOS keeps
  `.searchable`.
- macOS: fix the add-podcast search sheet — the source picker was invisible
  (`.principal` does not render in a sheet) and the sheet had no size.
  Replaced with an explicit header (picker + field + close) and a sized
  window. iOS unchanged.
- Subscriptions: right-click / long-press → "Se désabonner" with a
  confirmation, giving macOS the delete affordance it lacked.
- Subscriptions: flag same-title duplicates with an orange "Doublon" badge.
- Nouveautés: show each row's podcast thumbnail via a new optional
  Episode.podcastId carrying the episode→podcast link from storage.

Co-Authored-By: default avatarClaude <claude@anthropic.com>
parent 5d313810
Loading
Loading
Loading
Loading
Loading
+6 −0
Original line number Diff line number Diff line
@@ -8,6 +8,10 @@ public struct Episode: Identifiable, Hashable, Sendable, Codable {
    /// Locally assigned identity. The feed's own identity is ``guid``.
    public var id: UUID

    /// The owning podcast's local id (``Podcast/id``). Set when loaded from
    /// storage; `nil` for freshly parsed, not-yet-persisted episodes.
    public var podcastId: UUID?

    /// The `<guid>` value, falling back to the enclosure URL when absent.
    /// Used to de-duplicate episodes across feed refreshes.
    public var guid: String?
@@ -40,6 +44,7 @@ public struct Episode: Identifiable, Hashable, Sendable, Codable {

    public init(
        id: UUID = UUID(),
        podcastId: UUID? = nil,
        guid: String? = nil,
        title: String,
        summary: String? = nil,
@@ -57,6 +62,7 @@ public struct Episode: Identifiable, Hashable, Sendable, Codable {
        transcriptURL: URL? = nil
    ) {
        self.id = id
        self.podcastId = podcastId
        self.guid = guid
        self.title = title
        self.summary = summary
+1 −0
Original line number Diff line number Diff line
@@ -104,6 +104,7 @@ extension EpisodeRecord {
    func toModel() -> Episode {
        Episode(
            id: UUID(uuidString: id) ?? UUID(),
            podcastId: UUID(uuidString: podcastId),
            guid: guid,
            title: title,
            summary: summary,
+10 −0
Original line number Diff line number Diff line
@@ -244,6 +244,16 @@ final class AppModel {
        (try? await store.episodes(forPodcastID: podcast.id)) ?? []
    }

    /// Artwork to show for an episode in cross-podcast lists (the inbox): the
    /// owning podcast's image, falling back to the episode's own image.
    func artworkURL(for episode: Episode) -> URL? {
        if let id = episode.podcastId,
           let podcast = podcasts.first(where: { $0.id == id }) {
            return podcast.imageURL ?? episode.imageURL
        }
        return episode.imageURL
    }

    func isSubscribed(feedURL: URL) -> Bool {
        podcasts.contains { $0.feedURL == feedURL }
    }
+42 −0
Original line number Diff line number Diff line
@@ -59,8 +59,40 @@ struct ScrubBar: View {
    }
}

#if os(macOS)
/// Inline search field for macOS. A `NavigationSplitView` shares one window
/// toolbar, which can hold only a single `.searchable` field; secondary searches
/// (e.g. inside the detail pane) must be inline to avoid a duplicate-toolbar-item
/// crash. On iOS each `NavigationStack` keeps its own `.searchable` bar.
struct MacSearchField: View {
    let prompt: String
    @Binding var text: String
    var onSubmit: () -> Void = {}

    var body: some View {
        HStack(spacing: 6) {
            Image(systemName: "magnifyingglass").foregroundStyle(.secondary)
            TextField(prompt, text: $text)
                .textFieldStyle(.plain)
                .onSubmit(onSubmit)
            if !text.isEmpty {
                Button { text = "" } label: {
                    Image(systemName: "xmark.circle.fill")
                }
                .buttonStyle(.plain)
                .foregroundStyle(.secondary)
            }
        }
        .padding(8)
        .background(.quaternary, in: RoundedRectangle(cornerRadius: 8))
    }
}
#endif

struct PodcastRow: View {
    let podcast: Podcast
    /// Flags subscriptions sharing a title with another, so duplicates stand out.
    var isDuplicate: Bool = false

    var body: some View {
        HStack(spacing: 12) {
@@ -76,6 +108,16 @@ struct PodcastRow: View {
                        .lineLimit(1)
                }
            }
            if isDuplicate {
                Spacer(minLength: 8)
                Label("Doublon", systemImage: "exclamationmark.triangle.fill")
                    .font(.caption2.weight(.semibold))
                    .foregroundStyle(.white)
                    .padding(.horizontal, 7)
                    .padding(.vertical, 3)
                    .background(.orange, in: Capsule())
                    .help("Même titre qu'un autre abonnement")
            }
        }
        .padding(.vertical, 4)
    }
+6 −0
Original line number Diff line number Diff line
@@ -9,10 +9,16 @@ struct EpisodeListRow: View {
    @Environment(DownloadController.self) private var downloads
    @Environment(PlaybackController.self) private var playback
    let episode: Episode
    /// Shows the owning podcast's thumbnail (used in cross-podcast lists like
    /// the inbox); off in single-podcast contexts where it would be redundant.
    var showArtwork = false
    @State private var showingNotes = false

    var body: some View {
        HStack(spacing: 12) {
            if showArtwork {
                ArtworkView(url: model.artworkURL(for: episode), size: 44)
            }
            VStack(alignment: .leading, spacing: 4) {
                HStack(spacing: 6) {
                    if isCurrent {
Loading