Commit 1da522ba authored by Kourser's avatar Kourser
Browse files

fix(macos): back to a proven Touch Bar layout, episodes via a standard popover

The launch blank was caused by the layout tricks (hidden transport
views, zero-width slider and an invisible popover representation), not
by the popover mechanism. The bar returns to the configuration already
validated on hardware — transport, title and position always present
(disabled/empty while idle), subscriptions strip stretching into the
remaining space with the 320 pt cap only during playback — plus one
standard piece: an NSPopoverTouchBarItem with a system-managed
list-icon collapsed button. Artwork taps (and the sidebar sync) open
that popover programmatically; its back button clears the shared state.
The system-modal path tried in between turned out to be private API
(DFRFoundation) and was dropped.

Co-Authored-By: Claude (RCA)
parent 8f5cc515
Loading
Loading
Loading
Loading
+64 −94
Original line number Diff line number Diff line
@@ -5,19 +5,18 @@ import PlaybackKit

/// AppKit Touch Bar with two levels:
///
/// - **Bibliothèque** : transport + épisode en cours (titre, position) quand
///   quelque chose joue — masqués au repos,  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.
/// - **Bibliothèque** (barre principale, installée une fois, structure fixe) :
///   transport, titre + position de l'épisode en cours, et le scrubber des
///   abonnements (pochettes) qui absorbe tout l'espace restant — la limite de
///   320 pt ne s'active qu'en lecture.
/// - **Épisodes** : une barre modale présentée par-dessus au tap sur une
///   pochette (bouton retour + derniers épisodes) ; un tap lance la lecture
///   et referme le niveau. Une radio se lance directement.
///
/// Built on `NSTouchBar` (SwiftUI's `.touchBar` actions are unreliable and
/// `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.
/// Hard-learned rules, kept from three broken attempts: the main bar is never
/// replaced (it blanked), its identifiers are never mutated (it went black)
/// and no view is hidden/zero-width (it blanked at launch). All dynamism is
/// item *content* plus the system-modal presentation.
@MainActor
final class TouchBarController: NSObject {
    private let model: AppModel
@@ -25,15 +24,9 @@ final class TouchBarController: NSObject {
    private let settings: PlaybackSettings
    private let commands: CommandCenter

    private enum Mode { case library, episodes(Podcast) }
    private var mode: Mode = .library

    private var skipBackButton: NSButton?
    private var playPauseButton: NSButton?
    private var skipForwardButton: NSButton?
    private var playPauseItem: NSButtonTouchBarItem?
    private var titleLabel: NSTextField?
    private var positionItem: NSSliderTouchBarItem?
    private var positionCollapse: NSLayoutConstraint?
    private var podcastScrubber: NSScrubber?
    private var podcastScrubberWidthCap: NSLayoutConstraint?
    private var episodesPopover: NSPopoverTouchBarItem?
@@ -41,6 +34,7 @@ final class TouchBarController: NSObject {

    private var podcasts: [Podcast] = []
    private var episodes: [Episode] = []
    private var episodesPodcastID: UUID?
    private var artwork: [UUID: NSImage] = [:]
    private var artworkRequests: Set<UUID> = []
    /// Last `commands.visiblePodcastID` applied to the bar's mode.
@@ -73,21 +67,25 @@ final class TouchBarController: NSObject {
        NSApp.touchBar = bar
    }

    /// Drill-down: shows the latest episodes of `podcast` in the popover.
    // MARK: Episodes level (popover over the main bar)

    private func showEpisodes(of podcast: Podcast) {
        if case .episodes(let current) = mode, current.id == podcast.id { return }
        mode = .episodes(podcast)
        if episodesPodcastID != podcast.id {
            episodesPodcastID = podcast.id
            episodes = []
            episodeScrubber?.reloadData()
        episodesPopover?.showPopover(nil)
            Task { [weak self] in
                guard let self else { return }
                let list = await self.model.episodes(for: podcast)
            guard case .episodes(let current) = self.mode, current.id == podcast.id else { return }
                guard self.episodesPodcastID == podcast.id else { return }
                self.episodes = Array(list.prefix(16))
                self.episodeScrubber?.reloadData()
            }
        }
        if let popover = episodesPopover, !popover.popoverTouchBar.isVisible {
            popover.showPopover(nil)
        }
    }

    @objc private func backToLibrary() {
        // Routed through the shared state so the app's detail pane follows.
@@ -95,10 +93,10 @@ final class TouchBarController: NSObject {
    }

    private func returnToLibrary() {
        guard case .episodes = mode else { return }
        mode = .library
        episodes = []
        episodesPopover?.dismissPopover(nil)
        episodesPodcastID = nil
        if let popover = episodesPopover, popover.popoverTouchBar.isVisible {
            popover.dismissPopover(nil)
        }
    }

    // MARK: Observation (re-armed after each change)
@@ -122,28 +120,22 @@ final class TouchBarController: NSObject {

    private func refresh() {
        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")
        playPauseItem?.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).
            // Disabled while idle, and for live radio (no position to seek).
            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)
                item.slider.doubleValue = hasEpisode
                    ? min(playback.currentTime, item.slider.maxValue)
                    : 0
            }
        }
        // Idle, the strip soaks up the space the slider leaves unused.
        podcastScrubberWidthCap?.isActive = hasEpisode

        // Follow the app's detail pane (and our own taps, routed through the
@@ -184,27 +176,21 @@ 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:
            let (item, button) = Self.buttonItem(identifier, image: Self.symbol(settings.backwardSymbol),
                                                 target: self, action: #selector(skipBack),
                                                 visible: hasEpisode)
            skipBackButton = button
            return item
            return NSButtonTouchBarItem(identifier: identifier,
                                        image: Self.symbol(settings.backwardSymbol),
                                        target: self, action: #selector(skipBack))
        case .skingomzPlayPause:
            let (item, button) = Self.buttonItem(identifier,
            let item = NSButtonTouchBarItem(identifier: identifier,
                                            image: Self.symbol(playback.isPlaying ? "pause.fill" : "play.fill"),
                                                 target: self, action: #selector(togglePlayPause),
                                                 visible: hasEpisode)
            playPauseButton = button
                                            target: self, action: #selector(togglePlayPause))
            playPauseItem = item
            return item
        case .skingomzSkipForward:
            let (item, button) = Self.buttonItem(identifier, image: Self.symbol(settings.forwardSymbol),
                                                 target: self, action: #selector(skipForward),
                                                 visible: hasEpisode)
            skipForwardButton = button
            return item
            return NSButtonTouchBarItem(identifier: identifier,
                                        image: Self.symbol(settings.forwardSymbol),
                                        target: self, action: #selector(skipForward))
        case .skingomzTitle:
            let item = NSCustomTouchBarItem(identifier: identifier)
            let label = NSTextField(labelWithString: playback.currentEpisode?.title ?? "")
@@ -219,19 +205,15 @@ extension TouchBarController: NSTouchBarDelegate {
            item.slider.minValue = 0
            item.slider.maxValue = max(playback.duration, 1)
            item.slider.doubleValue = playback.currentTime
            item.slider.isEnabled = playback.currentEpisode != nil && playback.duration > 0
            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:
            let (item, _) = Self.buttonItem(identifier, image: Self.symbol("chevron.backward"),
                                            target: self, action: #selector(backToLibrary),
                                            visible: true)
            return item
            return NSButtonTouchBarItem(identifier: identifier,
                                        image: Self.symbol("chevron.backward"),
                                        target: self, action: #selector(backToLibrary))
        case .skingomzPodcasts:
            let item = NSCustomTouchBarItem(identifier: identifier)
            let scrubber = makeScrubber()
@@ -242,7 +224,7 @@ extension TouchBarController: NSTouchBarDelegate {
            scrubber.scrubberLayout = layout
            Self.stretch(scrubber)
            let cap = scrubber.widthAnchor.constraint(lessThanOrEqualToConstant: 320)
            cap.isActive = hasEpisode
            cap.isActive = playback.currentEpisode != nil
            podcastScrubberWidthCap = cap
            item.view = scrubber
            item.visibilityPriority = .low // collapses first when space is tight
@@ -258,17 +240,13 @@ extension TouchBarController: NSTouchBarDelegate {
            episodeScrubber = scrubber
            return item
        case .skingomzEpisodesPopover:
            // Invisible in the bar; expanded by program when drilling down.
            // Standard collapsed button (system-managed); the popover is also
            // opened programmatically when an artwork is tapped.
            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
            item.collapsedRepresentationImage = Self.symbol("list.bullet")
            item.showsCloseButton = false // our back button keeps the app in sync
            item.popoverTouchBar.delegate = self
            item.popoverTouchBar.defaultItemIdentifiers = [.skingomzBack, .skingomzEpisodes]
            episodesPopover = item
            return item
        default:
@@ -276,17 +254,6 @@ extension TouchBarController: NSTouchBarDelegate {
        }
    }

    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
@@ -356,8 +323,11 @@ extension TouchBarController: NSScrubberDataSource, NSScrubberDelegate, NSScrubb
            if podcast.isRadio {
                commands.openPodcastID = podcast.id // plays the station
            } else {
                // Shared state: opens the page in the app AND our episode level.
                // Shared state: opens the page in the app AND our episode
                // level. Presenting directly too heals an esc-key dismissal
                // (where the state would not change).
                commands.visiblePodcastID = podcast.id
                showEpisodes(of: podcast)
            }
        }
    }