Commit d7317b01 authored by Kourser's avatar Kourser
Browse files

feat(app): UI, settings, background refresh, notifications + 18-language localization



App layer: podcast settings sheet, fine speed control + configurable skip intervals, history & statistics screens (in Settings), download conditions (Wi-Fi/charging) + cache size & clearing, background app-refresh (BGTaskScheduler) + opt-in new-episode notifications, discovery suggestions/recommendations, transcript view, and a language picker. Adds a String Catalog localized into 18 languages (FR source), background fetch capability (Info.plist) and known regions (project).

Co-Authored-By: default avatarClaude <claude@anthropic.com>
parent 91d7fc27
Loading
Loading
Loading
Loading
+5 −0
Original line number Diff line number Diff line
@@ -5,6 +5,11 @@
	<key>UIBackgroundModes</key>
	<array>
		<string>audio</string>
		<string>fetch</string>
	</array>
	<key>BGTaskSchedulerPermittedIdentifiers</key>
	<array>
		<string>eu.cythin.skingomz.refresh</string>
	</array>
	<key>ITSAppUsesNonExemptEncryption</key>
	<false/>
+19 −1
Original line number Diff line number Diff line
@@ -108,10 +108,28 @@
				};
			};
			buildConfigurationList = ABCDEF0123456789ABCD000A /* Build configuration list for PBXProject "Skingomz" */;
			developmentRegion = en;
			developmentRegion = fr;
			hasScannedForEncodings = 0;
			knownRegions = (
				fr,
				en,
				es,
				it,
				de,
				pt,
				pl,
				nl,
				cs,
				sv,
				da,
				fi,
				el,
				hu,
				ro,
				sk,
				sl,
				hr,
				bg,
				Base,
			);
			mainGroup = ABCDEF0123456789ABCD0002;
+156 −13
Original line number Diff line number Diff line
@@ -26,6 +26,9 @@ final class AppModel {

    @ObservationIgnored let downloads: DownloadController
    @ObservationIgnored let syncSettings = SyncSettings()
    @ObservationIgnored let downloadSettings = DownloadSettings()
    @ObservationIgnored let notifications = NotificationsController()
    @ObservationIgnored private let network = NetworkMonitor()
    @ObservationIgnored private let store: LibraryStore
    @ObservationIgnored private let fetcher = FeedFetcher()
    @ObservationIgnored private var playback: PlaybackController?
@@ -85,6 +88,7 @@ final class AppModel {
            let state = PlayState(position: playback.duration, isPlayed: true)
            playStates[episode.id] = state
            try? await store.setPlayState(episodeID: episode.id, position: state.position, isPlayed: true)
            if downloadSettings.deleteWhenPlayed { downloads.delete(episode) }
        }
        await advanceQueue()
    }
@@ -181,6 +185,14 @@ final class AppModel {
        inbox = (try? await store.recentEpisodes(limit: 100)) ?? []
    }

    func recentlyPlayed() async -> [Episode] {
        (try? await store.recentlyPlayed(limit: 100)) ?? []
    }

    func listeningStats() async -> ListeningStats {
        (try? await store.listeningStats()) ?? ListeningStats()
    }

    func searchEpisodes(_ query: String) async -> [Episode] {
        (try? await store.searchEpisodes(query)) ?? []
    }
@@ -198,10 +210,20 @@ final class AppModel {
        podcastTags = (try? await store.allPodcastTags()) ?? [:]
    }

    // MARK: Per-podcast preferences

    func preferences(for podcast: Podcast) async -> PodcastPreferences {
        (try? await store.preferences(forPodcastID: podcast.id)) ?? .none
    }

    func setPreferences(_ prefs: PodcastPreferences, for podcast: Podcast) async {
        try? await store.setPreferences(prefs, forPodcastID: podcast.id)
    }

    func subscribe(urlString: String) async -> Bool {
        errorMessage = nil
        guard let url = Self.normalizedURL(from: urlString) else {
            errorMessage = "URL invalide."
            errorMessage = String(localized: "URL invalide.")
            return false
        }
        isAdding = true
@@ -212,7 +234,7 @@ final class AppModel {
            await load()
            return true
        } catch {
            errorMessage = "Échec de l'ajout : \(error.localizedDescription)"
            errorMessage = String(localized: "Échec de l'ajout : \(error.localizedDescription)")
            return false
        }
    }
