Commit afc94e95 authored by Kourser's avatar Kourser
Browse files

feat: a cross-podcast "à écouter plus tard", and history on the home screen

Two requests from the same reader. Favourites already existed per
episode, but only as a filter inside one podcast's page, so there was no
way to see everything you had starred; and history sat three taps deep in
the settings even though its whole point is picking an episode back up
quickly.

Both now live in the sidebar next to Nouveautés and the queue. The new
list is deliberately not a second queue: the queue is an ordered playlist
that empties as you listen, this is a pile you keep and pick from.

The store had no query for it — favoriteEpisodeIDs(in:) only answers for
a set you already hold — so favoriteEpisodes(limit:) joins the favourite
table and orders by publication date, that table carrying no timestamp of
its own. Covered by a test.

Co-Authored-By: default avatarClaude Opus 5 <noreply@anthropic.com>
parent e993e957
Loading
Loading
Loading
Loading
+7 −0
Original line number Diff line number Diff line
@@ -7,6 +7,13 @@ adopte le [versionnage sémantique](https://semver.org/lang/fr/).

## [Non publié]

### Ajouté
- **À écouter plus tard** : les épisodes mis en favori, toutes émissions
  confondues, dans une liste à part sur l'accueil. Les favoris n'étaient
  jusqu'ici qu'un filtre à l'intérieur de la fiche d'un podcast.
- **Historique sur l'accueil** : il quitte Réglages › Bibliothèque pour rejoindre
  la barre latérale, à un geste de la reprise d'un épisode en cours.

### Corrigé
- **iOS — affiche manquante dans le lecteur** : la plupart des flux ne publient
  d'affiche qu'au niveau du podcast, pas par épisode. Le lecteur et la barre de
+16 −0
Original line number Diff line number Diff line
@@ -14,6 +14,22 @@ extension LibraryStore {
        }
    }

    /// Every favourited episode across subscriptions, newest first — the
    /// "à écouter plus tard" list. The favourite table carries no timestamp, so
    /// the ordering is the publication date like everywhere else.
    public func favoriteEpisodes(limit: Int = 200) async throws -> [Episode] {
        try await dbQueue.read { db in
            try EpisodeRecord
                .fetchAll(db, sql: """
                    SELECT episode.* FROM episode
                    JOIN favorite ON favorite.episodeId = episode.id
                    ORDER BY episode.publicationDate DESC
                    LIMIT ?
                    """, arguments: [limit])
                .map { $0.toModel() }
        }
    }

    /// Episodes whose title matches `query` (case-insensitive), newest first.
    public func searchEpisodes(_ query: String, limit: Int = 50) async throws -> [Episode] {
        let trimmed = query.trimmingCharacters(in: .whitespacesAndNewlines)
+29 −0
Original line number Diff line number Diff line
@@ -21,4 +21,33 @@ import PodcastModel
        try await store.setFavorite(episodeID: eps[0].id, false)
        #expect(try await store.favoriteEpisodeIDs(in: eps.map(\.id)).isEmpty)
    }

    @Test func listsFavouritesNewestFirst() async throws {
        let store = try LibraryStore.inMemory()
        let stored = try await store.save(
            podcast: Podcast(feedURL: URL(string: "https://e.test/f.xml")!, title: "C"),
            episodes: [
                Episode(guid: "g0", title: "Ancien", publicationDate: Date(timeIntervalSince1970: 100),
                        enclosureURL: URL(string: "https://e.test/0.mp3")),
                Episode(guid: "g1", title: "Récent", publicationDate: Date(timeIntervalSince1970: 300),
                        enclosureURL: URL(string: "https://e.test/1.mp3")),
                Episode(guid: "g2", title: "Pas favori", publicationDate: Date(timeIntervalSince1970: 200),
                        enclosureURL: URL(string: "https://e.test/2.mp3")),
            ]
        )
        let eps = try await store.episodes(forPodcastID: stored.id)
        let old = try #require(eps.first { $0.title == "Ancien" })
        let recent = try #require(eps.first { $0.title == "Récent" })

        #expect(try await store.favoriteEpisodes().isEmpty)

        try await store.setFavorite(episodeID: old.id, true)
        try await store.setFavorite(episodeID: recent.id, true)
        #expect(try await store.favoriteEpisodes().map(\.title) == ["Récent", "Ancien"])

        #expect(try await store.favoriteEpisodes(limit: 1).map(\.title) == ["Récent"])

        try await store.setFavorite(episodeID: recent.id, false)
        #expect(try await store.favoriteEpisodes().map(\.title) == ["Ancien"])
    }
}
+4 −0
Original line number Diff line number Diff line
@@ -220,6 +220,10 @@ final class AppModel {
        inbox = (try? await store.recentEpisodes(limit: 100)) ?? []
    }

    func favoriteEpisodes() async -> [Episode] {
        (try? await store.favoriteEpisodes()) ?? []
    }

    func recentlyPlayed() async -> [Episode] {
        (try? await store.recentlyPlayed(limit: 100)) ?? []
    }
+34 −0
Original line number Diff line number Diff line
import SwiftUI
import PodcastModel

/// Favourited episodes across every subscription — the "à écouter plus tard"
/// shelf. Distinct from the queue on purpose: the queue is an ordered playlist
/// that empties as you listen, this is a pile you keep and pick from.
struct LaterView: View {
    @Environment(AppModel.self) private var model
    @State private var episodes: [Episode] = []

    var body: some View {
        List {
            if episodes.isEmpty {
                ContentUnavailableView(
                    "Rien en attente",
                    systemImage: "star",
                    description: Text("Les épisodes que vous mettez en favori se retrouvent ici, prêts à être écoutés plus tard.")
                )
                .frame(maxWidth: .infinity)
                .listRowBackground(Color.clear)
                .listRowSeparator(.hidden)
            } else {
                ForEach(episodes) { episode in
                    EpisodeListRow(episode: episode, showArtwork: true)
                }
            }
        }
        .navigationTitle("À écouter plus tard")
        .inlineNavigationTitle()
        // Keyed on the favourites themselves, so un-starring an episode from
        // this very list makes it leave straight away.
        .task(id: model.favorites) { episodes = await model.favoriteEpisodes() }
    }
}
Loading