Commit 4c2bc92e authored by Kourser's avatar Kourser
Browse files

Play state: local persistence/resume + episode-action sync



- PlayState model + StorageKit playstate table (migration v3): save/read
  position and played flag, batch fetch, lookup by enclosure URL, and a join
  (EpisodeProgress) for sync.
- Playback: resume from saved position (PlaybackController.startAt); persist
  position every 10s while playing; mark played on finish.
- App: manual mark played/unplayed, "Lu"/resume indicators in episode rows.
- SyncKit: EpisodeAction API on both providers; syncNow now also pushes local
  play progress and applies remote play positions (separate timestamp).

45 package tests green; iOS + macOS build; simulators launch clean.

Co-Authored-By: default avatarClaude <claude@anthropic.com>
parent f3b4f603
Loading
Loading
Loading
Loading
+89 −5
Original line number Diff line number Diff line
@@ -14,6 +14,7 @@ import SyncKit
final class AppModel {
    private(set) var podcasts: [Podcast] = []
    private(set) var queue: [Episode] = []
    private(set) var playStates: [UUID: PlayState] = [:]
    var errorMessage: String?
    var isAdding = false
    var isSyncing = false
@@ -24,6 +25,7 @@ final class AppModel {
    @ObservationIgnored private let store: LibraryStore
    @ObservationIgnored private let fetcher = FeedFetcher()
    @ObservationIgnored private var playback: PlaybackController?
    @ObservationIgnored private var persistTask: Task<Void, Never>?

    init(store: LibraryStore, downloads: DownloadController) {
        self.store = store
@@ -49,8 +51,38 @@ final class AppModel {
    func configure(playback: PlaybackController) {
        self.playback = playback
        playback.onFinish = { [weak self] in
            Task { await self?.advanceQueue() }
            Task { await self?.handlePlaybackFinished() }
        }
        startPositionPersistence()
    }

    /// Persists the current playback position every few seconds while playing,
    /// so a resume point survives app relaunch.
    private func startPositionPersistence() {
        persistTask?.cancel()
        persistTask = Task { [weak self] in
            while !Task.isCancelled {
                try? await Task.sleep(for: .seconds(10))
                await self?.persistCurrentPosition()
            }
        }
    }

    private func persistCurrentPosition() async {
        guard let playback, let episode = playback.currentEpisode,
              playback.isPlaying, playback.currentTime > 0 else { return }
        let state = PlayState(position: playback.currentTime, isPlayed: false)
        playStates[episode.id] = state
        try? await store.setPlayState(episodeID: episode.id, position: state.position, isPlayed: false)
    }

    private func handlePlaybackFinished() async {
        if let playback, let episode = playback.currentEpisode {
            let state = PlayState(position: playback.duration, isPlayed: true)
            playStates[episode.id] = state
            try? await store.setPlayState(episodeID: episode.id, position: state.position, isPlayed: true)
        }
        await advanceQueue()
    }

    func start() async {
@@ -119,9 +151,27 @@ final class AppModel {

    // MARK: Playback

    /// Plays an episode, preferring its local download when available.
    func play(_ episode: Episode) {
        playback?.play(episode: episode, url: downloads.localURL(for: episode))
    /// Plays an episode, resuming from its saved position and preferring a
    /// local download when available.
    func play(_ episode: Episode) async {
        let saved = try? await store.playState(forEpisodeID: episode.id)
        let resume = (saved?.isPlayed ?? false) ? 0 : (saved?.position ?? 0)
        playback?.play(episode: episode, url: downloads.localURL(for: episode), startAt: resume)
    }

    func playInfo(for episode: Episode) -> PlayState? {
        playStates[episode.id]
    }

    func loadPlayStates(for episodes: [Episode]) async {
        let map = (try? await store.playStates(forEpisodeIDs: episodes.map(\.id))) ?? [:]
        for (id, state) in map { playStates[id] = state }
    }

    func markPlayed(_ episode: Episode, _ played: Bool) async {
        let position = played ? (episode.duration ?? 0) : 0
        playStates[episode.id] = PlayState(position: position, isPlayed: played)
        try? await store.setPlayState(episodeID: episode.id, position: position, isPlayed: played)
    }

    private func advanceQueue() async {
@@ -130,7 +180,7 @@ final class AppModel {
        }
        await loadQueue()
        if let next = queue.first {
            play(next)
            await play(next)
        }
    }

@@ -198,6 +248,9 @@ final class AppModel {
                add: podcasts.map(\.feedURL), remove: []
            )
            syncSettings.lastTimestamp = newTimestamp ?? changes.timestamp ?? syncSettings.lastTimestamp

            try await syncEpisodeActions(using: provider)

            syncSettings.save()
            syncMessage = "Synchronisé — \(podcasts.count) abonnement(s)."
        } catch {
@@ -205,6 +258,37 @@ final class AppModel {
        }
    }

    /// Pushes local play progress as `play` actions, then applies remote ones.
    private func syncEpisodeActions(using provider: PodcastSyncProvider) async throws {
        let progress = (try? await store.playProgressForSync()) ?? []
        if !progress.isEmpty {
            let now = ISO8601DateFormatter().string(from: Date())
            let actions = progress.map { item in
                EpisodeAction(
                    podcast: item.feedURL.absoluteString,
                    episode: item.enclosureURL.absoluteString,
                    action: "play",
                    timestamp: now,
                    position: Int(item.position),
                    total: item.total.map { Int($0) }
                )
            }
            _ = try await provider.uploadEpisodeActions(actions)
        }

        let page = try await provider.episodeActions(since: syncSettings.lastEpisodeTimestamp)
        for action in page.actions where action.action == "play" {
            guard let url = URL(string: action.episode),
                  let episode = try await store.episode(withEnclosureURL: url) else { continue }
            let position = TimeInterval(action.position ?? 0)
            let total = TimeInterval(action.total ?? 0)
            let isPlayed = total > 0 && position >= total * 0.95
            try await store.setPlayState(episodeID: episode.id, position: position, isPlayed: isPlayed)
            playStates[episode.id] = PlayState(position: position, isPlayed: isPlayed)
        }
        syncSettings.lastEpisodeTimestamp = page.timestamp ?? syncSettings.lastEpisodeTimestamp
    }

    private static func normalizedURL(from raw: String) -> URL? {
        var string = raw.trimmingCharacters(in: .whitespacesAndNewlines)
        guard !string.isEmpty else { return nil }
+17 −1
Original line number Diff line number Diff line
@@ -22,12 +22,13 @@ struct EpisodeListRow: View {
                    Text(episode.title)
                        .font(.headline)
                        .lineLimit(2)
                        .foregroundStyle(isPlayed ? .secondary : .primary)
                }
                metadata
            }
            .frame(maxWidth: .infinity, alignment: .leading)
            .contentShape(Rectangle())
            .onTapGesture { model.play(episode) }
            .onTapGesture { Task { await model.play(episode) } }

            downloadControl
        }
@@ -39,6 +40,9 @@ struct EpisodeListRow: View {
        playback.currentEpisode?.id == episode.id
    }

    private var playState: PlayState? { model.playInfo(for: episode) }
    private var isPlayed: Bool { playState?.isPlayed ?? false }

    @ViewBuilder private var metadata: some View {
        HStack(spacing: 8) {
            if let date = episode.publicationDate {
@@ -48,6 +52,11 @@ struct EpisodeListRow: View {
                Text("·")
                Text(Self.format(duration))
            }
            if isPlayed {
                Label("Lu", systemImage: "checkmark").labelStyle(.titleAndIcon)
            } else if let position = playState?.position, position > 1 {
                Text("· repris à \(Self.format(position))")
            }
        }
        .font(.caption)
        .foregroundStyle(.secondary)
@@ -91,6 +100,13 @@ struct EpisodeListRow: View {
            .disabled(episode.enclosureURL == nil)
        }

        Button {
            Task { await model.markPlayed(episode, !isPlayed) }
        } label: {
            Label(isPlayed ? "Marquer comme non lu" : "Marquer comme lu",
                  systemImage: isPlayed ? "circle" : "checkmark.circle")
        }

        if downloads.status(for: episode) == .downloaded {
            Button(role: .destructive) {
                downloads.delete(episode)
+1 −0
Original line number Diff line number Diff line
@@ -29,6 +29,7 @@ struct PodcastDetailView: View {
        .inlineNavigationTitle()
        .task {
            episodes = await model.episodes(for: podcast)
            await model.loadPlayStates(for: episodes)
            loaded = true
        }
    }
+4 −0
Original line number Diff line number Diff line
@@ -12,6 +12,7 @@ final class SyncSettings {
    var username: String
    var password: String
    var lastTimestamp: Int?
    var lastEpisodeTimestamp: Int?

    @ObservationIgnored private let defaults = UserDefaults.standard
    private static let passwordAccount = "sync.password"
@@ -23,6 +24,8 @@ final class SyncSettings {
        password = KeychainStore.read(account: Self.passwordAccount) ?? ""
        let stored = defaults.integer(forKey: "sync.timestamp")
        lastTimestamp = stored == 0 ? nil : stored
        let storedEpisode = defaults.integer(forKey: "sync.epTimestamp")
        lastEpisodeTimestamp = storedEpisode == 0 ? nil : storedEpisode
    }

    func save() {
@@ -31,6 +34,7 @@ final class SyncSettings {
        defaults.set(username, forKey: "sync.user")
        KeychainStore.save(password, account: Self.passwordAccount)
        if let lastTimestamp { defaults.set(lastTimestamp, forKey: "sync.timestamp") }
        if let lastEpisodeTimestamp { defaults.set(lastEpisodeTimestamp, forKey: "sync.epTimestamp") }
    }

    func credentials() -> SyncCredentials? {
+4 −4
Original line number Diff line number Diff line
@@ -36,13 +36,13 @@ public final class PlaybackController {
    }

    /// Plays an episode, optionally from a local file `url` (e.g. a download)
    /// instead of its remote enclosure.
    public func play(episode: Episode, url: URL? = nil) {
    /// instead of its remote enclosure, resuming at `startAt` seconds.
    public func play(episode: Episode, url: URL? = nil, startAt: TimeInterval = 0) {
        guard let source = url ?? episode.enclosureURL else { return }
        currentEpisode = episode
        currentTime = 0
        currentTime = startAt
        duration = episode.duration ?? 0
        engine.load(url: source, startAt: 0)
        engine.load(url: source, startAt: startAt)
        engine.setRate(playbackRate)
        engine.play()
        isPlaying = true
Loading