Commit b518383c authored by Kourser's avatar Kourser
Browse files

Add sleep timer, favorites + episode filters, clickable links, OPML export



- Sleep timer: PlaybackController.startSleepTimer/cancel (countdown pauses
  playback); control in the full-screen player (shows remaining time).
- Favorites: `favorite` table (migration v4) + AppModel favorites; star
  indicator and "Ajouter/Retirer des favoris" in episode rows; podcast detail
  gains a filter (Tous / Non lus / Favoris / Téléchargés).
- Clickable links: FeedKit.HTMLText.attributed keeps <a href> as tappable links
  in descriptions (detail uses it).
- OPML export: DiscoveryKit.OPMLExport + AppModel.exportOPML; ShareLink in
  Réglages.

56 package tests green; iOS + macOS build; player (sleep) and detail (filter)
verified in the simulator.

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

/// Generates an OPML 2.0 document from a list of subscriptions.
public enum OPMLExport {
    public static func generate(feeds: [(title: String?, feedURL: URL)]) -> String {
        var lines = [
            "<?xml version=\"1.0\" encoding=\"UTF-8\"?>",
            "<opml version=\"2.0\">",
            "  <head><title>Skingomz</title></head>",
            "  <body>",
        ]
        for feed in feeds {
            let title = escape(feed.title ?? feed.feedURL.absoluteString)
            let url = escape(feed.feedURL.absoluteString)
            lines.append("    <outline type=\"rss\" text=\"\(title)\" title=\"\(title)\" xmlUrl=\"\(url)\"/>")
        }
        lines.append("  </body>")
        lines.append("</opml>")
        return lines.joined(separator: "\n") + "\n"
    }

    private static func escape(_ string: String) -> String {
        string
            .replacingOccurrences(of: "&", with: "&amp;")
            .replacingOccurrences(of: "<", with: "&lt;")
            .replacingOccurrences(of: ">", with: "&gt;")
            .replacingOccurrences(of: "\"", with: "&quot;")
    }
}
+45 −1
Original line number Diff line number Diff line
@@ -6,6 +6,10 @@ import Foundation
/// (unlike `NSAttributedString(html:)`).
public enum HTMLText {
    public static func plain(_ html: String) -> String {
        normalize(html, trim: true)
    }