@@ -248,18 +270,79 @@ final class AppModel {

    /// Re-fetches every subscription's feed to pull in new episodes.
    func refreshAllFeeds() async {
        for podcast in podcasts {
            if let feed = try? await fetcher.fetch(url: podcast.feedURL) {
                try? await store.save(podcast: feed.podcast, episodes: feed.episodes)
            }
        }
        for podcast in podcasts { _ = await refreshFeed(podcast) }
        await load()
    }

    /// Re-fetches a single subscription's feed.
    func refresh(_ podcast: Podcast) async {
        if let feed = try? await fetcher.fetch(url: podcast.feedURL) {
            try? await store.save(podcast: feed.podcast, episodes: feed.episodes)
        _ = await refreshFeed(podcast)
    }

    /// Background entry point (called by the OS app-refresh task): reloads
    /// subscriptions from the store (the UI may never have run), refreshes every
    /// feed — triggering auto-download — and notifies about new episodes.
    func backgroundRefresh() async {
        await load()
        var newCount = 0
        for podcast in podcasts { newCount += await refreshFeed(podcast) }
        await load()
        await notifications.postNewEpisodes(count: newCount)
    }

    /// Fetches and stores one feed, auto-downloads its new episodes, and returns
    /// how many episodes were newly added.
    @discardableResult
    private func refreshFeed(_ podcast: Podcast) async -> Int {
        guard let feed = try? await fetcher.fetch(url: podcast.feedURL),
              let result = try? await store.saveReportingNewEpisodes(
                  podcast: feed.podcast, episodes: feed.episodes) else { return 0 }
        await autoDownloadNewEpisodes(podcastID: result.podcast.id,
                                      newEpisodeIDs: result.newEpisodeIDs)
        return result.newEpisodeIDs.count
    }

    // MARK: Auto-download

    /// Returns whether the configured network/power conditions currently allow
    /// auto-downloading.
    private func autoDownloadAllowed() -> Bool {
        if downloadSettings.wifiOnly && !network.isUnmetered { return false }
        if downloadSettings.chargingOnly && !PowerStatus.isPluggedIn { return false }
        return true
    }

    /// Downloads freshly published episodes for a podcast when it has
    /// auto-download enabled, honouring its episode cache limit (and pruning
    /// older downloads beyond it). No-op on first subscribe (only refreshes
    /// report new episodes).
    private func autoDownloadNewEpisodes(podcastID: UUID, newEpisodeIDs: [UUID]) async {
        guard !newEpisodeIDs.isEmpty else { return }
        let prefs = (try? await store.preferences(forPodcastID: podcastID)) ?? .none
        guard prefs.autoDownload, autoDownloadAllowed() else { return }

        let all = (try? await store.episodes(forPodcastID: podcastID)) ?? []
        let newSet = Set(newEpisodeIDs)
        let newEpisodes = all.filter { newSet.contains($0.id) }

        guard prefs.episodeCacheLimit > 0 else {
            // Unlimited: just download the new ones.
            for episode in newEpisodes { downloads.download(episode) }
            return
        }

        // Bounded: keep the newest `limit` across existing downloads + new ones.
        let existing = (try? await store.downloadedEpisodes(forPodcastID: podcastID)) ?? []
        var candidates = existing
        candidates.append(contentsOf: newEpisodes.filter { ep in !existing.contains { $0.id == ep.id } })
        candidates.sort { ($0.publicationDate ?? .distantPast) > ($1.publicationDate ?? .distantPast) }
        let keep = Set(candidates.prefix(prefs.episodeCacheLimit).map(\.id))

        for episode in newEpisodes where keep.contains(episode.id) {
            downloads.download(episode)
        }
        for episode in existing where !keep.contains(episode.id) {
            downloads.delete(episode)
        }
    }

@@ -286,11 +369,27 @@ final class AppModel {
    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)
        let prefs = (try? await store.preferences(forEpisodeID: episode.id)) ?? .none
        // Skip the intro only on a fresh start, never when resuming mid-episode.
        let startAt = resume > 0 ? resume : TimeInterval(prefs.skipIntro)
        playback?.play(
            episode: episode,
            url: downloads.localURL(for: episode),
            startAt: startAt,
            rate: prefs.playbackRate,
            skipEnd: TimeInterval(prefs.skipEnd),
            skipSilence: prefs.skipSilence
        )
        currentChapters = []
        if let url = episode.chaptersURL {
            currentChapters = (try? await ChaptersFetcher().fetch(url: url)) ?? []
        }
        // Fallback: chapters embedded in the media file (ID3/MP4), preferring a
        // local download to avoid pulling metadata over the network.
        if currentChapters.isEmpty,
           let source = downloads.localURL(for: episode) ?? episode.enclosureURL {
            currentChapters = await EmbeddedChapters.load(from: source)
        }
    }

    /// The chapter containing `time`, if the current episode has chapters.
@@ -322,6 +421,49 @@ final class AppModel {
        try? await store.setFavorite(episodeID: episode.id, makeFavorite)
    }

    /// Current size of the downloaded-media cache, in bytes.
    func cacheByteSize() -> Int64 {
        downloads.cacheByteSize()
    }

    /// Deletes every downloaded file and its record, keeping all other data.
    func clearDownloadCache() async {
        try? await store.clearAllDownloads()
        downloads.reset()
    }

    /// Writes a full database backup to a temp file, for sharing/export.
    func exportDatabase() async -> URL? {
        let url = FileManager.default.temporaryDirectory
            .appendingPathComponent("Skingomz-sauvegarde.sqlite")
        do {
            try await store.exportDatabase(to: url)
            return url
        } catch {
            return nil
        }
    }

    /// Restores all data from a backup file, then reloads in-memory state.
    /// Destructive — the caller must confirm with the user first.
    func restoreDatabase(from url: URL) async -> Bool {
        do {
            try await store.restoreDatabase(from: url)
        } catch {
            errorMessage = String(localized: "Échec de la restauration : \(error.localizedDescription)")
            return false
        }
        playback?.stop()
        favorites = []
        playStates = [:]
        inbox = []
        currentChapters = []
        await load()
        await loadQueue()
        await downloads.loadPersisted()
        return true
    }

    /// Writes an OPML export of the current subscriptions to a temp file.
    func exportOPML() -> URL? {
        let feeds = podcasts.map { (title: $0.title as String?, feedURL: $0.feedURL) }
@@ -340,6 +482,7 @@ final class AppModel {
        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)
        if played && downloadSettings.deleteWhenPlayed { downloads.delete(episode) }
    }

    private func advanceQueue() async {
@@ -396,7 +539,7 @@ final class AppModel {
    /// requires local play-state persistence (not yet implemented).
    func syncNow() async {
        guard let credentials = syncSettings.credentials() else {
            syncMessage = "Configurez d'abord la synchronisation."
            syncMessage = String(localized: "Configurez d'abord la synchronisation.")
            return
        }
        isSyncing = true
@@ -420,9 +563,9 @@ final class AppModel {
            try await syncEpisodeActions(using: provider)

            syncSettings.save()
            syncMessage = "Synchronisé — \(podcasts.count) abonnement(s)."
            syncMessage = String(localized: "Synchronisé — \(podcasts.count) abonnement(s).")
        } catch {
            syncMessage = "Échec de la synchronisation : \(error.localizedDescription)"
            syncMessage = String(localized: "Échec de la synchronisation : \(error.localizedDescription)")
        }
    }

+20 −0
Original line number Diff line number Diff line
import Foundation
#if os(iOS)
import BackgroundTasks
#endif

/// Identifier for the background app-refresh task. Must match the entry in
/// `Info.plist` under `BGTaskSchedulerPermittedIdentifiers`.
enum BackgroundRefresh {
    static let taskIdentifier = "eu.cythin.skingomz.refresh"

    #if os(iOS)
    /// Submits a request for the next opportunistic feed refresh. The system
    /// decides the actual timing based on usage; `earliestBeginDate` is a floor.
    static func schedule() {
        let request = BGAppRefreshTaskRequest(identifier: taskIdentifier)
        request.earliestBeginDate = Date(timeIntervalSinceNow: 4 * 60 * 60)
        try? BGTaskScheduler.shared.submit(request)
    }
    #endif
}
+13 −1
Original line number Diff line number Diff line
@@ -73,7 +73,19 @@ final class DownloadController {
        Task { try? await store.removeDownloadRecord(episodeID: episode.id) }
    }

    /// Removes every downloaded file and clears status (used by the reset).
    /// Total size on disk of the downloaded media, in bytes.
    func cacheByteSize() -> Int64 {
        guard let urls = try? FileManager.default.contentsOfDirectory(
            at: directory, includingPropertiesForKeys: [.fileSizeKey]
        ) else { return 0 }
        return urls.reduce(0) { sum, url in
            let size = (try? url.resourceValues(forKeys: [.fileSizeKey]).fileSize) ?? 0
            return sum + Int64(size)
        }
    }

    /// Removes every downloaded file and clears status (used by the reset and
    /// the "clear cache" action).
    func reset() {
        statuses = [:]
        try? FileManager.default.removeItem(at: directory)
Loading