Commit 2dba0eff authored by Kourser's avatar Kourser
Browse files

fix(macos): keep the Touch Bar scrubber position, show the now-playing state

- Artwork arrivals no longer call reloadData() (which reset the
  scrubber's scroll position while swiping): the visible cell is
  updated in place via itemViewForItem(at:).
- While an episode is loaded the bar gains the episode title
  (truncated label) and a seekable position slider, fed by the observed
  currentTime/duration; the slider never fights a finger mid-drag and
  is disabled for live radio (no position). The items disappear again
  when playback is cleared, and the subscriptions strip collapses first
  (low visibility priority, capped width) when space runs out.

Co-Authored-By: Claude (RCA)
parent 2e4f0bfe
Loading
Loading
Loading
Loading
+71 −7
Original line number Diff line number Diff line
@@ -3,10 +3,12 @@ import AppKit
import PodcastModel
import PlaybackKit

/// AppKit Touch Bar: transport buttons plus 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: 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.
@MainActor
final class TouchBarController: NSObject {
    private let model: AppModel
@@ -15,10 +17,13 @@ final class TouchBarController: NSObject {
    private let commands: CommandCenter

    private var playPauseItem: NSButtonTouchBarItem?
    private var positionItem: NSSliderTouchBarItem?
    private var titleLabel: NSTextField?
    private var scrubber: NSScrubber?
    private var podcasts: [Podcast] = []
    private var artwork: [UUID: NSImage] = [:]
    private var artworkRequests: Set<UUID> = []
    private var hadEpisode = false

    private static let scrubberCell = NSUserInterfaceItemIdentifier("podcast-artwork")

@@ -36,6 +41,7 @@ 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
        NSApp.touchBar = makeTouchBar()
    }

@@ -44,6 +50,9 @@ final class TouchBarController: NSObject {
    private func observe() {
        withObservationTracking {
            _ = playback.isPlaying
            _ = playback.currentEpisode
            _ = playback.currentTime
            _ = playback.duration
            _ = model.podcasts
        } onChange: { [weak self] in
            Task { @MainActor [weak self] in
@@ -55,7 +64,27 @@ final class TouchBarController: NSObject {
    }

    private func refresh() {
        // The now-playing items (title + position) only exist while an
        // episode is loaded: rebuild the bar when that changes.
        let hasEpisode = playback.currentEpisode != nil
        if hasEpisode != hadEpisode {
            hadEpisode = hasEpisode
            NSApp.touchBar = makeTouchBar()
        }

        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.isEnabled = duration > 0 // a live radio has no position
            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)
            }
        }

        if podcasts.map(\.id) != model.podcasts.map(\.id) {
            podcasts = model.podcasts
            scrubber?.reloadData()
@@ -68,6 +97,10 @@ final class TouchBarController: NSObject {
    @objc private func skipBack() { playback.skipBackward(TimeInterval(settings.skipBackward)) }
    @objc private func skipForward() { playback.skipForward(TimeInterval(settings.skipForward)) }

    @objc private func positionMoved(_ sender: NSSliderTouchBarItem) {
        playback.seek(to: sender.slider.doubleValue)
    }

    private static func symbol(_ name: String) -> NSImage {
        NSImage(systemSymbolName: name, accessibilityDescription: nil) ?? NSImage()
    }
@@ -77,8 +110,12 @@ extension TouchBarController: NSTouchBarDelegate {
    private func makeTouchBar() -> NSTouchBar {
        let bar = NSTouchBar()
        bar.delegate = self
        bar.defaultItemIdentifiers = [.skingomzSkipBack, .skingomzPlayPause, .skingomzSkipForward,
                                      .fixedSpaceSmall, .skingomzPodcasts]
        var identifiers: [NSTouchBarItem.Identifier] = [.skingomzSkipBack, .skingomzPlayPause, .skingomzSkipForward]
        if playback.currentEpisode != nil {
            identifiers += [.skingomzTitle, .skingomzPosition]
        }
        identifiers += [.fixedSpaceSmall, .skingomzPodcasts]
        bar.defaultItemIdentifiers = identifiers
        return bar
    }

@@ -99,6 +136,24 @@ extension TouchBarController: NSTouchBarDelegate {
            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 ?? "")
            label.font = .systemFont(ofSize: 12)
            label.lineBreakMode = .byTruncatingTail
            label.widthAnchor.constraint(lessThanOrEqualToConstant: 170).isActive = true
            item.view = label
            titleLabel = label
            return item
        case .skingomzPosition:
            let item = NSSliderTouchBarItem(identifier: identifier)
            item.slider.minValue = 0
            item.slider.maxValue = max(playback.duration, 1)
            item.slider.doubleValue = playback.currentTime
            item.target = self
            item.action = #selector(positionMoved(_:))
            positionItem = item
            return item
        case .skingomzPodcasts:
            let item = NSCustomTouchBarItem(identifier: identifier)
            let scrubber = NSScrubber()
@@ -112,7 +167,9 @@ extension TouchBarController: NSTouchBarDelegate {
            layout.itemSize = NSSize(width: 36, height: 30)
            layout.itemSpacing = 4
            scrubber.scrubberLayout = layout
            scrubber.widthAnchor.constraint(lessThanOrEqualToConstant: 320).isActive = true
            item.view = scrubber
            item.visibilityPriority = .low // collapses first when space is tight
            self.scrubber = scrubber
            return item
        default:
@@ -152,7 +209,12 @@ 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
            self.scrubber?.reloadData()
            // 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 {
                view.image = image
            }
        }
    }
}
@@ -161,6 +223,8 @@ private extension NSTouchBarItem.Identifier {
    static let skingomzSkipBack = Self("eu.cythin.skingomz.touchbar.skip-back")
    static let skingomzPlayPause = Self("eu.cythin.skingomz.touchbar.play-pause")
    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 skingomzPodcasts = Self("eu.cythin.skingomz.touchbar.podcasts")
}
#endif