Commit 8590ac30 authored by Kourser's avatar Kourser
Browse files

feat(macos): two-level Touch Bar — full-width strip, per-podcast episode picker

- Idle, the subscriptions strip is the only item and takes the whole
  bar; the transport, title and position items only join it while an
  episode is loaded (the width cap on the strip activates then).
- Tapping a podcast artwork drills into a second level: a back button
  on the left and a scrubber of the podcast's latest episodes (titles
  sized to fit, via NSScrubberFlowLayoutDelegate); tapping one starts
  playback and returns to the library level. Radios keep playing
  directly on tap.
- Mode switches mutate defaultItemIdentifiers on the single installed
  bar (instantiated items are cached, so the strip keeps its scroll
  position); the bar object itself is never replaced.

Co-Authored-By: Claude (RCA)
parent fac385f5
Loading
Loading
Loading
Loading
+144 −40
Original line number Diff line number Diff line
@@ -3,12 +3,19 @@ import AppKit
import PodcastModel
import PlaybackKit

/// AppKit Touch Bar: transport buttons, the current episode (title + seekable
/// position slider) while something is loaded, and a scrubber of the
/// subscriptions (artwork strip — swipe and tap to open a podcast, or play a
/// radio). Built on `NSTouchBar` rather than SwiftUI's `.touchBar` because
/// button actions there are unreliable and `NSScrubber` has no SwiftUI
/// counterpart.
/// 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.
///
/// 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.
@MainActor
final class TouchBarController: NSObject {
    private let model: AppModel
@@ -16,15 +23,26 @@ 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 bar: NSTouchBar?
    private var playPauseItem: NSButtonTouchBarItem?
    private var positionItem: NSSliderTouchBarItem?
    private var titleLabel: NSTextField?
    private var scrubber: NSScrubber?
    private var podcastScrubber: NSScrubber?
    private var podcastScrubberWidthCap: NSLayoutConstraint?
    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

    private static let scrubberCell = NSUserInterfaceItemIdentifier("podcast-artwork")
    private static let podcastCell = NSUserInterfaceItemIdentifier("podcast-artwork")
    private static let episodeCell = NSUserInterfaceItemIdentifier("episode-title")
    private static let episodeFont = NSFont.systemFont(ofSize: 15)

