Commit 58291a4d authored by Kourser's avatar Kourser
Browse files

feat: speed, live radio, chapters and AirPlay on the system surfaces

What the lock screen, Control Center and CarPlay were missing, and one
control the player lacked.

Speed can now be changed from the lock screen and CarPlay: the rate
command is registered with the same speeds the player offers, now a
single list in PlaybackSettings rather than a private copy in the view.

Radios are published as live streams. The lock screen drew a progress bar
for a stream that never ends; it now shows it as live, and seeking,
skipping and speed are greyed out there instead of offered and silently
ignored.

The chapter being played is published — number, count, and its title
where CarPlay shows it. The controller deliberately does not notify on
every position update, so the coordinator watches the position itself
and republishes only when a boundary is crossed.

The player gets the system AirPlay picker, iOS only: on macOS it would
need the AVPlayer PlaybackKit keeps to itself, and output is chosen from
the menu bar there anyway.

Verified on an iOS 26 simulator: 8 speeds and a default rate of 1
published; FIP published as live; chapters going from "1/3 Introduction"
to "2/3 Analyse de la situation" on their own when playback crossed the
600 s mark, with a single republish at the boundary.

Co-Authored-By: default avatarClaude Opus 5.5 <noreply@anthropic.com>
parent 25505e1c
Loading
Loading
Loading
Loading
+30 −0
Original line number Diff line number Diff line
#if os(iOS)
import AVKit
import SwiftUI

/// The system AirPlay picker, so the episode can go to a HomePod or an AirPlay
/// speaker without a detour through Control Center.
///
/// iOS only: there the picker drives the app's audio session. On macOS it needs
/// the underlying `AVPlayer`, which PlaybackKit keeps to itself, and output is
/// chosen from the menu bar anyway.
struct AirPlayButton: View {
    var body: some View {
        RoutePicker()
            .frame(width: 28, height: 28)
            .accessibilityLabel(Text(verbatim: "AirPlay"))
    }
}

private struct RoutePicker: UIViewRepresentable {
    func makeUIView(context: Context) -> AVRoutePickerView {
        let view = AVRoutePickerView()
        view.prioritizesVideoDevices = false
        view.tintColor = .secondaryLabel
        view.activeTintColor = .tintColor
        return view
    }

    func updateUIView(_ uiView: AVRoutePickerView, context: Context) {}
}
#endif
+69 −3
Original line number Diff line number Diff line
@@ -10,11 +10,21 @@ import UIKit
private typealias PlatformImage = UIImage
#endif

/// The chapter being played, as MediaPlayer counts them (zero-based).
struct NowPlayingChapter: Equatable {
    var number: Int
    var count: Int
    var title: String
}

/// What the system panel shows around the episode itself: the show's name
/// under the title, and the picture beside it.
/// under the title, the picture beside it, and what kind of audio it is.
struct NowPlayingMetadata {
    var artworkURL: URL?
    var showTitle: String?
    /// A radio stream: no duration, nothing to seek, no speed to change.
    var isLiveStream = false
    var chapter: NowPlayingChapter?
}

