Commit 8f5cc515 authored by Kourser's avatar Kourser
Browse files

fix(macos): episode level as a Touch Bar popover — no identifier mutation

Mutating defaultItemIdentifiers on the installed bar rendered it black
on the first mode switch, confirming that live identifier changes are
not dependable. The bar now has a single, fixed item list installed
once; all dynamism is content-level:

- Idle, the transport buttons and the position slider are hidden (and
  the slider width-collapsed), so hidden views detach from layout and
  the subscriptions strip keeps the whole bar; they reappear while an
  episode is loaded.
- The episode level becomes an NSPopoverTouchBarItem with an invisible
  collapsed representation, presented programmatically on artwork tap
  (back button + episode titles inside its own popover bar, system
  close button disabled so our back keeps the app pane in sync).

Co-Authored-By: Claude (RCA)
parent 5119e459
Loading
Loading
Loading
Loading
+86 −56
Original line number Diff line number Diff line
@@ -6,16 +6,18 @@ import PlaybackKit
/// AppKit Touch Bar with two levels:
///
/// - **Bibliothèque** : transport + épisode en cours (titre, position) quand
///   quelque chose joue, et un scrubber des abonnements (pochettes) — pleine
///   largeur au repos. Un tap sur une pochette ouvre ses épisodes (une radio
///   se lance directement).
/// - **Épisodes** : bouton retour + les derniers épisodes du podcast choisi ;
///   un tap lance la lecture et revient à la bibliothèque.
///   quelque chose joue — masqués au repos, où le scrubber des abonnements
///   (pochettes) occupe toute la barre. Un tap sur une pochette ouvre ses
///   épisodes (une radio se lance directement).
/// - **Épisodes** : un `NSPopoverTouchBarItem` présenté par programme —
///   bouton retour + les derniers épisodes du podcast ; un tap lance la
///   lecture et referme le niveau.
///
/// Built on `NSTouchBar` (SwiftUI's `.touchBar` actions are unreliable and
/// `NSScrubber` has no SwiftUI counterpart). The bar object is installed once
/// and never replaced — modes only mutate `defaultItemIdentifiers`; replacing
/// the bar live made it vanish.
/// `NSScrubber` has no SwiftUI counterpart). Hard-learned rules: the bar is
/// installed once with a **fixed** item list — replacing the bar blanked it,
/// and mutating `defaultItemIdentifiers` live rendered it black. All dynamism
/// goes through item *content* (hidden/enabled/values) and the popover.
@MainActor
final class TouchBarController: NSObject {
    private let model: AppModel
@@ -26,19 +28,21 @@ final class TouchBarController: NSObject {
    private enum Mode { case library, episodes(Podcast) }
    private var mode: Mode = .library

    private var bar: NSTouchBar?
    private var playPauseItem: NSButtonTouchBarItem?
    private var positionItem: NSSliderTouchBarItem?
    private var skipBackButton: NSButton?
    private var playPauseButton: NSButton?
    private var skipForwardButton: NSButton?
    private var titleLabel: NSTextField?
    private var positionItem: NSSliderTouchBarItem?
    private var positionCollapse: NSLayoutConstraint?
    private var podcastScrubber: NSScrubber?
    private var podcastScrubberWidthCap: NSLayoutConstraint?
    private var episodesPopover: NSPopoverTouchBarItem?
    private var episodeScrubber: NSScrubber?

    private var podcasts: [Podcast] = []
    private var episodes: [Episode] = []
    private var artwork: [UUID: NSImage] = [:]
    private var artworkRequests: Set<UUID> = []
    private var hadEpisode = false
    /// Last `commands.visiblePodcastID` applied to the bar's mode.
    private var syncedPodcastID: UUID?

@@ -60,37 +64,22 @@ final class TouchBarController: NSObject {
    /// Installs the bar app-wide: the application object sits at the end of
    /// the Touch Bar responder chain, so this is the fallback for every view.
    func install() {
        hadEpisode = playback.currentEpisode != nil
        let bar = NSTouchBar()
        bar.delegate = self
        self.bar = bar
        applyMode()
        bar.defaultItemIdentifiers = [.skingomzSkipBack, .skingomzPlayPause, .skingomzSkipForward,
                                      .skingomzTitle, .skingomzPosition,
                                      .fixedSpaceSmall, .skingomzPodcasts,
                                      .skingomzEpisodesPopover]
        NSApp.touchBar = bar
    }

    /// Sets the items for the current mode by mutating the installed bar.
    private func applyMode() {
        guard let bar else { return }
        switch mode {
        case .library:
            let playing = playback.currentEpisode != nil
            podcastScrubberWidthCap?.isActive = playing
            bar.defaultItemIdentifiers = playing
                ? [.skingomzSkipBack, .skingomzPlayPause, .skingomzSkipForward,
                   .skingomzTitle, .skingomzPosition, .fixedSpaceSmall, .skingomzPodcasts]
                : [.skingomzPodcasts] // idle: the strip gets the whole bar
        case .episodes:
            bar.defaultItemIdentifiers = [.skingomzBack, .skingomzEpisodes]
        }
    }

    /// Drill-down: shows the latest episodes of `podcast`.
    /// Drill-down: shows the latest episodes of `podcast` in the popover.
    private func showEpisodes(of podcast: Podcast) {
        if case .episodes(let current) = mode, current.id == podcast.id { return }
        mode = .episodes(podcast)
        episodes = []
        episodeScrubber?.reloadData()
        applyMode()
        episodesPopover?.showPopover(nil)
        Task { [weak self] in
            guard let self else { return }
            let list = await self.model.episodes(for: podcast)
@@ -109,7 +98,7 @@ final class TouchBarController: NSObject {
        guard case .episodes = mode else { return }
        mode = .library
        episodes = []
        applyMode()
        episodesPopover?.dismissPopover(nil)
    }

    // MARK: Observation (re-armed after each change)
@@ -132,26 +121,30 @@ final class TouchBarController: NSObject {
    }

    private func refresh() {
        playPauseItem?.image = Self.symbol(playback.isPlaying ? "pause.fill" : "play.fill")
        let hasEpisode = playback.currentEpisode != nil

        // Idle: the transport and position collapse so the subscriptions
        // strip gets the whole bar (hidden views are detached from layout).
        for button in [skipBackButton, playPauseButton, skipForwardButton] {
            button?.isHidden = !hasEpisode
            button?.isEnabled = hasEpisode
        }
        playPauseButton?.image = Self.symbol(playback.isPlaying ? "pause.fill" : "play.fill")
        titleLabel?.stringValue = playback.currentEpisode?.title ?? ""

        if let item = positionItem {
            let duration = playback.duration
            item.slider.isHidden = !hasEpisode
            positionCollapse?.isActive = !hasEpisode
            // Disabled for live radio (no position to seek).
            item.slider.isEnabled = playback.currentEpisode != nil && duration > 0
            item.slider.isEnabled = hasEpisode && duration > 0
            item.slider.maxValue = max(duration, 1)
            // Don't fight the user's finger mid-drag.
            if item.slider.cell?.isHighlighted != true {
                item.slider.doubleValue = min(playback.currentTime, item.slider.maxValue)
            }
        }

        // The now-playing items appear/disappear with the loaded episode.
        let hasEpisode = playback.currentEpisode != nil
        if hasEpisode != hadEpisode {
            hadEpisode = hasEpisode
            if case .library = mode { applyMode() }
        }
        podcastScrubberWidthCap?.isActive = hasEpisode

        // Follow the app's detail pane (and our own taps, routed through the
        // same shared state): podcast page visible -> its episode level.
@@ -191,21 +184,27 @@ final class TouchBarController: NSObject {
extension TouchBarController: NSTouchBarDelegate {
    func touchBar(_ touchBar: NSTouchBar,
                  makeItemForIdentifier identifier: NSTouchBarItem.Identifier) -> NSTouchBarItem? {
        let hasEpisode = playback.currentEpisode != nil
        switch identifier {
        case .skingomzSkipBack:
            return NSButtonTouchBarItem(identifier: identifier,
                                        image: Self.symbol(settings.backwardSymbol),
                                        target: self, action: #selector(skipBack))
            let (item, button) = Self.buttonItem(identifier, image: Self.symbol(settings.backwardSymbol),
                                                 target: self, action: #selector(skipBack),
                                                 visible: hasEpisode)
            skipBackButton = button
            return item
        case .skingomzPlayPause:
            let item = NSButtonTouchBarItem(identifier: identifier,
            let (item, button) = Self.buttonItem(identifier,
                                                 image: Self.symbol(playback.isPlaying ? "pause.fill" : "play.fill"),
                                            target: self, action: #selector(togglePlayPause))
            playPauseItem = item
                                                 target: self, action: #selector(togglePlayPause),
                                                 visible: hasEpisode)
            playPauseButton = button
            return item
        case .skingomzSkipForward:
            return NSButtonTouchBarItem(identifier: identifier,
                                        image: Self.symbol(settings.forwardSymbol),
                                        target: self, action: #selector(skipForward))
            let (item, button) = Self.buttonItem(identifier, image: Self.symbol(settings.forwardSymbol),
                                                 target: self, action: #selector(skipForward),
                                                 visible: hasEpisode)
            skipForwardButton = button
            return item
        case .skingomzTitle:
            let item = NSCustomTouchBarItem(identifier: identifier)
            let label = NSTextField(labelWithString: playback.currentEpisode?.title ?? "")
@@ -222,12 +221,17 @@ extension TouchBarController: NSTouchBarDelegate {
            item.slider.doubleValue = playback.currentTime
            item.target = self
            item.action = #selector(positionMoved(_:))
            let collapse = item.slider.widthAnchor.constraint(equalToConstant: 0)
            collapse.isActive = !hasEpisode
            item.slider.isHidden = !hasEpisode
            positionCollapse = collapse
            positionItem = item
            return item
        case .skingomzBack:
            return NSButtonTouchBarItem(identifier: identifier,
                                        image: Self.symbol("chevron.backward"),
                                        target: self, action: #selector(backToLibrary))
            let (item, _) = Self.buttonItem(identifier, image: Self.symbol("chevron.backward"),
                                            target: self, action: #selector(backToLibrary),
                                            visible: true)
            return item
        case .skingomzPodcasts:
            let item = NSCustomTouchBarItem(identifier: identifier)
            let scrubber = makeScrubber()
@@ -238,7 +242,7 @@ extension TouchBarController: NSTouchBarDelegate {
            scrubber.scrubberLayout = layout
            Self.stretch(scrubber)
            let cap = scrubber.widthAnchor.constraint(lessThanOrEqualToConstant: 320)
            cap.isActive = playback.currentEpisode != nil
            cap.isActive = hasEpisode
            podcastScrubberWidthCap = cap
            item.view = scrubber
            item.visibilityPriority = .low // collapses first when space is tight
@@ -253,11 +257,36 @@ extension TouchBarController: NSTouchBarDelegate {
            item.view = scrubber
            episodeScrubber = scrubber
            return item
        case .skingomzEpisodesPopover:
            // Invisible in the bar; expanded by program when drilling down.
            let item = NSPopoverTouchBarItem(identifier: identifier)
            item.showsCloseButton = false // our back button handles the sync
            let empty = NSView()
            empty.translatesAutoresizingMaskIntoConstraints = false
            empty.widthAnchor.constraint(equalToConstant: 0).isActive = true
            item.collapsedRepresentation = empty
            let popoverBar = NSTouchBar()
            popoverBar.delegate = self
            popoverBar.defaultItemIdentifiers = [.skingomzBack, .skingomzEpisodes]
            item.popoverTouchBar = popoverBar
            episodesPopover = item
            return item
        default:
            return nil
        }
    }

    private static func buttonItem(_ identifier: NSTouchBarItem.Identifier, image: NSImage,
                                   target: AnyObject, action: Selector,
                                   visible: Bool) -> (NSCustomTouchBarItem, NSButton) {
        let item = NSCustomTouchBarItem(identifier: identifier)
        let button = NSButton(image: image, target: target, action: action)
        button.isHidden = !visible
        button.isEnabled = visible
        item.view = button
        return (item, button)
    }

    private func makeScrubber() -> NSScrubber {
        let scrubber = NSScrubber()
        scrubber.dataSource = self
@@ -361,5 +390,6 @@ private extension NSTouchBarItem.Identifier {
    static let skingomzBack = Self("eu.cythin.skingomz.touchbar.back")
    static let skingomzPodcasts = Self("eu.cythin.skingomz.touchbar.podcasts")
    static let skingomzEpisodes = Self("eu.cythin.skingomz.touchbar.episodes")
    static let skingomzEpisodesPopover = Self("eu.cythin.skingomz.touchbar.episodes-popover")
}
#endif