    init(model: AppModel, playback: PlaybackController,
         settings: PlaybackSettings, commands: CommandCenter) {
@@ -39,10 +57,50 @@ 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.
    /// The bar is installed exactly once, with a fixed item structure —
    /// replacing it live proved glitchy (controls vanishing, scrubber reset).
    func install() {
        NSApp.touchBar = makeTouchBar()
        hadEpisode = playback.currentEpisode != nil
        let bar = NSTouchBar()
        bar.delegate = self
        self.bar = bar
        applyMode()
        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`.
    private func showEpisodes(of podcast: Podcast) {
        mode = .episodes(podcast)
        episodes = []
        episodeScrubber?.reloadData()
        applyMode()
        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 }
            self.episodes = Array(list.prefix(16))
            self.episodeScrubber?.reloadData()
        }
    }

    @objc private func backToLibrary() {
        mode = .library
        episodes = []
        applyMode()
    }

    // MARK: Observation (re-armed after each change)
@@ -69,20 +127,25 @@ final class TouchBarController: NSObject {

        if let item = positionItem {
            let duration = playback.duration
            // Disabled while idle, and for live radio (no position to seek).
            // Disabled for live radio (no position to seek).
            item.slider.isEnabled = playback.currentEpisode != nil && 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 = playback.currentEpisode == nil
                    ? 0
                    : min(playback.currentTime, item.slider.maxValue)
                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() }
        }

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

@@ -102,15 +165,6 @@ final class TouchBarController: NSObject {
}

extension TouchBarController: NSTouchBarDelegate {
    private func makeTouchBar() -> NSTouchBar {
        let bar = NSTouchBar()
        bar.delegate = self
        bar.defaultItemIdentifiers = [.skingomzSkipBack, .skingomzPlayPause, .skingomzSkipForward,
                                      .skingomzTitle, .skingomzPosition,
                                      .fixedSpaceSmall, .skingomzPodcasts]
        return bar
    }

    func touchBar(_ touchBar: NSTouchBar,
                  makeItemForIdentifier identifier: NSTouchBarItem.Identifier) -> NSTouchBarItem? {
        switch identifier {
@@ -146,37 +200,62 @@ extension TouchBarController: NSTouchBarDelegate {
            item.action = #selector(positionMoved(_:))
            positionItem = item
            return item
        case .skingomzBack:
            return NSButtonTouchBarItem(identifier: identifier,
                                        image: Self.symbol("chevron.backward"),
                                        target: self, action: #selector(backToLibrary))
        case .skingomzPodcasts:
            let item = NSCustomTouchBarItem(identifier: identifier)
            let scrubber = NSScrubber()
            scrubber.register(NSScrubberImageItemView.self, forItemIdentifier: Self.scrubberCell)
            scrubber.dataSource = self
            scrubber.delegate = self
            scrubber.mode = .free
            scrubber.selectionOverlayStyle = .outlineOverlay
            scrubber.showsAdditionalContentIndicators = true
            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
            scrubber.widthAnchor.constraint(lessThanOrEqualToConstant: 320).isActive = true
            let cap = scrubber.widthAnchor.constraint(lessThanOrEqualToConstant: 320)
            cap.isActive = playback.currentEpisode != nil
            podcastScrubberWidthCap = cap
            item.view = scrubber
            item.visibilityPriority = .low // collapses first when space is tight
            self.scrubber = scrubber
            podcastScrubber = scrubber
            return item
        case .skingomzEpisodes:
            let item = NSCustomTouchBarItem(identifier: identifier)
            let scrubber = makeScrubber()
            scrubber.register(NSScrubberTextItemView.self, forItemIdentifier: Self.episodeCell)
            scrubber.scrubberLayout = NSScrubberFlowLayout() // sized per title below
            item.view = scrubber
            episodeScrubber = scrubber
            return item
        default:
            return nil
        }
    }

    private func makeScrubber() -> NSScrubber {
        let scrubber = NSScrubber()
        scrubber.dataSource = self
        scrubber.delegate = self
        scrubber.mode = .free
        scrubber.selectionOverlayStyle = .outlineOverlay
        scrubber.showsAdditionalContentIndicators = true
        return scrubber
    }
}

extension TouchBarController: NSScrubberDataSource, NSScrubberDelegate {
extension TouchBarController: NSScrubberDataSource, NSScrubberDelegate, NSScrubberFlowLayoutDelegate {
    func numberOfItems(for scrubber: NSScrubber) -> Int {
        podcasts.count
        scrubber === episodeScrubber ? episodes.count : podcasts.count
    }

    func scrubber(_ scrubber: NSScrubber, viewForItemAt index: Int) -> NSScrubberItemView {
        let view = scrubber.makeItem(withIdentifier: Self.scrubberCell, owner: nil)
        if scrubber === episodeScrubber {
            let view = scrubber.makeItem(withIdentifier: Self.episodeCell, owner: nil)
                as? NSScrubberTextItemView ?? NSScrubberTextItemView()
            view.title = episodes.indices.contains(index) ? episodes[index].title : ""
            return view
        }
        let view = scrubber.makeItem(withIdentifier: Self.podcastCell, owner: nil)
            as? NSScrubberImageItemView ?? NSScrubberImageItemView()
        let podcast = podcasts[index]
        view.image = artwork[podcast.id]
@@ -186,10 +265,33 @@ extension TouchBarController: NSScrubberDataSource, NSScrubberDelegate {
        return view
    }

    func scrubber(_ scrubber: NSScrubber, layout: NSScrubberFlowLayout,
                  sizeForItemAt itemIndex: Int) -> NSSize {
        guard scrubber === episodeScrubber else {
            return NSSize(width: 36, height: 30)
        }
        guard episodes.indices.contains(itemIndex) else { return NSSize(width: 60, height: 30) }
        let width = (episodes[itemIndex].title as NSString)
            .size(withAttributes: [.font: Self.episodeFont]).width + 24
        return NSSize(width: min(max(width, 60), 280), height: 30)
    }

    func scrubber(_ scrubber: NSScrubber, didSelectItemAt index: Int) {
        defer { scrubber.selectedIndex = -1 } // allow re-tapping the same item
        if scrubber === episodeScrubber {
            guard episodes.indices.contains(index) else { return }
            let episode = episodes[index]
            Task { await model.play(episode) }
            backToLibrary()
        } else {
            guard podcasts.indices.contains(index) else { return }
        // RootView routes: opens the podcast page, or plays a radio station.
        commands.openPodcastID = podcasts[index].id
            let podcast = podcasts[index]
            if podcast.isRadio {
                commands.openPodcastID = podcast.id // plays the station
            } else {
                showEpisodes(of: podcast)
            }
        }
    }

    private func loadArtwork(_ podcast: Podcast) {
@@ -204,7 +306,7 @@ extension TouchBarController: NSScrubberDataSource, NSScrubberDelegate {
            // Update the visible cell 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.scrubber?.itemViewForItem(at: index) as? NSScrubberImageItemView {
               let view = self.podcastScrubber?.itemViewForItem(at: index) as? NSScrubberImageItemView {
                view.image = image
            }
        }
@@ -217,6 +319,8 @@ private extension NSTouchBarItem.Identifier {
    static let skingomzSkipForward = Self("eu.cythin.skingomz.touchbar.skip-forward")
    static let skingomzTitle = Self("eu.cythin.skingomz.touchbar.title")
    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 skingomzEpisodes = Self("eu.cythin.skingomz.touchbar.episodes")
}
#endif