Commit 881407d5 authored by Kourser's avatar Kourser
Browse files

feat(ios): widgets and a Control Center control

Two widgets and one control, in a new SkingomzWidgets extension built and
embedded for iOS only.

"En cours" — small, and circular or rectangular on the Lock Screen —
shows the episode in the player, or the one listened to last, with a
button that resumes or pauses it without opening the app. "À suivre" —
medium — adds the next episodes of the queue, each one tap from playing.
The control toggles playback from Control Center, the Lock Screen or the
Action button.

The extension has no access to the library, so the app hands it a
snapshot through an App Group: WidgetPublisher writes what is playing,
the next three of the queue and thumbnails of their artwork, then asks
the system to redraw. It publishes when the episode, the playing state,
the speed or the queue changes — not the position: while playing, the
progress bar runs on a timer interval computed from the last snapshot
and the speed, so the widgets are not reloaded every second. A last
snapshot is written when the app is terminated, or the widgets would go
on showing a pause button over a bar filling on its own.

The buttons are App Intents. A widget button is built from the intent's
type, so the intents it needs move to Shared/, compiled into both
targets — but AudioPlaybackIntents always run in the app's process, so
they depend on a PlaybackActions protocol the extension can see and only
AppServices implements.

Project: the version and build number move to the project level, one
place for the app and its extension, which the App Store requires to be
identical. The app gets an iOS entitlements file for the App Group.
Releasing needs the new identifier eu.cythin.skingomz.widgets and the
group group.eu.cythin.skingomz registered with Apple first.

Verified on an iOS 27 simulator with a real enclosure: both widgets in
the gallery and on the Home Screen; play, pause and an up-next row from
the Home Screen with the app terminated, the intents running in the app
process; the control toggling both ways from Control Center; macOS
builds without the extension. Not seen: the two Lock Screen families,
and the snapshot written at termination — simctl kills the process
without the notification.

