Commit ecc5b103 authored by Kourser's avatar Kourser
Browse files

feat(macos): three-bar Touch Bar — idle strip only, whole-bar swaps

The popover kept being dismissed by the app's own synced navigation
(responder churn re-resolves the bar under it), and the idle placeholders
were unwanted. New architecture assembling only hardware-validated
combinations, one NSTouchBar instance per level, swapped whole via
NSApp.touchBar on state transitions:

- idle: the subscriptions strip alone, full width (low-priority stretch
  — validated solo);
- playing: transport + title + position + strip capped at 320 pt
  (validated together);
- episodes: back button + the podcast's latest episodes (strip-alone
  shape again).

Each bar's structure is fixed for its whole life: no identifier
mutation, no hidden views, no popover left to dismiss. Content updates
are change-guarded to avoid re-rendering the displayed bar, and the two
podcast-strip instances (idle/playing) share data, artwork cache and
in-place image updates.

Co-Authored-By: Claude (RCA)
parent 51888639
Loading
Loading
Loading
Loading
+128 −83
Original line number Diff line number Diff line
@@ -3,20 +3,21 @@ import AppKit
import PodcastModel
import PlaybackKit

/// AppKit Touch Bar with two levels:
/// AppKit Touch Bar with three levels, each its own bar instance, swapped
/// whole via `NSApp.touchBar`:
///
/// - **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.
/// - **Repos** : uniquement le scrubber des abonnements (pochettes), pleine
///   largeur — la configuration validée sur matériel.
/// - **Lecture** : transport + titre + position + pochettes (plafond 320 pt) —
///   l'autre configuration validée.
/// - **Épisodes** : bouton retour + derniers épisodes du podcast choisi ;
///   un tap lance la lecture. Synchronisé avec le volet de l'app.
///
/// 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.
/// Hard-learned rules: never mutate a live bar's identifiers (black bar),
/// never hide/zero-width its views (blank bar), and no popover items (they
/// get dismissed by the app's own navigation). Whole-bar swaps on state
/// transitions are the one remaining mechanism — each bar keeps a fixed
/// structure its whole life.
@MainActor
final class TouchBarController: NSObject {
    private let model: AppModel
@@ -24,11 +25,18 @@ final class TouchBarController: NSObject {
    private let settings: PlaybackSettings
    private let commands: CommandCenter

    private enum Level { case idle, playing, episodes }
    private var displayed: Level?

    private var idleBar: NSTouchBar?
    private var playingBar: NSTouchBar?
    private var episodesBar: NSTouchBar?

    private var playPauseItem: NSButtonTouchBarItem?
    private var titleLabel: NSTextField?
    private var positionItem: NSSliderTouchBarItem?
    private var podcastScrubber: NSScrubber?
    private var episodesPopover: NSPopoverTouchBarItem?
    /// One instance per bar containing the strip (idle + playing).
    private var podcastScrubbers: [NSScrubber] = []
    private var episodeScrubber: NSScrubber?

    private var podcasts: [Podcast] = []
@@ -36,8 +44,10 @@ final class TouchBarController: NSObject {
    private var episodesPodcastID: UUID?
    private var artwork: [UUID: NSImage] = [:]
    private var artworkRequests: Set<UUID> = []
    /// Last `commands.visiblePodcastID` applied to the bar's mode.
    /// Last `commands.visiblePodcastID` applied to the bar's level.
    private var syncedPodcastID: UUID?
    private var lastTitle = ""
    private var lastPlayingIcon: Bool?

    private static let podcastCell = NSUserInterfaceItemIdentifier("podcast-artwork")
    private static let episodeCell = NSUserInterfaceItemIdentifier("episode-title")
@@ -54,20 +64,56 @@ final class TouchBarController: NSObject {
        observe()
    }

    /// 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.
    /// Installs the bar matching the current state; the application object
    /// sits at the end of the Touch Bar responder chain, so `NSApp.touchBar`
    /// is the fallback for every view.
    func install() {
        let bar = NSTouchBar()
        bar.delegate = self
        bar.defaultItemIdentifiers = [.skingomzSkipBack, .skingomzPlayPause, .skingomzSkipForward,
        applyLevel()
    }

    // MARK: Levels

    private var targetLevel: Level {
        if episodesPodcastID != nil { return .episodes }
        return playback.currentEpisode != nil ? .playing : .idle
    }

    /// Swaps the displayed bar when the state's level changed. Bars are
    /// created once and reused; their structure never changes.
    private func applyLevel() {
        let target = targetLevel
        guard target != displayed else { return }
        displayed = target
        switch target {
        case .idle:
            if idleBar == nil { idleBar = makeBar([.skingomzPodcasts]) }
            NSApp.touchBar = idleBar
        case .playing:
            if playingBar == nil {
                playingBar = makeBar([.skingomzSkipBack, .skingomzPlayPause, .skingomzSkipForward,
                                      .skingomzTitle, .skingomzPosition,
                                      .fixedSpaceSmall, .skingomzPodcasts,
                                      .skingomzEpisodesPopover]
        NSApp.touchBar = bar
                                      .fixedSpaceSmall, .skingomzPodcastsCapped])
            }
            NSApp.touchBar = playingBar
        case .episodes:
            if episodesBar == nil { episodesBar = makeBar([.skingomzBack, .skingomzEpisodes]) }
            NSApp.touchBar = episodesBar
        }
        // Leaving the episodes level: clear the picker selections.
        if target != .episodes {
            episodeScrubber?.selectedIndex = -1
            for scrubber in podcastScrubbers { scrubber.selectedIndex = -1 }
        }
    }

    // MARK: Episodes level (popover over the main bar)
    private func makeBar(_ identifiers: [NSTouchBarItem.Identifier]) -> NSTouchBar {
        let bar = NSTouchBar()
        bar.delegate = self
        bar.defaultItemIdentifiers = identifiers
        return bar
    }

    /// Drill-down: shows the latest episodes of `podcast`.
    private func showEpisodes(of podcast: Podcast) {
        if episodesPodcastID != podcast.id {
            episodesPodcastID = podcast.id
@@ -81,16 +127,7 @@ final class TouchBarController: NSObject {
                self.episodeScrubber?.reloadData()
            }
        }
        // Deferred presentation: opening synchronously raced with the app's
        // own navigation to the podcast page (responder churn dismissed the
        // popover right after it appeared).
        Task { @MainActor [weak self] in
            try? await Task.sleep(for: .milliseconds(250))
            guard let self, self.episodesPodcastID == podcast.id,
                  let popover = self.episodesPopover,
                  !popover.popoverTouchBar.isVisible else { return }
            popover.showPopover(nil)
        }
        applyLevel()
    }

    @objc private func backToLibrary() {
@@ -100,13 +137,7 @@ final class TouchBarController: NSObject {

    private func returnToLibrary() {
        episodesPodcastID = nil
        if let popover = episodesPopover, popover.popoverTouchBar.isVisible {
            popover.dismissPopover(nil)
        }
        // Selections reset here, once the popover is closed — mutating the
        // scrubbers around the presentation dismissed it.
        podcastScrubber?.selectedIndex = -1
        episodeScrubber?.selectedIndex = -1
        applyLevel()
    }

    // MARK: Observation (re-armed after each change)
@@ -129,22 +160,29 @@ final class TouchBarController: NSObject {
    }

    private func refresh() {
        let hasEpisode = playback.currentEpisode != nil
        // Content updates are change-guarded: rewriting identical values into
        // the displayed bar causes needless re-rendering.
        if playback.isPlaying != lastPlayingIcon {
            lastPlayingIcon = playback.isPlaying
            playPauseItem?.image = Self.symbol(playback.isPlaying ? "pause.fill" : "play.fill")
        titleLabel?.stringValue = playback.currentEpisode?.title ?? ""

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

        // Follow the app's detail pane (and our own taps, routed through the
        // same shared state): podcast page visible -> its episode level.
        let visible = commands.visiblePodcastID
@@ -161,8 +199,10 @@ final class TouchBarController: NSObject {

        if podcasts.map(\.id) != model.podcasts.map(\.id) {
            podcasts = model.podcasts
            podcastScrubber?.reloadData()
            for scrubber in podcastScrubbers { scrubber.reloadData() }
        }

        applyLevel()
    }

    // MARK: Actions
@@ -222,45 +262,50 @@ extension TouchBarController: NSTouchBarDelegate {
                                        image: Self.symbol("chevron.backward"),
                                        target: self, action: #selector(backToLibrary))
        case .skingomzPodcasts:
            // Exactly the layout validated on hardware: permanent 320 pt cap,
            // no stretching constraint (the width games blanked the bar).
            // Idle bar: the strip alone, soaking up the whole width — the
            // combination validated on hardware (low-priority stretch).
            let item = NSCustomTouchBarItem(identifier: identifier)
            let scrubber = makeScrubber()
            scrubber.register(NSScrubberImageItemView.self, forItemIdentifier: Self.podcastCell)
            let layout = NSScrubberFlowLayout()
            layout.itemSize = NSSize(width: 36, height: 30)
            layout.itemSpacing = 4
            scrubber.scrubberLayout = layout
            let scrubber = makePodcastScrubber()
            let stretch = scrubber.widthAnchor.constraint(equalToConstant: 1200)
            stretch.priority = .defaultLow
            stretch.isActive = true
            item.view = scrubber
            return item
        case .skingomzPodcastsCapped:
            // Playing bar: the strip next to the transport, capped at 320 pt —
            // the other combination validated on hardware.
            let item = NSCustomTouchBarItem(identifier: identifier)
            let scrubber = makePodcastScrubber()
            scrubber.widthAnchor.constraint(lessThanOrEqualToConstant: 320).isActive = true
            item.view = scrubber
            podcastScrubber = scrubber
            return item
        case .skingomzEpisodes:
            // Same proven shape as the podcasts strip: a plain cap, no
            // stretching constraint (those blanked the bar).
            let item = NSCustomTouchBarItem(identifier: identifier)
            let scrubber = makeScrubber()
            scrubber.register(NSScrubberTextItemView.self, forItemIdentifier: Self.episodeCell)
            scrubber.scrubberLayout = NSScrubberFlowLayout() // sized per title below
            scrubber.widthAnchor.constraint(lessThanOrEqualToConstant: 800).isActive = true
            let stretch = scrubber.widthAnchor.constraint(equalToConstant: 1200)
            stretch.priority = .defaultLow
            stretch.isActive = true
            item.view = scrubber
            episodeScrubber = scrubber
            return item
        case .skingomzEpisodesPopover:
            // Standard collapsed button (system-managed); the popover is also
            // opened programmatically when an artwork is tapped.
            let item = NSPopoverTouchBarItem(identifier: identifier)
            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:
            return nil
        }
    }

    private func makePodcastScrubber() -> NSScrubber {
        let scrubber = makeScrubber()
        scrubber.register(NSScrubberImageItemView.self, forItemIdentifier: Self.podcastCell)
        let layout = NSScrubberFlowLayout()
        layout.itemSize = NSSize(width: 36, height: 30)
        layout.itemSpacing = 4
        scrubber.scrubberLayout = layout
        podcastScrubbers.append(scrubber)
        return scrubber
    }

    private func makeScrubber() -> NSScrubber {
        let scrubber = NSScrubber()
        scrubber.dataSource = self
@@ -311,8 +356,8 @@ extension TouchBarController: NSScrubberDataSource, NSScrubberDelegate,
            guard episodes.indices.contains(index) else { return }
            let episode = episodes[index]
            Task { await model.play(episode) }
            // Show the now-playing level; the app stays on the podcast page.
            // Forget the synced id so re-tapping the same artwork drills in.
            // Back to the now-playing level; the app stays on the podcast
            // page. Forget the synced id so re-tapping the artwork drills in.
            returnToLibrary()
            syncedPodcastID = nil
        } else {
@@ -322,10 +367,8 @@ extension TouchBarController: NSScrubberDataSource, NSScrubberDelegate,
                commands.openPodcastID = podcast.id // plays the station
            } else {
                // 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).
                // level (via the observation-driven sync).
                commands.visiblePodcastID = podcast.id
                showEpisodes(of: podcast)
            }
        }
    }
@@ -339,15 +382,17 @@ extension TouchBarController: NSScrubberDataSource, NSScrubberDelegate,
            guard let (data, _) = try? await URLSession.shared.data(from: url) else { return }
            guard let self, let image = NSImage(data: data) else { return }
            self.artwork[podcast.id] = image
            // Update the visible cell in place: a reloadData here would reset
            // Update visible cells in place: a reloadData here would reset
            // the scrubber's scroll position on every downloaded artwork.
            if let index = self.podcasts.firstIndex(where: { $0.id == podcast.id }),
               let view = self.podcastScrubber?.itemViewForItem(at: index) as? NSScrubberImageItemView {
            guard let index = self.podcasts.firstIndex(where: { $0.id == podcast.id }) else { return }
            for scrubber in self.podcastScrubbers {
                if let view = scrubber.itemViewForItem(at: index) as? NSScrubberImageItemView {
                    view.image = image
                }
            }
        }
    }
}

private extension NSTouchBarItem.Identifier {
    static let skingomzSkipBack = Self("eu.cythin.skingomz.touchbar.skip-back")
@@ -357,7 +402,7 @@ private extension NSTouchBarItem.Identifier {
    static let skingomzPosition = Self("eu.cythin.skingomz.touchbar.position")
    static let skingomzBack = Self("eu.cythin.skingomz.touchbar.back")
    static let skingomzPodcasts = Self("eu.cythin.skingomz.touchbar.podcasts")
    static let skingomzPodcastsCapped = Self("eu.cythin.skingomz.touchbar.podcasts-capped")
    static let skingomzEpisodes = Self("eu.cythin.skingomz.touchbar.episodes")
    static let skingomzEpisodesPopover = Self("eu.cythin.skingomz.touchbar.episodes-popover")
}
#endif