    private static func normalize(_ html: String, trim: Bool) -> String {
        var text = html

        // Block-level / line-break tags → newlines.
@@ -42,6 +46,46 @@ public enum HTMLText {
        // Tidy whitespace: collapse runs of blank lines and spaces.
        text = text.replacingOccurrences(of: "[ \\t]+", with: " ", options: .regularExpression)
        text = text.replacingOccurrences(of: "\\n[ \\t]*\\n[ \\t]*\\n+", with: "\n\n", options: .regularExpression)
        return text.trimmingCharacters(in: .whitespacesAndNewlines)
        return trim ? text.trimmingCharacters(in: .whitespacesAndNewlines) : text
    }

    /// Like ``plain(_:)`` but keeps `<a href>` anchors as tappable links.
    public static func attributed(_ html: String) -> AttributedString {
        let pattern = "<a\\s+[^>]*href=\"([^\"]*)\"[^>]*>(.*?)</a>"
        guard let regex = try? NSRegularExpression(pattern: pattern, options: [.caseInsensitive, .dotMatchesLineSeparators]) else {
            return AttributedString(plain(html))
        }
        let ns = html as NSString
        var result = AttributedString()
        var cursor = 0
        for match in regex.matches(in: html, range: NSRange(location: 0, length: ns.length)) {
            // Text before the link.
            if match.range.location > cursor {
                let before = ns.substring(with: NSRange(location: cursor, length: match.range.location - cursor))
                result += AttributedString(normalize(before, trim: false))
            }
            let href = ns.substring(with: match.range(at: 1))
            var link = AttributedString(normalize(ns.substring(with: match.range(at: 2)), trim: false))
            if let url = URL(string: href) {
                link.link = url
            }
            result += link
            cursor = match.range.location + match.range.length
        }
        if cursor < ns.length {
            result += AttributedString(normalize(ns.substring(from: cursor), trim: false))
        }
        return trimmed(result)
    }

    private static func trimmed(_ input: AttributedString) -> AttributedString {
        var attr = input
        while let last = attr.characters.last, last.isWhitespace || last.isNewline {
            attr.removeSubrange(attr.characters.index(before: attr.endIndex)..<attr.endIndex)
        }
        while let first = attr.characters.first, first.isWhitespace || first.isNewline {
            attr.removeSubrange(attr.startIndex..<attr.characters.index(after: attr.startIndex))
        }
        return attr
    }
}
+36 −0
Original line number Diff line number Diff line
@@ -18,6 +18,8 @@ public final class PlaybackController {
    public private(set) var currentTime: TimeInterval = 0
    public private(set) var duration: TimeInterval = 0
    public private(set) var playbackRate: Float = 1.0
    /// Seconds left on the sleep timer, or `nil` when inactive.
    public private(set) var sleepRemaining: TimeInterval?

    /// Fraction in 0...1, safe when the duration is unknown.
    public var progress: Double {
@@ -26,6 +28,7 @@ public final class PlaybackController {
    }

    @ObservationIgnored private let engine: AudioPlayerEngine
    @ObservationIgnored private var sleepTask: Task<Void, Never>?
    @ObservationIgnored public weak var observer: PlaybackObserver?
    /// Called when the current item plays to its end (used for queue advance).
    @ObservationIgnored public var onFinish: (@MainActor () -> Void)?
@@ -62,6 +65,7 @@ public final class PlaybackController {

    /// Stops playback and clears the current item (used by the in-app reset).
    public func stop() {
        cancelSleepTimer()
        engine.pause()
        isPlaying = false
        currentEpisode = nil
@@ -70,6 +74,38 @@ public final class PlaybackController {
        notify()
    }

    // MARK: Sleep timer

    /// Pauses playback after `minutes`. Replaces any running timer.
    public func startSleepTimer(minutes: Int) {
        cancelSleepTimer()
        sleepRemaining = TimeInterval(max(1, minutes) * 60)
        sleepTask = Task { [weak self] in
            while let remaining = self?.sleepRemaining, remaining > 0, !Task.isCancelled {
                try? await Task.sleep(for: .seconds(1))
                guard !Task.isCancelled else { return }
                self?.tickSleepTimer()
            }
        }
    }

    public func cancelSleepTimer() {
        sleepTask?.cancel()
        sleepTask = nil
        sleepRemaining = nil
    }

    private func tickSleepTimer() {
        guard let remaining = sleepRemaining else { return }
        if remaining <= 1 {
            sleepRemaining = nil
            sleepTask = nil
            pause()
        } else {
            sleepRemaining = remaining - 1
        }
    }

    public func resume() {
        guard currentEpisode != nil else { return }
        engine.setRate(playbackRate)
+29 −0
Original line number Diff line number Diff line
@@ -92,6 +92,12 @@ public final class LibraryStore: Sendable {
                t.column("updatedAt", .double).notNull().defaults(to: 0)
            }
        }
        migrator.registerMigration("v4") { db in
            try db.create(table: "favorite") { t in
                t.primaryKey("episodeId", .text)
                    .references("episode", onDelete: .cascade)
            }
        }
        return migrator
    }()

@@ -169,6 +175,7 @@ public final class LibraryStore: Sendable {
    /// play state). Used by the in-app reset.
    public func deleteAllData() async throws {
        _ = try await dbQueue.write { db in
            try FavoriteRecord.deleteAll(db)
            try PlayStateRecord.deleteAll(db)
            try QueueRecord.deleteAll(db)
            try DownloadRecord.deleteAll(db)
@@ -176,4 +183,26 @@ public final class LibraryStore: Sendable {
            try PodcastRecord.deleteAll(db)
        }
    }

    // MARK: Favorites

    public func setFavorite(episodeID id: UUID, _ favorite: Bool) async throws {
        let episodeId = id.uuidString
        _ = try await dbQueue.write { db in
            if favorite {
                try FavoriteRecord(episodeId: episodeId).upsert(db)
            } else {
                try FavoriteRecord.deleteOne(db, key: episodeId)
            }
        }
    }

    /// Of the given episodes, which are favorited.
    public func favoriteEpisodeIDs(in ids: [UUID]) async throws -> Set<UUID> {
        let keys = ids.map(\.uuidString)
        return try await dbQueue.read { db in
            let records = try FavoriteRecord.filter(keys: keys).fetchAll(db)
            return Set(records.compactMap { UUID(uuidString: $0.episodeId) })
        }
    }
}
+6 −0
Original line number Diff line number Diff line
@@ -147,6 +147,12 @@ struct PlayStateRecord: Codable, FetchableRecord, PersistableRecord {
    var updatedAt: Double
}

/// Favorited (starred) episodes. Presence in the table means "favorite".
struct FavoriteRecord: Codable, FetchableRecord, PersistableRecord {
    static let databaseTableName = "favorite"
    var episodeId: String
}

enum CategoryCoding {
    static func encode(_ categories: [String]) -> String {
        guard let data = try? JSONEncoder().encode(categories) else { return "[]" }
Loading