Co-Authored-By: default avatarClaude Opus 5.5 <noreply@anthropic.com>
parent 2f9aa868
Loading
Loading
Loading
Loading
+6 −0
Original line number Diff line number Diff line
@@ -14,6 +14,12 @@ adopte le [versionnage sémantique](https://semver.org/lang/fr/).
  bouton Action. Siri les comprend aussi : « Reprendre Skingomz », « Écouter
  Le Collimateur dans Skingomz ». Ils agissent même app fermée : la lecture
  démarre sans que l'app s'ouvre.
- **iOS — widgets et centre de contrôle** : « En cours » (petit format, et sur
  l'écran verrouillé) montre l'épisode en cours ou le dernier écouté, avec un
  bouton pour le reprendre ou le mettre en pause sans ouvrir l'app. « À suivre »
  (format moyen) y ajoute les prochains épisodes de la file d'attente, chacun à
  un toucher de la lecture. Une commande « Lecture Skingomz » s'ajoute au
  centre de contrôle, à l'écran verrouillé ou au bouton Action.
- **Vitesse, direct et chapitres sur l'écran verrouillé** : la vitesse de
  lecture se règle depuis l'écran verrouillé et CarPlay ; une radio y apparaît
  en direct, sans barre de progression ni boutons de saut qui ne faisaient
+4 −0
Original line number Diff line number Diff line
@@ -39,6 +39,10 @@ qui n'existe pas sur iOS.
- 🔍 **Recherche** dans la bibliothèque et les épisodes
- 🗂️ **Tri/filtre & tags** des abonnements ; **favoris**, filtres et tri des épisodes
- ▶️ **Lecture en arrière-plan** + contrôles écran verrouillé (Now Playing, AirPods…)
- 🗣️ **Siri, Raccourcis et bouton Action** : reprendre, mettre en pause, écouter le
  dernier épisode d'un podcast — sans ouvrir l'app
- 🧩 **Widgets** (iOS/iPadOS) : épisode en cours et file d'attente sur l'écran
  d'accueil et l'écran verrouillé, commande lecture/pause dans le centre de contrôle
- ⏩ **Vitesse** réglable (0,8× à 3×, pas de 0,1), **chapitres** (navigation)
- 🌙 **Minuteur de sommeil** avec **fondu** de fin et option **« Fin de l'épisode »**
- 🔉 **Volume de l'application** (curseur fin dans le lecteur, sous le premier cran
+1 −1
Original line number Diff line number Diff line
@@ -54,7 +54,7 @@ persistance GRDB/SQLite · iOS 18+.

## Suite envisagée

- **Widget**, **CarPlay**, **Apple Watch**, **Siri/Raccourcis**.
- **CarPlay**, **Apple Watch**.
- Relecture native des traductions générées.
- Langues UE manquantes (maltais, irlandais, letton, lituanien, estonien) :
  à faire via relecture native / flux de traduction communautaire.
+99 −0
Original line number Diff line number Diff line
import AppIntents
import Foundation

// The intents behind the play and pause buttons: in Siri and the Shortcuts
// app, but also on the widgets and the Control Center control. This folder is
// compiled into the widget extension as well, because a widget button is built
// from the intent's type — yet an `AudioPlaybackIntent` always runs in the
// app's process, the only one holding the player. The extension therefore
// only ever sees `PlaybackActions`, never its implementation.

/// What the playback intents ask of the app. Implemented by `AppServices`.
@MainActor
protocol PlaybackActions: AnyObject, Sendable {
    /// Resumes the episode loaded in the player, or else the one listened to
    /// most recently, where it was left.
    func resume() async throws
    func pause() async
    /// Plays one episode — from the queue, or recently listened to — from
    /// where it was left.
    func play(episodeID: UUID) async throws
}

/// Resumes the episode in progress, or else the one heard most recently.
struct ResumePlaybackIntent: AudioPlaybackIntent {
    static let title: LocalizedStringResource = "Reprendre la lecture"
    static let description = IntentDescription(
        "Reprend l'épisode en cours, ou le dernier écouté, là où vous vous étiez arrêté."
    )

    @Dependency private var player: any PlaybackActions

    @MainActor
    func perform() async throws -> some IntentResult {
        try await player.resume()
        return .result()
    }
}

struct PausePlaybackIntent: AudioPlaybackIntent {
    static let title: LocalizedStringResource = "Mettre en pause"
    static let description = IntentDescription("Met en pause l'épisode en cours.")

    @Dependency private var player: any PlaybackActions

    @MainActor
    func perform() async throws -> some IntentResult {
        await player.pause()
        return .result()
    }
}

/// Plays one episode: what the rows of the "À suivre" widget run. Hidden from
/// the Shortcuts app, where an episode identifier would mean nothing to anyone.
struct PlayEpisodeIntent: AudioPlaybackIntent {
    static let title: LocalizedStringResource = "Lire l'épisode"
    static let isDiscoverable = false

    @Parameter(title: "Épisode")
    var episodeID: String

    @Dependency private var player: any PlaybackActions

    init() {}

    init(episodeID: UUID) {
        self.episodeID = episodeID.uuidString
    }

    @MainActor
    func perform() async throws -> some IntentResult {
        if let id = UUID(uuidString: episodeID) {
            try await player.play(episodeID: id)
        }
        return .result()
    }
}

/// Plays or pauses: the action of the Control Center toggle, which hands the
/// state it wants rather than asking for a flip. Hidden from the Shortcuts
/// app, which already offers "Reprendre la lecture" and "Mettre en pause".
struct SetPlayingIntent: SetValueIntent, AudioPlaybackIntent {
    static let title: LocalizedStringResource = "Lecture ou pause"
    static let isDiscoverable = false

    @Parameter(title: "En lecture")
    var value: Bool

    @Dependency private var player: any PlaybackActions

    @MainActor
    func perform() async throws -> some IntentResult {
        if value {
            try await player.resume()
        } else {
            await player.pause()
        }
        return .result()
    }
}
+88 −0
Original line number Diff line number Diff line
import Foundation

/// What the widgets and the Control Center control show. The app writes it to
/// the App Group container whenever playback or the queue changes; the widget
/// extension, which has no access to the library, only ever reads it.
struct WidgetSnapshot: Codable, Equatable, Sendable {
    struct Episode: Codable, Equatable, Sendable, Identifiable {
        var id: UUID
        var title: String
        var showTitle: String
        /// File name of a thumbnail in the shared container — widgets cannot
        /// download anything while they are being drawn.
        var artwork: String?
        var duration: TimeInterval?
        var position: TimeInterval
        var isLive: Bool
    }

    /// The episode in the player, or else the one listened to last.
    var current: Episode?
    var isPlaying: Bool
    var rate: Double
    /// When `current.position` was read. While playing, the widgets carry the
    /// position forward from here instead of being reloaded every second.
    var date: Date
    /// The next episodes of the queue, after the current one.
    var upNext: [Episode]

    static let empty = WidgetSnapshot(current: nil, isPlaying: false, rate: 1, date: .distantPast, upNext: [])

    /// How far through the current episode, in 0...1, as of `date`.
    var fraction: Double {
        guard let current, let duration = current.duration, duration > 0 else { return 0 }
        return min(1, max(0, current.position / duration))
    }

    /// While playing, the span over which a timer-driven progress bar fills at
    /// exactly the pace of the episode: from where 0 would have been to where
    /// the end will be, at the current speed. `nil` when paused, live, or of
    /// unknown length.
    var playbackInterval: ClosedRange<Date>? {
        guard isPlaying, rate > 0, let current, !current.isLive,
              let duration = current.duration, duration > current.position else { return nil }
        return date.addingTimeInterval(-current.position / rate)...date.addingTimeInterval((duration - current.position) / rate)
    }
}

/// The App Group container the app and the widget extension share.
enum WidgetStore {
    static let appGroup = "group.eu.cythin.skingomz"

    enum Kind {
        static let nowPlaying = "eu.cythin.skingomz.now-playing"
        static let upNext = "eu.cythin.skingomz.up-next"
        static let playback = "eu.cythin.skingomz.playback"
    }

    private static var container: URL? {
        FileManager.default.containerURL(forSecurityApplicationGroupIdentifier: appGroup)
    }

    private static var snapshotURL: URL? {
        container?.appendingPathComponent("WidgetSnapshot.json")
    }

    static var artworkDirectory: URL? {
        container?.appendingPathComponent("WidgetArtwork", isDirectory: true)
    }

    static func artworkURL(_ name: String?) -> URL? {
        name.flatMap { artworkDirectory?.appendingPathComponent($0) }
    }

    /// The last snapshot the app wrote, or an empty one.
    static func read() -> WidgetSnapshot {
        guard let url = snapshotURL,
              let data = try? Data(contentsOf: url),
              let snapshot = try? JSONDecoder().decode(WidgetSnapshot.self, from: data) else {
            return .empty
        }
        return snapshot
    }

    static func write(_ snapshot: WidgetSnapshot) throws {
        guard let url = snapshotURL else { return }
        try JSONEncoder().encode(snapshot).write(to: url, options: .atomic)
    }
}
Loading