Commit 2f9aa868 authored by Kourser's avatar Kourser
Browse files

feat: Siri, Shortcuts and the Action button

Three App Intents — resume, pause, and play a show's latest episode — are
offered as App Shortcuts: in Spotlight and the Shortcuts app with no setup,
to the Action button, and to Siri by phrase ("Reprendre Skingomz",
"Écouter Le Collimateur dans Skingomz"). Subscriptions are the entity
Siri matches spoken show names against; radios are left out, "the latest
episode" meaning nothing for a live stream.

They are AudioPlaybackIntents, which the system runs inside the app's own
process, launching it in the background when it is not running. That
launch opens no window, so the setup that lived in the window's .task
never ran: playback was not wired to the model and the library was not
loaded. It moves to AppServices, started once by whichever entry point
arrives first — the window or an intent — and registered with
AppDependencyManager before any scene exists.

Localized in the 23 other app languages; the Siri phrases in the 13 of
them Siri understands App Shortcuts in.

Verified on an iOS 27 simulator, app terminated beforehand: "Le
Collimateur" and "Reprendre" from the Shortcuts app each launched the
app in the background, started the services and began playback of the
right episode at its saved position. The iOS 26.5 simulator's linkd
rejects every ad-hoc signed app ("Unable to get teamId"), so intents
cannot be tried there at all.