/// Bridges ``PlaybackController`` to the system: lock-screen / Control Center
@@ -34,6 +44,8 @@ final class NowPlayingCoordinator: PlaybackObserver {
    private var loadedArtworkURL: URL?
    private var artwork: MPMediaItemArtwork?
    private var artworkTask: Task<Void, Never>?
    /// The chapter last handed to the system, to republish only on a change.
    private var publishedChapter: NowPlayingChapter?

    init(
        controller: PlaybackController,
@@ -45,6 +57,26 @@ final class NowPlayingCoordinator: PlaybackObserver {
        self.metadata = metadata
        controller.observer = self
        configureRemoteCommands()
        observePosition()
    }

    /// Chapters change as time passes, and the controller deliberately does
    /// not notify on every position update. Watching the position here costs a
    /// comparison per tick and republishes only when a chapter boundary is
    /// crossed — which also corrects any drift in the elapsed time shown.
    private func observePosition() {
        withObservationTracking {
            _ = controller.currentTime
        } onChange: { [weak self] in
            Task { @MainActor in
                guard let self else { return }
                if let episode = self.controller.currentEpisode,
                   self.metadata(episode).chapter != self.publishedChapter {
                    self.publish(episode)
                }
                self.observePosition()
            }
        }
    }

    private func configureRemoteCommands() {
@@ -73,6 +105,15 @@ final class NowPlayingCoordinator: PlaybackObserver {
            self.controller.skipBackward(TimeInterval(self.settings.skipBackward))
            return .success
        }
        center.changePlaybackRateCommand.supportedPlaybackRates =
            PlaybackSettings.rates.map { NSNumber(value: $0) }
        center.changePlaybackRateCommand.addTarget { [weak self] event in
            guard let self, let event = event as? MPChangePlaybackRateCommandEvent else {
                return .commandFailed
            }
            self.controller.setRate(event.playbackRate)
            return .success
        }
        center.changePlaybackPositionCommand.addTarget { [weak self] event in
            guard let self, let event = event as? MPChangePlaybackPositionCommandEvent else {
                return .commandFailed
@@ -106,17 +147,31 @@ final class NowPlayingCoordinator: PlaybackObserver {
    }

    private func publish(_ episode: Episode) {
        let meta = metadata(episode)
        publishedChapter = meta.chapter
        var info: [String: Any] = [
            MPMediaItemPropertyTitle: episode.title,
            MPNowPlayingInfoPropertyElapsedPlaybackTime: controller.currentTime,
            MPNowPlayingInfoPropertyPlaybackRate: controller.isPlaying ? controller.playbackRate : 0,
            MPNowPlayingInfoPropertyDefaultPlaybackRate: 1.0,
        ]
        if controller.duration > 0 {
        if meta.isLiveStream {
            // Without this the lock screen draws a progress bar for a stream
            // that never ends.
            info[MPNowPlayingInfoPropertyIsLiveStream] = true
        } else if controller.duration > 0 {
            info[MPMediaItemPropertyPlaybackDuration] = controller.duration
        }
        if let chapter = meta.chapter {
            info[MPNowPlayingInfoPropertyChapterNumber] = chapter.number
            info[MPNowPlayingInfoPropertyChapterCount] = chapter.count
            // Shown by CarPlay and the larger system players under the show.
            info[MPMediaItemPropertyAlbumTitle] = chapter.title
        }
        applyCommandAvailability(isLiveStream: meta.isLiveStream)
        // The show's name is what the lock screen puts under the title; without
        // it the panel reads as an untitled file.
        if let showTitle = metadata(episode).showTitle {
        if let showTitle = meta.showTitle {
            info[MPMediaItemPropertyArtist] = showTitle
        }
        if let artwork {
@@ -125,6 +180,17 @@ final class NowPlayingCoordinator: PlaybackObserver {
        MPNowPlayingInfoCenter.default().nowPlayingInfo = info
    }

    /// A live stream has no position to seek to and no speed to change: greyed
    /// out rather than offered and silently ignored.
    private func applyCommandAvailability(isLiveStream: Bool) {
        let center = MPRemoteCommandCenter.shared()
        let seekable = !isLiveStream
        center.changePlaybackPositionCommand.isEnabled = seekable
        center.skipForwardCommand.isEnabled = seekable
        center.skipBackwardCommand.isEnabled = seekable
        center.changePlaybackRateCommand.isEnabled = seekable
    }

    /// Downloads the episode's picture once and republishes with it. A failure
    /// is silent on purpose: the lock screen simply keeps its default glyph,
    /// which is better than interrupting playback over a missing image.
+2 −0
Original line number Diff line number Diff line
@@ -24,6 +24,8 @@ final class PlaybackSettings {
    /// Offered interval lengths (seconds). Restricted to values with a matching
    /// `goforward.N` / `gobackward.N` SF Symbol so the glyphs stay accurate.
    static let intervalOptions = [10, 15, 30, 45, 60]
    /// Speeds offered in the player, and to the lock screen and CarPlay.
    static let rates: [Float] = [0.8, 1.0, 1.2, 1.5, 1.8, 2.0, 2.5, 3.0]

    init() {
        let f = defaults.integer(forKey: Self.forwardKey)
+6 −3
Original line number Diff line number Diff line
@@ -14,8 +14,6 @@ struct PlayerView: View {
    @State private var showingChapters = false
    @State private var showingTranscript = false

    private static let rates: [Float] = [0.8, 1.0, 1.2, 1.5, 1.8, 2.0, 2.5, 3.0]

    private var artworkURL: URL? {
        playback.currentEpisode.flatMap { model.playerArtworkURL(for: $0) }
    }
@@ -60,7 +58,12 @@ struct PlayerView: View {
            transport
            speedControl
            volumeControl
            HStack(spacing: 32) {
                sleepMenu
                #if os(iOS)
                AirPlayButton()
                #endif
            }

            Spacer(minLength: 0)
        }
@@ -154,7 +157,7 @@ struct PlayerView: View {
            }
            .help("Moins vite")
            Menu {
                ForEach(Self.rates, id: \.self) { rate in
                ForEach(PlaybackSettings.rates, id: \.self) { rate in
                    Button {
                        playback.setRate(rate)
                    } label: {
+10 −2
Original line number Diff line number Diff line
@@ -59,9 +59,17 @@ struct SkingomzApp: App {
                        controller: playback,
                        settings: playbackSettings,
                        metadata: { episode in
                            NowPlayingMetadata(
                            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
                                showTitle: model.podcast(for: episode)?.title,
                                isLiveStream: model.isRadioEpisode(episode),
                                chapter: chapter
                            )
                        }
                    )