Commit 0d3a0419 authored by Kourser's avatar Kourser
Browse files

Chapters: parse Podcasting 2.0 chapters JSON + player navigation



- PodcastModel.Chapter; FeedKit ChaptersParser/ChaptersFetcher (sorted, title
  fallback) + tests.
- AppModel loads chapters for the current episode (from chaptersURL) into
  currentChapters; currentChapter(at:) resolves the active one.
- PlayerView shows the current chapter (tappable) and a ChaptersListView to jump
  to a chapter (seek). Demo seed includes sample chapters for screenshots.

62 package tests green; iOS + macOS build; current chapter shown in the player
(verified in the simulator).

Co-Authored-By: default avatarClaude <claude@anthropic.com>
parent c69bd597
Loading
Loading
Loading
Loading
Loading
+45 −0
Original line number Diff line number Diff line
import Foundation
import PodcastModel

/// Parses the Podcasting 2.0 chapters JSON document.
public enum ChaptersParser {
    public static func parse(data: Data) -> [Chapter] {
        guard let document = try? JSONDecoder().decode(Document.self, from: data) else { return [] }
        let sorted = document.chapters.sorted { ($0.startTime ?? 0) < ($1.startTime ?? 0) }
        return sorted.enumerated().map { index, raw in
            let title = (raw.title?.isEmpty == false) ? raw.title! : "Chapitre \(index + 1)"
            return Chapter(
                id: index,
                startTime: raw.startTime ?? 0,
                title: title,
                imageURL: raw.img.flatMap(URL.init(string:)),
                link: raw.url.flatMap(URL.init(string:))
            )
        }
    }

    private struct Document: Decodable {
        let chapters: [Raw]
    }

    private struct Raw: Decodable {
        let startTime: Double?
        let title: String?
        let img: String?
        let url: String?
    }
}

/// Fetches and parses an episode's chapters file.
public struct ChaptersFetcher: Sendable {
    private let loader: FeedDataLoading

    public init(loader: FeedDataLoading = URLSessionFeedLoader()) {
        self.loader = loader
    }

    public func fetch(url: URL) async throws -> [Chapter] {
        let data = try await loader.loadData(from: url)
        return ChaptersParser.parse(data: data)
    }
}
+18 −0
Original line number Diff line number Diff line
import Foundation

/// A chapter within an episode (Podcasting 2.0 `podcast:chapters` JSON).
public struct Chapter: Identifiable, Hashable, Sendable {
    public let id: Int
    public let startTime: TimeInterval
    public let title: String
    public let imageURL: URL?
    public let link: URL?

    public init(id: Int, startTime: TimeInterval, title: String, imageURL: URL? = nil, link: URL? = nil) {
        self.id = id
        self.startTime = startTime
        self.title = title
        self.imageURL = imageURL
        self.link = link
    }
}
+36 −0
Original line number Diff line number Diff line
import Foundation
import Testing
@testable import FeedKit

@Suite struct ChaptersTests {
    @Test func parsesSortsAndFallsBack() {
        let json = """
        {
          "version": "1.2.0",
          "chapters": [
            { "startTime": 120, "title": "Sujet 1", "img": "https://x.test/1.jpg" },
            { "startTime": 0, "title": "Intro" },
            { "startTime": 300 }
          ]
        }
        """
        let chapters = ChaptersParser.parse(data: Data(json.utf8))
        #expect(chapters.map(\.title) == ["Intro", "Sujet 1", "Chapitre 3"])
        #expect(chapters.map(\.startTime) == [0, 120, 300])
        #expect(chapters[1].imageURL == URL(string: "https://x.test/1.jpg"))
    }

    @Test func handlesGarbage() {
        #expect(ChaptersParser.parse(data: Data("nope".utf8)).isEmpty)
    }

