Commit 7f16ad05 authored by Kourser's avatar Kourser
Browse files

feat: publish artwork to the system Now Playing panel

The lock screen and Control Center only ever got a title, a position and a
duration, so they showed no picture at all — which is what a reader meant
by missing artwork "quand je slide vers le bas".

The image is fetched once per episode rather than once per update:
playbackStateDidChange fires on every play, pause and seek, and
re-downloading a 1400px cover each time would be absurd. The info is
published immediately without it and republished when it arrives, so the
panel is never empty while waiting. A failed download stays silent — the
system keeps its default glyph, which beats disturbing playback over a
picture.

The episode-then-show precedence now lives in one place,
AppModel.playerArtworkURL(for:), shared by the player, the mini bar and
this. It was spelled out twice after the earlier fix.

Verified on an iOS 26 simulator: published without artwork, then the
episode's own 1400x1400 cover fetched and republished with it. The
simulator's lock-screen panel did not render for me, so the final look is
worth a glance on a device.

Co-Authored-By: default avatarClaude Opus 5 <noreply@anthropic.com>
parent d9e3aaf2
Loading
Loading
Loading
Loading
+4 −0
Original line number Diff line number Diff line
@@ -41,6 +41,10 @@ adopte le [versionnage sémantique](https://semver.org/lang/fr/).
  nouveaux épisodes ne déclenchent plus de notification.

### Corrigé
- **Affiche sur l'écran verrouillé et le centre de contrôle** : le panneau
  « En cours de lecture » du système ne recevait que le titre, la position et la
  durée — jamais d'image. L'affiche de l'épisode (ou celle du podcast à défaut)
  y est désormais publiée, téléchargée une fois par épisode.
- **iOS — affiche manquante dans le lecteur** : la plupart des flux ne publient
  d'affiche qu'au niveau du podcast, pas par épisode. Le lecteur et la barre de
  lecture n'utilisaient que celle de l'épisode et restaient donc vides. Ils
+8 −0
Original line number Diff line number Diff line
@@ -304,6 +304,14 @@ final class AppModel {
        return episode.imageURL
    }

    /// Artwork for the player, the mini bar and the system Now Playing panel:
    /// the episode's own image when a feed publishes one, the show's otherwise.
    /// Deliberately the reverse of ``artworkURL(for:)``, where the show's image
    /// is what identifies a row in a cross-podcast list.
    func playerArtworkURL(for episode: Episode) -> URL? {
        episode.imageURL ?? artworkURL(for: episode)
    }

    func isSubscribed(feedURL: URL) -> Bool {
        podcasts.contains { $0.feedURL == feedURL }
    }
+1 −4
Original line number Diff line number Diff line
@@ -61,10 +61,7 @@ struct NowPlayingBar: View {

    private func info(_ episode: Episode) -> some View {
        HStack(spacing: 14) {
            // Same fallback as the full player: most feeds only publish
            // artwork at the podcast level, and a bare placeholder next to the
            // title looks like a loading failure.
            ArtworkView(url: episode.imageURL ?? model.artworkURL(for: episode), size: 44)
            ArtworkView(url: model.playerArtworkURL(for: episode), size: 44)

            VStack(alignment: .leading, spacing: 2) {
                Text(episode.title)
+60 −1
Original line number Diff line number Diff line
import Foundation
import MediaPlayer
import PlaybackKit
import PodcastModel
#if os(macOS)
import AppKit
private typealias PlatformImage = NSImage
#else
import UIKit
private typealias PlatformImage = UIImage
#endif

/// Bridges ``PlaybackController`` to the system: lock-screen / Control Center
/// remote commands in, Now Playing info out. Lives in the app layer because
@@ -9,10 +17,25 @@ import PlaybackKit
final class NowPlayingCoordinator: PlaybackObserver {
    private let controller: PlaybackController
    private let settings: PlaybackSettings
    /// Resolves the picture to publish. Injected so this stays clear of the
    /// library model, which is where the episode-then-show precedence lives.
    private let artworkURL: @MainActor (Episode) -> URL?

    init(controller: PlaybackController, settings: PlaybackSettings) {
    /// The artwork currently published, and the URL it was fetched from.
    /// `playbackStateDidChange` fires on every play, pause and seek, so the
    /// image is fetched once per episode rather than once per update.
    private var loadedArtworkURL: URL?
    private var artwork: MPMediaItemArtwork?
    private var artworkTask: Task<Void, Never>?

    init(
        controller: PlaybackController,
        settings: PlaybackSettings,
        artworkURL: @escaping @MainActor (Episode) -> URL?
    ) {
        self.controller = controller
        self.settings = settings
        self.artworkURL = artworkURL
        controller.observer = self
        configureRemoteCommands()
    }
@@ -64,9 +87,18 @@ final class NowPlayingCoordinator: PlaybackObserver {
    func playbackStateDidChange(_ controller: PlaybackController) {
        applySkipIntervals()
        guard let episode = controller.currentEpisode else {
            artworkTask?.cancel()
            artworkTask = nil
            loadedArtworkURL = nil
            artwork = nil
            MPNowPlayingInfoCenter.default().nowPlayingInfo = nil
            return
        }
        loadArtworkIfNeeded(for: episode)
        publish(episode)
    }

    private func publish(_ episode: Episode) {
        var info: [String: Any] = [
            MPMediaItemPropertyTitle: episode.title,
            MPNowPlayingInfoPropertyElapsedPlaybackTime: controller.currentTime,
@@ -75,6 +107,33 @@ final class NowPlayingCoordinator: PlaybackObserver {
        if controller.duration > 0 {
            info[MPMediaItemPropertyPlaybackDuration] = controller.duration
        }
        if let artwork {
            info[MPMediaItemPropertyArtwork] = artwork
        }
        MPNowPlayingInfoCenter.default().nowPlayingInfo = info
    }

    /// 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.
    private func loadArtworkIfNeeded(for episode: Episode) {
        let url = artworkURL(episode)
        guard url != loadedArtworkURL else { return }
        loadedArtworkURL = url
        artwork = nil
        artworkTask?.cancel()
        guard let url else { return }

        artworkTask = Task { [weak self] in
            guard let (data, _) = try? await URLSession.shared.data(from: url),
                  let image = PlatformImage(data: data),
                  !Task.isCancelled
            else { return }
            guard let self, self.loadedArtworkURL == url else { return }
            self.artwork = MPMediaItemArtwork(boundsSize: image.size) { _ in image }
            if let episode = self.controller.currentEpisode {
                self.publish(episode)
            }
        }
    }
}
+1 −5
Original line number Diff line number Diff line
@@ -17,11 +17,7 @@ struct PlayerView: View {
    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? {
        guard let episode = playback.currentEpisode else { return nil }
        // Precedence is deliberately the reverse of the cross-podcast lists:
        // there the show's image is what identifies a row, here the episode's
        // own art is the better picture when a feed bothers to publish one.
        return episode.imageURL ?? model.artworkURL(for: episode)
        playback.currentEpisode.flatMap { model.playerArtworkURL(for: $0) }
    }

    var body: some View {
Loading