Co-Authored-By: default avatarClaude Opus 5.5 <noreply@anthropic.com>
parent cd0aed2f
Loading
Loading
Loading
Loading
+6 −0
Original line number Diff line number Diff line
@@ -8,6 +8,12 @@ adopte le [versionnage sémantique](https://semver.org/lang/fr/).
## [Non publié]

### Ajouté
- **Siri, Raccourcis et bouton Action** : « Reprendre la lecture », « Mettre en
  pause » et « Écouter le dernier épisode » d'un podcast sont proposés sans
  aucun réglage dans Spotlight, dans l'app Raccourcis et, sur iPhone, pour le
  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.
- **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
+114 −0
Original line number Diff line number Diff line
import Foundation
import PlaybackKit
import PodcastModel

/// The objects every entry point into the app needs, and the one-time setup
/// that makes them usable.
///
/// The window is no longer the only way in: Siri, the Shortcuts app, the
/// Action button and widget buttons run App Intents, and an intent can start
/// the app in the background with no window at all. The setup used to live in
/// the window's `.task`, which never runs in that case — playback was not wired
/// to the model and the library was not loaded. It lives here now, started by
/// whichever entry point arrives first.
@MainActor
final class AppServices {
    let model: AppModel
    let playback: PlaybackController
    let playbackSettings: PlaybackSettings

    private var nowPlaying: NowPlayingCoordinator?
    #if os(iOS)
    private var interruptions: AudioInterruptionHandler?
    #endif
    private var startTask: Task<Void, Never>?

    init() {
        model = AppModel.makeDefault()
        playback = PlaybackController(engine: SwitchingAudioEngine())
        playbackSettings = PlaybackSettings()
    }

    /// Wires playback to the model and loads the library, once. Every entry
    /// point calls it; later callers simply wait for the first to finish.
    func start() async {
        if let startTask {
            return await startTask.value
        }
        let task = Task { await performStart() }
        startTask = task
        await task.value
    }

    private func performStart() async {
        model.configure(playback: playback)
        playback.setVolume(playbackSettings.engineVolume)
        let model = model
        let playback = playback
        nowPlaying = NowPlayingCoordinator(
            controller: playback,
            settings: playbackSettings,
            metadata: { episode in
                let chapters = model.currentChapters
                let chapter = model.currentChapter(at: playback.currentTime).flatMap { current in
                    chapters.firstIndex(of: current).map {
                        NowPlayingChapter(number: $0, count: chapters.count, title: current.title)
                    }
                }
                return NowPlayingMetadata(
                    artworkURL: model.playerArtworkURL(for: episode),
                    showTitle: model.podcast(for: episode)?.title,
                    isLiveStream: model.isRadioEpisode(episode),
                    chapter: chapter
                )
            }
        )
        #if os(iOS)
        interruptions = AudioInterruptionHandler(playback: playback)
        #endif
        await model.start()
    }

    // MARK: Actions shared by intents, widgets and controls

    enum ActionError: Error, CustomLocalizedStringResourceConvertible {
        case nothingToResume
        case noEpisode

        var localizedStringResource: LocalizedStringResource {
            switch self {
            case .nothingToResume: "Aucun épisode à reprendre."
            case .noEpisode: "Ce podcast n'a pas encore d'épisode."
            }
        }
    }

    /// Resumes the episode loaded in the player, or else the one listened to
    /// most recently, where it was left.
    func resume() async throws {
        await start()
        if playback.currentEpisode != nil {
            if !playback.isPlaying { playback.resume() }
            return
        }
        guard let episode = await model.recentlyPlayed().first else {
            throw ActionError.nothingToResume
        }
        await model.play(episode)
    }

    func pause() async {
        await start()
        if playback.isPlaying { playback.pause() }
    }

    /// Plays a show's newest episode — from where it was left, if it was started.
    func playLatest(of podcastID: UUID) async throws {
        await start()
        guard let podcast = model.podcasts.first(where: { $0.id == podcastID }),
              let episode = await model.episodes(for: podcast).first else {
            throw ActionError.noEpisode
        }
        await model.play(episode)
    }
}
+580 −0
Original line number Diff line number Diff line
{
  "sourceLanguage" : "fr",
  "strings" : {
    "Reprendre ${applicationName}" : {
      "localizations" : {
        "da" : {
          "stringUnit" : {
            "state" : "translated",
            "value" : "Genoptag ${applicationName}"
          }
        },
        "de" : {
          "stringUnit" : {
            "state" : "translated",
            "value" : "${applicationName} fortsetzen"
          }
        },
        "en" : {
          "stringUnit" : {
            "state" : "translated",
            "value" : "Resume ${applicationName}"
          }
        },
        "es" : {
          "stringUnit" : {
            "state" : "translated",
            "value" : "Reanudar ${applicationName}"
          }
        },
        "fi" : {
          "stringUnit" : {
            "state" : "translated",
            "value" : "Jatka kuuntelua appissa ${applicationName}"
          }
        },
        "it" : {
          "stringUnit" : {
            "state" : "translated",
            "value" : "Riprendi ${applicationName}"
          }
        },
        "ja" : {
          "stringUnit" : {
            "state" : "translated",
            "value" : "${applicationName}を再開"
          }
        },
        "ko" : {
          "stringUnit" : {
            "state" : "translated",
            "value" : "${applicationName} 재개"
          }
        },
        "nl" : {
          "stringUnit" : {
            "state" : "translated",
            "value" : "${applicationName} hervatten"
          }
        },
        "pt-BR" : {
          "stringUnit" : {
            "state" : "translated",
            "value" : "Retomar ${applicationName}"
          }
        },
        "ru" : {
          "stringUnit" : {
            "state" : "translated",
            "value" : "Продолжить в ${applicationName}"
          }
        },
        "sv" : {
          "stringUnit" : {
            "state" : "translated",
            "value" : "Återuppta ${applicationName}"
          }
        },
        "zh-Hans" : {
          "stringUnit" : {
            "state" : "translated",
            "value" : "继续播放${applicationName}"
          }
        }
      }
    },
    "Reprendre la lecture dans ${applicationName}" : {
      "localizations" : {
        "da" : {
          "stringUnit" : {
            "state" : "translated",
            "value" : "Genoptag afspilningen i ${applicationName}"
          }
        },
        "de" : {
          "stringUnit" : {
            "state" : "translated",
            "value" : "Wiedergabe in ${applicationName} fortsetzen"
          }
        },
        "en" : {
          "stringUnit" : {
            "state" : "translated",
            "value" : "Resume playback in ${applicationName}"
          }
        },
        "es" : {
          "stringUnit" : {
            "state" : "translated",
            "value" : "Reanudar la reproducción en ${applicationName}"
          }
        },
        "fi" : {
          "stringUnit" : {
            "state" : "translated",
            "value" : "Jatka toistoa appissa ${applicationName}"
          }
        },
        "it" : {
          "stringUnit" : {
            "state" : "translated",
            "value" : "Riprendi la riproduzione in ${applicationName}"
          }
        },
        "ja" : {
          "stringUnit" : {
            "state" : "translated",
            "value" : "${applicationName}で再生を再開"
          }
        },
        "ko" : {
          "stringUnit" : {
            "state" : "translated",
            "value" : "${applicationName}에서 재생 재개"
          }
        },
        "nl" : {
          "stringUnit" : {
            "state" : "translated",
            "value" : "Afspelen hervatten in ${applicationName}"
          }
        },
        "pt-BR" : {
          "stringUnit" : {
            "state" : "translated",
            "value" : "Retomar a reprodução no ${applicationName}"
          }
        },
        "ru" : {
          "stringUnit" : {
            "state" : "translated",
            "value" : "Продолжить воспроизведение в ${applicationName}"
          }
        },
        "sv" : {
          "stringUnit" : {
            "state" : "translated",
            "value" : "Återuppta uppspelningen i ${applicationName}"
          }
        },
        "zh-Hans" : {
          "stringUnit" : {
            "state" : "translated",
            "value" : "在${applicationName}中继续播放"
          }
        }
      }
    },
    "Reprends mon podcast dans ${applicationName}" : {
      "localizations" : {
        "da" : {
          "stringUnit" : {
            "state" : "translated",
            "value" : "Genoptag min podcast i ${applicationName}"
          }
        },
        "de" : {
          "stringUnit" : {
            "state" : "translated",
            "value" : "Meinen Podcast in ${applicationName} fortsetzen"
          }
        },
        "en" : {
          "stringUnit" : {
            "state" : "translated",
            "value" : "Resume my podcast in ${applicationName}"
          }
        },
        "es" : {
          "stringUnit" : {
            "state" : "translated",
            "value" : "Reanuda mi podcast en ${applicationName}"
          }
        },
        "fi" : {
          "stringUnit" : {
            "state" : "translated",
            "value" : "Jatka podcastiani appissa ${applicationName}"
          }
        },
        "it" : {
          "stringUnit" : {
            "state" : "translated",
            "value" : "Riprendi il mio podcast in ${applicationName}"
          }
        },
        "ja" : {
          "stringUnit" : {
            "state" : "translated",
            "value" : "${applicationName}でポッドキャストを再開して"
          }
        },
        "ko" : {
          "stringUnit" : {
            "state" : "translated",
            "value" : "${applicationName}에서 내 팟캐스트 이어 듣기"
          }
        },
        "nl" : {
          "stringUnit" : {
            "state" : "translated",
            "value" : "Mijn podcast hervatten in ${applicationName}"
          }
        },
        "pt-BR" : {
          "stringUnit" : {
            "state" : "translated",
            "value" : "Retome meu podcast no ${applicationName}"
          }
        },
        "ru" : {
          "stringUnit" : {
            "state" : "translated",
            "value" : "Продолжи мой подкаст в ${applicationName}"
          }
        },
        "sv" : {
          "stringUnit" : {
            "state" : "translated",
            "value" : "Återuppta min podd i ${applicationName}"
          }
        },
        "zh-Hans" : {
          "stringUnit" : {
            "state" : "translated",
            "value" : "在${applicationName}中继续收听我的播客"
          }
        }
      }
    },
    "Mettre ${applicationName} en pause" : {
      "localizations" : {
        "da" : {
          "stringUnit" : {
            "state" : "translated",
            "value" : "Sæt ${applicationName} på pause"
          }
        },
        "de" : {
          "stringUnit" : {
            "state" : "translated",
            "value" : "${applicationName} pausieren"
          }
        },
        "en" : {
          "stringUnit" : {
            "state" : "translated",
            "value" : "Pause ${applicationName}"
          }
        },
        "es" : {
          "stringUnit" : {
            "state" : "translated",
            "value" : "Pausar ${applicationName}"
          }
        },
        "fi" : {
          "stringUnit" : {
            "state" : "translated",
            "value" : "Keskeytä toisto appissa ${applicationName}"
          }
        },
        "it" : {
          "stringUnit" : {
            "state" : "translated",
            "value" : "Metti in pausa ${applicationName}"
          }
        },
        "ja" : {
          "stringUnit" : {
            "state" : "translated",
            "value" : "${applicationName}を一時停止"
          }
        },
        "ko" : {
          "stringUnit" : {
            "state" : "translated",
            "value" : "${applicationName} 일시정지"
          }
        },
        "nl" : {
          "stringUnit" : {
            "state" : "translated",
            "value" : "${applicationName} pauzeren"
          }
        },
        "pt-BR" : {
          "stringUnit" : {
            "state" : "translated",
            "value" : "Pausar ${applicationName}"
          }
        },
        "ru" : {
          "stringUnit" : {
            "state" : "translated",
            "value" : "Поставить ${applicationName} на паузу"
          }
        },
        "sv" : {
          "stringUnit" : {
            "state" : "translated",
            "value" : "Pausa ${applicationName}"
          }
        },
        "zh-Hans" : {
          "stringUnit" : {
            "state" : "translated",
            "value" : "暂停${applicationName}"
          }
        }
      }
    },
    "Pause dans ${applicationName}" : {
      "localizations" : {
        "da" : {
          "stringUnit" : {
            "state" : "translated",
            "value" : "Pause i ${applicationName}"
          }
        },
        "de" : {
          "stringUnit" : {
            "state" : "translated",
            "value" : "Wiedergabe in ${applicationName} pausieren"
          }
        },
        "en" : {
          "stringUnit" : {
            "state" : "translated",
            "value" : "Pause playback in ${applicationName}"
          }
        },
        "es" : {
          "stringUnit" : {
            "state" : "translated",
            "value" : "Pausar la reproducción en ${applicationName}"
          }
        },
        "fi" : {
          "stringUnit" : {
            "state" : "translated",
            "value" : "Tauko appissa ${applicationName}"
          }
        },
        "it" : {
          "stringUnit" : {
            "state" : "translated",
            "value" : "Pausa in ${applicationName}"
          }
        },
        "ja" : {
          "stringUnit" : {
            "state" : "translated",
            "value" : "${applicationName}で一時停止"
          }
        },
        "ko" : {
          "stringUnit" : {
            "state" : "translated",
            "value" : "${applicationName}에서 일시정지"
          }
        },
        "nl" : {
          "stringUnit" : {
            "state" : "translated",
            "value" : "Afspelen pauzeren in ${applicationName}"
          }
        },
        "pt-BR" : {
          "stringUnit" : {
            "state" : "translated",
            "value" : "Pausar a reprodução no ${applicationName}"
          }
        },
        "ru" : {
          "stringUnit" : {
            "state" : "translated",
            "value" : "Пауза в ${applicationName}"
          }
        },
        "sv" : {
          "stringUnit" : {
            "state" : "translated",
            "value" : "Pausa uppspelningen i ${applicationName}"
          }
        },
        "zh-Hans" : {
          "stringUnit" : {
            "state" : "translated",
            "value" : "在${applicationName}中暂停"
          }
        }
      }
    },
    "Écouter ${podcast} dans ${applicationName}" : {
      "localizations" : {
        "da" : {
          "stringUnit" : {
            "state" : "translated",
            "value" : "Lyt til ${podcast} i ${applicationName}"
          }
        },
        "de" : {
          "stringUnit" : {
            "state" : "translated",
            "value" : "${podcast} in ${applicationName} abspielen"
          }
        },
        "en" : {
          "stringUnit" : {
            "state" : "translated",
            "value" : "Play ${podcast} in ${applicationName}"
          }
        },
        "es" : {
          "stringUnit" : {
            "state" : "translated",
            "value" : "Escuchar ${podcast} en ${applicationName}"
          }
        },
        "fi" : {
          "stringUnit" : {
            "state" : "translated",
            "value" : "Kuuntele ${podcast} appissa ${applicationName}"
          }
        },
        "it" : {
          "stringUnit" : {
            "state" : "translated",
            "value" : "Ascolta ${podcast} in ${applicationName}"
          }
        },
        "ja" : {
          "stringUnit" : {
            "state" : "translated",
            "value" : "${applicationName}で${podcast}を聴く"
          }
        },
        "ko" : {
          "stringUnit" : {
            "state" : "translated",
            "value" : "${applicationName}에서 ${podcast} 듣기"
          }
        },
        "nl" : {
          "stringUnit" : {
            "state" : "translated",
            "value" : "Luister naar ${podcast} in ${applicationName}"
          }
        },
        "pt-BR" : {
          "stringUnit" : {
            "state" : "translated",
            "value" : "Ouvir ${podcast} no ${applicationName}"
          }
        },
        "ru" : {
          "stringUnit" : {
            "state" : "translated",
            "value" : "Слушать ${podcast} в ${applicationName}"
          }
        },
        "sv" : {
          "stringUnit" : {
            "state" : "translated",
            "value" : "Lyssna på ${podcast} i ${applicationName}"
          }
        },
        "zh-Hans" : {
          "stringUnit" : {
            "state" : "translated",
            "value" : "在${applicationName}中收听${podcast}"
          }
        }
      }
    },
    "Dernier épisode de ${podcast} dans ${applicationName}" : {
      "localizations" : {
        "da" : {
          "stringUnit" : {
            "state" : "translated",
            "value" : "Seneste episode af ${podcast} i ${applicationName}"
          }
        },
        "de" : {
          "stringUnit" : {
            "state" : "translated",
            "value" : "Neueste Episode von ${podcast} in ${applicationName}"
          }
        },
        "en" : {
          "stringUnit" : {
            "state" : "translated",
            "value" : "Latest episode of ${podcast} in ${applicationName}"
          }
        },
        "es" : {
          "stringUnit" : {
            "state" : "translated",
            "value" : "Último episodio de ${podcast} en ${applicationName}"
          }
        },
        "fi" : {
          "stringUnit" : {
            "state" : "translated",
            "value" : "Podcastin ${podcast} uusin jakso appissa ${applicationName}"
          }
        },
        "it" : {
          "stringUnit" : {
            "state" : "translated",
            "value" : "Ultimo episodio di ${podcast} in ${applicationName}"
          }
        },
        "ja" : {
          "stringUnit" : {
            "state" : "translated",
            "value" : "${applicationName}で${podcast}の最新エピソード"
          }
        },
        "ko" : {
          "stringUnit" : {
            "state" : "translated",
            "value" : "${applicationName}에서 ${podcast} 최신 에피소드"
          }
        },
        "nl" : {
          "stringUnit" : {
            "state" : "translated",
            "value" : "Nieuwste aflevering van ${podcast} in ${applicationName}"
          }
        },
        "pt-BR" : {
          "stringUnit" : {
            "state" : "translated",
            "value" : "Último episódio de ${podcast} no ${applicationName}"
          }
        },
        "ru" : {
          "stringUnit" : {
            "state" : "translated",
            "value" : "Последний выпуск ${podcast} в ${applicationName}"
          }
        },
        "sv" : {
          "stringUnit" : {
            "state" : "translated",
            "value" : "Senaste avsnittet av ${podcast} i ${applicationName}"
          }
        },
        "zh-Hans" : {
          "stringUnit" : {
            "state" : "translated",
            "value" : "在${applicationName}中播放${podcast}的最新单集"
          }
        }
      }
    }
  },
  "version" : "1.0"
}
 No newline at end of file
+1707 −0

File changed.

Preview size limit exceeded, changes collapsed.

+147 −0

File added.

Preview size limit exceeded, changes collapsed.

Loading