    @Test func fetcherParses() async throws {
        struct Stub: FeedDataLoading {
            func loadData(from url: URL) async throws -> Data {
                Data(#"{"chapters":[{"startTime":0,"title":"A"}]}"#.utf8)
            }
        }
        let chapters = try await ChaptersFetcher(loader: Stub()).fetch(url: URL(string: "https://x.test/c.json")!)
        #expect(chapters.map(\.title) == ["A"])
    }
}
+15 −1
Original line number Diff line number Diff line
@@ -18,6 +18,7 @@ final class AppModel {
    private(set) var favorites: Set<UUID> = []
    private(set) var inbox: [Episode] = []
    private(set) var podcastTags: [UUID: [String]] = [:]
    private(set) var currentChapters: [Chapter] = []
    var errorMessage: String?
    var isAdding = false
    var isSyncing = false
@@ -136,6 +137,8 @@ final class AppModel {
            "Dans le viseur : opérations spéciales",
        ]
        let base = 1_750_000_000.0
        let demoChaptersJSON = "{\"chapters\":[{\"startTime\":0,\"title\":\"Introduction\"},{\"startTime\":600,\"title\":\"Analyse de la situation\"},{\"startTime\":1800,\"title\":\"Perspectives et conclusion\"}]}"
        let chaptersURL = URL(string: "data:application/json," + (demoChaptersJSON.addingPercentEncoding(withAllowedCharacters: .urlQueryAllowed) ?? ""))
        let episodes = titles.enumerated().map { i, title in
            Episode(
                guid: "demo-\(i)",
@@ -144,7 +147,8 @@ final class AppModel {
                publicationDate: Date(timeIntervalSince1970: base - Double(i) * 172_800),
                enclosureURL: URL(string: "https://feeds.audiomeans.fr/demo/\(i).mp3"),
                duration: Double(3600 + i * 420),
                imageURL: artwork
                imageURL: artwork,
                chaptersURL: i == 0 ? chaptersURL : nil
            )
        }
        guard let stored = try? await store.save(podcast: podcast, episodes: episodes) else { return }
@@ -270,6 +274,7 @@ final class AppModel {
        favorites = []
        inbox = []
        podcastTags = [:]
        currentChapters = []
        await load()
        await loadQueue()
    }
@@ -282,6 +287,15 @@ final class AppModel {
        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)
        currentChapters = []
        if let url = episode.chaptersURL {
            currentChapters = (try? await ChaptersFetcher().fetch(url: url)) ?? []
        }
    }

    /// The chapter containing `time`, if the current episode has chapters.
    func currentChapter(at time: TimeInterval) -> Chapter? {
        currentChapters.last { $0.startTime <= time + 0.5 }
    }

    func playInfo(for episode: Episode) -> PlayState? {
+54 −0
Original line number Diff line number Diff line
import SwiftUI
import PlaybackKit
import PodcastModel

struct ChaptersListView: View {
    @Environment(AppModel.self) private var model
    @Environment(PlaybackController.self) private var playback
    @Environment(\.dismiss) private var dismiss

    var body: some View {
        NavigationStack {
            List(model.currentChapters) { chapter in
                Button {
                    playback.seek(to: chapter.startTime)
                    dismiss()
                } label: {
                    HStack(spacing: 12) {
                        Text(Self.format(chapter.startTime))
                            .font(.caption)
                            .monospacedDigit()
                            .foregroundStyle(.secondary)
                            .frame(width: 64, alignment: .leading)
                        Text(chapter.title)
                            .lineLimit(2)
                        Spacer()
                        if isCurrent(chapter) {
                            Image(systemName: "speaker.wave.2.fill").foregroundStyle(.tint)
                        }
                    }
                }
                .buttonStyle(.plain)
            }
            .navigationTitle("Chapitres")
            .inlineNavigationTitle()
            .toolbar {
                ToolbarItem(placement: .cancellationAction) {
                    Button("Fermer") { dismiss() }
                }
            }
        }
    }

    private func isCurrent(_ chapter: Chapter) -> Bool {
        model.currentChapter(at: playback.currentTime)?.id == chapter.id
    }

    private static func format(_ seconds: TimeInterval) -> String {
        let total = Int(seconds)
        let h = total / 3600, m = (total % 3600) / 60, s = total % 60
        return h > 0
            ? String(format: "%d:%02d:%02d", h, m, s)
            : String(format: "%d:%02d", m, s)
    }
}
Loading