Commit 16ea251e authored by Kourser's avatar Kourser
Browse files

fix: stop blanking a loaded list when its query is cancelled

An episode swiped into "à écouter plus tard" was not there when you
opened the list. The model had it — the log showed listenLater=1 — and
then the screen's own .task overwrote it with nothing.

The cause is that `(try? await …) ?? []` cannot tell "there is nothing"
from "this read did not finish". SwiftUI cancels a .task while the
navigation push animates, the cancellation propagates into the query, and
the empty fallback then replaced a correct list. loadInbox and loadQueue
had the same shape, which is very likely what the reader meant by
"Nouveautés shows nothing": the earlier fix let them refresh out of it,
but this is why they went empty in the first place.

Each loader now keeps what it has when the read fails, and the list lives
on the model rather than in the view — loading it also refreshes
`favorites`, so keying a .task on that set made the view restart itself on
its own side effect.

Verified on an iOS 26 simulator, walking the reported path: swipe, add,
go back, open the list.

Co-Authored-By: default avatarClaude Opus 5 <noreply@anthropic.com>
parent 97f20e23
Loading
Loading
Loading
Loading
+5 −0
Original line number Diff line number Diff line
@@ -19,6 +19,11 @@ adopte le [versionnage sémantique](https://semver.org/lang/fr/).
  ne disait pas à quoi servait la liste.

### Corrigé
- **Listes vidées par une lecture annulée** : un épisode mis de côté
  n'apparaissait pas dans « À écouter plus tard ». La requête était annulée avec
  l'animation de navigation, et une lecture annulée était prise pour une liste
  vide, qui écrasait la bonne. Même cause pour « Nouveautés » et la file
  d'attente : elles gardent maintenant leur contenu quand une lecture échoue.
- **État des épisodes dans les listes transversales** : dans « Nouveautés », la
  file d'attente et « À écouter plus tard », l'étoile et la barre de progression
  s'affichaient toujours à l'état neuf. Seule la fiche d'un podcast chargeait
+24 −8
Original line number Diff line number Diff line
@@ -17,6 +17,7 @@ final class AppModel {
    private(set) var playStates: [UUID: PlayState] = [:]
    private(set) var favorites: Set<UUID> = []
    private(set) var inbox: [Episode] = []
    private(set) var listenLater: [Episode] = []
    private(set) var podcastTags: [UUID: [String]] = [:]
    private(set) var currentChapters: [Chapter] = []
    var errorMessage: String?
@@ -217,14 +218,22 @@ final class AppModel {
    // MARK: Browse / search / tags

    func loadInbox() async {
        inbox = (try? await store.recentEpisodes(limit: 100)) ?? []
        guard let episodes = try? await store.recentEpisodes(limit: 100) else { return }
        inbox = episodes
        await loadPlayStates(for: inbox)
    }

    func favoriteEpisodes() async -> [Episode] {
        let episodes = (try? await store.favoriteEpisodes()) ?? []
        await loadPlayStates(for: episodes)
        return episodes
    /// Loads the "à écouter plus tard" list. It lives on the model, like the
    /// inbox and the queue, rather than in the view: loading it also refreshes
    /// `favorites`, so a view keying a `.task(id:)` on that set would cancel and
    /// restart itself on its own side effect.
    func loadListenLater() async {
        // Keep what is already loaded when the read fails. A `.task` cancelled
        // by the navigation push cancels the query with it, and blanking the
        // list on that was what emptied this screen right after adding to it.
        guard let episodes = try? await store.favoriteEpisodes() else { return }
        listenLater = episodes
        await loadPlayStates(for: listenLater)
    }

    func recentlyPlayed() async -> [Episode] {
@@ -539,6 +548,7 @@ final class AppModel {
        playStates = [:]
        favorites = []
        inbox = []
        listenLater = []
        podcastTags = [:]
        currentChapters = []
        await load()
@@ -593,9 +603,13 @@ final class AppModel {
    }

    func loadPlayStates(for episodes: [Episode]) async {
        let map = (try? await store.playStates(forEpisodeIDs: episodes.map(\.id))) ?? [:]
        let ids = episodes.map(\.id)
        if let map = try? await store.playStates(forEpisodeIDs: ids) {
            for (id, state) in map { playStates[id] = state }
        let favs = (try? await store.favoriteEpisodeIDs(in: episodes.map(\.id))) ?? []
        }
        // A read that fails — or is cancelled — is not "none of these are in the
        // list". Treating it as such wiped the set and emptied the screen.
        guard let favs = try? await store.favoriteEpisodeIDs(in: ids) else { return }
        for episode in episodes {
            if favs.contains(episode.id) { favorites.insert(episode.id) }
            else { favorites.remove(episode.id) }
@@ -610,6 +624,7 @@ final class AppModel {
        let makeFavorite = !favorites.contains(episode.id)
        if makeFavorite { favorites.insert(episode.id) } else { favorites.remove(episode.id) }
        try? await store.setFavorite(episodeID: episode.id, makeFavorite)
        await loadListenLater()
    }

    /// Pushes the current equalizer settings to live playback (downloaded files).
@@ -696,7 +711,8 @@ final class AppModel {
    // MARK: Queue

    func loadQueue() async {
        queue = (try? await store.queuedEpisodes()) ?? []
        guard let episodes = try? await store.queuedEpisodes() else { return }
        queue = episodes
        await loadPlayStates(for: queue)
    }

+5 −6
Original line number Diff line number Diff line
@@ -6,11 +6,10 @@ import PodcastModel
/// 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 {
            if model.listenLater.isEmpty {
                ContentUnavailableView(
                    "Rien en attente",
                    systemImage: "star",
@@ -20,15 +19,15 @@ struct LaterView: View {
                .listRowBackground(Color.clear)
                .listRowSeparator(.hidden)
            } else {
                ForEach(episodes) { episode in
                ForEach(model.listenLater) { 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() }
        // The model refreshes this list on every toggle, so un-starring an
        // episode — here or anywhere else — makes it leave straight away.
        .task { await model.loadListenLater() }
    }
}