Docs

Now PlayingNew

A mini player that grows into the full player in one continuous liquid glass motion: a glass bar with the artwork, the title over the artist and a red progress hairline, with a red play bubble and a forward bubble joined to its end. Tap it or pull it up and the bar stretches with the finger, then settles open with the flick's speed: the same glass grows into a card, the artwork, title and play button fly to their places, and previous and next bud out of play. Scrub the track with a swelling thumb while the times follow, skip with a letter by letter title morph and a blurred artwork swap, and drag the card down to fold it back into the bar. Playback stays the app's: the piece shows the time it is given and reports taps, skips and seeks.

Free · MIT + Commons ClauseiOS 17.0+Liquid Glassplayermusicpodcastaudiomini playermorphscrubberglasshaptics
Type
NowPlaying
Files
NowPlaying.swift
Depends on
Nothing (Apple frameworks only)
Version
1.0.0

A mini player that grows into the full player: one liquid glass shape that morphs from a bar into a card. Collapsed, it is a glass bar with the artwork, the title over the artist and a red progress hairline, with a red play bubble and a forward bubble joined to its end. Tap it, or pull it up (it stretches with the finger), and the same glass grows into a card: the artwork, title and play button fly to their places, previous and next bud out of play, and a scrubber and the times blur in. Drag the card down, or tap its grabber, to fold it back. Playback is the app's. The piece never runs a clock: it shows the time it is given, and calls back when people play, pause, skip or seek. Put it in the bottom safe area inset of your screen; the card opens over the content above the bar. swift .safeAreaInset(edge: .bottom) { NowPlaying(title: track.title, artist: track.artist, isPlaying: player.isPlaying, time: player.time, duration: track.duration, expanded: $showsPlayer, onPlayPause: { player.toggle() }, onSkip: { player.skip($0) }, onSeek: { player.seek(to: $0) }) { track.artwork.resizable().scaledToFill() } .padding(.horizontal, 12) }

Notes

  • Put it in the bottom safe area inset of your main screen, .safeAreaInset(edge: .bottom) { NowPlaying(…).padding(.horizontal, 12) }. Its layout size is only the bar, so your scroll content stops above it, and the card opens over the content above the bar, never past the top safe area.
  • Playback is yours. The piece never runs a clock: pass your player's time, duration and isPlaying, and act in onPlayPause, onSkip and onSeek. Time updates are eased linearly over the gap between them, so a player that reports once a second still creeps smoothly.
  • Collapsed, it is a glass bar (the artwork, the title over the artist, a red progress hairline) with a red play bubble and a forward bubble joined to its end on liquid necks. Tap the bar, or pull it up: the glass stretches after the finger with a rubber band's resistance, and a pull or flick that would carry past 72 points opens it; a shorter one springs back.
  • Opening is one continuous morph: forward melts into play, the same glass grows into the card, the artwork flies from its tile to its square, play flies to the middle of the transport and swells, and previous and next bud out of play and rest apart from it. The title and artist blur out as they leave the bar and back in at the card's size, so they never cross the growing artwork. A pull carries the finger's speed into the morph.
  • Drag the card down from anywhere but the scrubber and it follows the finger; let go past about a fifth of its height, or flick, and it closes: the controls blur out, previous and next melt home, the card folds into the bar and forward buds back out. Tap the grabber to close it too. expanded follows every change, and setting it from your code runs the same choreography.
  • The scrubber: touch the track anywhere and the thumb jumps under the finger and swells; the times follow, your player's updates are ignored while the finger is down, and onSeek fires once on release. The thumb then holds the new position until your time catches up (or a second and a half passes), so it never jumps back. onSeek: nil makes it a read-only progress bar.
  • Skipping: .tracks calls onSkip(.previous) or onSkip(.next); the title morphs letter by letter and the artwork blurs into the new one when your title changes. .seconds(back: 15, forward: 30) (or .podcast) shows gobackward.15 and goforward.30 and seeks through onSeek instead. Without a handler the skip bubbles don't appear.
  • States: a duration of 0 is a live stream (no hairline, no scrubber, Live in place of the times); an empty title shows "Not playing"; long titles truncate in one line; the default artwork is a sand tile with a note.
  • The card's artwork fills its width and the card hugs the height left above the bar: on a short window it shrinks, and in landscape or at the largest text sizes the card switches to a row with a small artwork beside the lines.
  • VoiceOver: collapsed, the bar is one button, "Now playing, Slow Mornings, Aster Vale", valued Playing or Paused, with Play or Pause, Next track and Open player actions. Open, focus moves to the title (a header); the scrubber is adjustable in steps of 15 seconds and reads "1 minute, 12 seconds of 3 minutes, 42 seconds"; the transport and the grabber ("Close player") are buttons, and the escape gesture closes. A skip announces the new title and artist.
  • Hardware keyboard: Space plays or pauses, Return opens, Escape closes, and the left and right arrows seek 15 seconds while open.
  • Reduce Motion: no stretch under the finger, the glass changes shape on a short ease without overshoot, the artwork and lines fade out and back in at their new places instead of flying, and the bubbles fade in place.
  • Dynamic Type: the lines, the artwork tile and the mini buttons scale, the bar grows taller and the lines truncate; the card's line heights follow, and its artwork gives way first. Right to left mirrors the bar and the card's lines; the transport, the scrubber and the hairline keep left to right, as media controls do.
  • Haptics: a light impact on play and pause, a selection tick on skip and when a finger grabs the scrubber, and a soft impact when the player lands open or closed.
  • Red marks three things only: the play bubble, the progress hairline and the played part of the track. Everything else is clear glass and house ink. All text is one weight, semibold.
  • Style sets the accent and its ink, the label colours, the track, the thumb, the grabber and the placeholder tile. Messages replaces any of the copy and the VoiceOver strings, such as untitled: "Nothing queued".

Usage

NowPlayingExample()

Parameters

ParameterDescription
titleThe track, episode or station. It morphs letter by letter when it changes, and truncates when it doesn't fit. An empty title shows messages.untitled.
artistThe secondary line: the artist, show or author.
isPlayingWhether the app is playing. The red play bubble shows pause while it is true.
timeThe playback position in seconds, from your player. Updates between ticks are eased, so the hairline and the scrubber creep smoothly at any update rate. While people scrub, updates are ignored.
durationThe length in seconds. 0 (or any value that isn't positive and finite) is a live stream: the hairline, the scrubber and the times give way to messages.live.
expandedWhether the full player is open. Bind it to open or close the player from your own code; any change animates.
skippingWhat the skip buttons do: .tracks (previous and next, through onSkip, the default) or .seconds(back:forward:) for podcasts and audiobooks (through onSeek).
messagesThe built-in copy and VoiceOver strings. Defaults are localizable through your String Catalog; replace any line.
styleColors. Defaults to the SwiftPieces house palette, adapting to light and dark, with red for play, the hairline and the played part of the track.
onPlayPauseCalled when the play bubble is tapped (or Space is pressed). Toggle your player here.
onSkipCalled with .previous or .next in .tracks mode. nil hides the skip bubbles.
onSeekCalled once with the new position when a scrub ends, a seconds skip is tapped, or VoiceOver adjusts the scrubber. nil makes the scrubber a read-only display.
artworkThe artwork, filled into a square. Omit it for a house tile with a note. It blurs into the new one whenever the title or artist changes.

Source

NowPlaying.swift
// Copyright (c) 2026 Saivion Hayes
//
// Permission is hereby granted, free of charge, to any person obtaining a copy
// of this software and associated documentation files (the "Software"), to deal
// in the Software without restriction, including without limitation the rights
// to use, copy, modify, merge, publish, and distribute the Software as part of
// an application, website, or product, subject to the following conditions:
//
// The above copyright notice and this permission notice shall be included in all
// copies or substantial portions of the Software.
//
// Commons Clause Restriction
//
// You may use this Software, including for any commercial purpose, so long as
// you do not sell, sublicense, or redistribute the components themselves, whether
// alone, in a bundle, or as a ported version.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
// IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
// FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
// AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
// LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
// OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
// SOFTWARE.

import SwiftUI
import UIKit

/// A mini player that grows into the full player: one liquid glass shape that morphs from a bar into a card.
///
/// Collapsed, it is a glass bar with the artwork, the title over the artist and a red progress hairline, with a red
/// play bubble and a forward bubble joined to its end. Tap it, or pull it up (it stretches with the finger), and the
/// same glass grows into a card: the artwork, title and play button fly to their places, previous and next bud out of
/// play, and a scrubber and the times blur in. Drag the card down, or tap its grabber, to fold it back.
///
/// Playback is the app's. The piece never runs a clock: it shows the `time` it is given, and calls back when people
/// play, pause, skip or seek. Put it in the bottom safe area inset of your screen; the card opens over the content
/// above the bar.
///
/// ```swift
/// .safeAreaInset(edge: .bottom) {
///     NowPlaying(title: track.title, artist: track.artist, isPlaying: player.isPlaying, time: player.time,
///                duration: track.duration, expanded: $showsPlayer,
///                onPlayPause: { player.toggle() }, onSkip: { player.skip($0) }, onSeek: { player.seek(to: $0) }) {
///         track.artwork.resizable().scaledToFill()
///     }
///     .padding(.horizontal, 12)
/// }
/// ```
///
/// - Parameters:
///   - title: The track, episode or station. It morphs letter by letter when it changes, and truncates when it doesn't fit. An empty title shows `messages.untitled`.
///   - artist: The secondary line: the artist, show or author.
///   - isPlaying: Whether the app is playing. The red play bubble shows pause while it is `true`.
///   - time: The playback position in seconds, from your player. Updates between ticks are eased, so the hairline and the scrubber creep smoothly at any update rate. While people scrub, updates are ignored.
///   - duration: The length in seconds. `0` (or any value that isn't positive and finite) is a live stream: the hairline, the scrubber and the times give way to `messages.live`.
///   - expanded: Whether the full player is open. Bind it to open or close the player from your own code; any change animates.
///   - skipping: What the skip buttons do: `.tracks` (previous and next, through `onSkip`, the default) or `.seconds(back:forward:)` for podcasts and audiobooks (through `onSeek`).
///   - messages: The built-in copy and VoiceOver strings. Defaults are localizable through your String Catalog; replace any line.
///   - style: Colors. Defaults to the SwiftPieces house palette, adapting to light and dark, with red for play, the hairline and the played part of the track.
///   - onPlayPause: Called when the play bubble is tapped (or Space is pressed). Toggle your player here.
///   - onSkip: Called with `.previous` or `.next` in `.tracks` mode. `nil` hides the skip bubbles.
///   - onSeek: Called once with the new position when a scrub ends, a seconds skip is tapped, or VoiceOver adjusts the scrubber. `nil` makes the scrubber a read-only display.
///   - artwork: The artwork, filled into a square. Omit it for a house tile with a note. It blurs into the new one whenever the title or artist changes.
public struct NowPlaying: View {

    // MARK: Types

    /// A skip between tracks, passed to `onSkip`.
    public enum Skip: Hashable, Sendable {
        case previous
        case next
    }

    /// What the skip bubbles do.
    public enum Skipping: Hashable, Sendable {
        /// Previous and next track (`backward.fill` and `forward.fill`), through `onSkip`.
        case tracks
        /// Jumps back and forward by whole seconds (`gobackward.15` and `goforward.30`), through `onSeek`. For podcasts and audiobooks.
        case seconds(back: Int, forward: Int)

        /// Fifteen seconds back, thirty forward: the usual podcast pair.
        public static let podcast = Skipping.seconds(back: 15, forward: 30)
    }

    /// The player's copy. Every default goes through `String(localized:)`, so it can be translated in your String
    /// Catalog, and any line can be replaced: `.init(untitled: "Nothing queued")`.
    public struct Messages: Sendable {
        /// Opens the collapsed bar's VoiceOver label: "Now playing, Slow Mornings, Aster Vale".
        public var nowPlaying: String
        /// The play bubble while paused.
        public var play: String
        /// The play bubble while playing.
        public var pause: String
        /// The collapsed bar's VoiceOver value while playing.
        public var playing: String
        /// The collapsed bar's VoiceOver value while paused.
        public var paused: String
        /// The previous track bubble.
        public var previous: String
        /// The next track bubble.
        public var next: String
        /// The back bubble in `.seconds` mode, read before its duration: "Skip back, 15 seconds".
        public var skipBack: String
        /// The forward bubble in `.seconds` mode, read before its duration.
        public var skipForward: String
        /// The action that opens the player.
        public var openPlayer: String
        /// The grabber, and the action that closes the player.
        public var closePlayer: String
        /// The scrubber's VoiceOver label.
        public var position: String
        /// Shown in place of the times for a live stream.
        public var live: String
        /// Shown when `title` is empty.
        public var untitled: String

        /// Pass only the lines you want to change.
        public init(
            nowPlaying: String = String(localized: "Now playing", comment: "Now playing: first words of the mini player's VoiceOver label"),
            play: String = String(localized: "Play", comment: "Now playing: play button"),
            pause: String = String(localized: "Pause", comment: "Now playing: pause button"),
            playing: String = String(localized: "Playing", comment: "Now playing: VoiceOver value while audio plays"),
            paused: String = String(localized: "Paused", comment: "Now playing: VoiceOver value while audio is paused"),
            previous: String = String(localized: "Previous track", comment: "Now playing: button that goes to the previous track"),
            next: String = String(localized: "Next track", comment: "Now playing: button that goes to the next track"),
            skipBack: String = String(localized: "Skip back", comment: "Now playing: button that jumps back by some seconds"),
            skipForward: String = String(localized: "Skip forward", comment: "Now playing: button that jumps forward by some seconds"),
            openPlayer: String = String(localized: "Open player", comment: "Now playing: action that opens the full player"),
            closePlayer: String = String(localized: "Close player", comment: "Now playing: grabber and action that close the full player"),
            position: String = String(localized: "Playback position", comment: "Now playing: VoiceOver label of the scrubber"),
            live: String = String(localized: "Live", comment: "Now playing: shown instead of the times for a live stream"),
            untitled: String = String(localized: "Not playing", comment: "Now playing: shown when there is no title")
        ) {
            self.nowPlaying = nowPlaying
            self.play = play
            self.pause = pause
            self.playing = playing
            self.paused = paused
            self.previous = previous
            self.next = next
            self.skipBack = skipBack
            self.skipForward = skipForward
            self.openPlayer = openPlayer
            self.closePlayer = closePlayer
            self.position = position
            self.live = live
            self.untitled = untitled
        }

        public static var standard: Messages { Messages() }
    }

    /// Colors. `.standard` is the house palette.
    public struct Style: Sendable {
        /// The play bubble, the progress hairline and the played part of the track.
        public var accent: Color
        /// The play and pause glyph on `accent`.
        public var accentInk: Color
        /// The title and the glyphs on clear glass.
        public var label: Color
        /// The artist and the times.
        public var secondaryLabel: Color
        /// The unplayed part of the hairline and the track.
        public var track: Color
        /// The scrubber's thumb.
        public var thumb: Color
        /// The card's grabber.
        public var grabber: Color
        /// The tile behind the note when no artwork is given.
        public var placeholder: Color

        /// Pass only what you want to change; `nil` keeps the house palette value.
        public init(accent: Color? = nil, accentInk: Color? = nil, label: Color? = nil, secondaryLabel: Color? = nil, track: Color? = nil, thumb: Color? = nil, grabber: Color? = nil, placeholder: Color? = nil) {
            self.accent = accent ?? Color(red: 1, green: 0, blue: 0)
            self.accentInk = accentInk ?? nowPlayingAdaptive(light: 0x141414, dark: 0x141414)
            self.label = label ?? nowPlayingAdaptive(light: 0x141414, dark: 0xF4F3EF)
            self.secondaryLabel = secondaryLabel ?? nowPlayingAdaptive(light: 0x5C5A56, dark: 0xA6A49F)
            self.track = track ?? nowPlayingAdaptive(light: 0x141414, dark: 0xF4F3EF, alpha: 0.14)
            self.thumb = thumb ?? nowPlayingAdaptive(light: 0xFFFFFF, dark: 0xF4F3EF)
            self.grabber = grabber ?? nowPlayingAdaptive(light: 0x8B8984, dark: 0x6F6D69)
            self.placeholder = placeholder ?? nowPlayingAdaptive(light: 0xE9D5B3, dark: 0xE9D5B3)
        }

        public static let standard = Style()
    }

    // MARK: State

    @Environment(\.accessibilityReduceMotion) private var reduceMotion
    @Environment(\.layoutDirection) private var layoutDirection
    @Environment(\.isEnabled) private var isEnabled
    @ScaledMetric(relativeTo: .subheadline) private var miniTitleSize: CGFloat = 15
    @ScaledMetric(relativeTo: .footnote) private var miniArtistSize: CGFloat = 13
    @ScaledMetric(relativeTo: .title2) private var cardTitleSize: CGFloat = 22
    @ScaledMetric(relativeTo: .subheadline) private var cardArtistSize: CGFloat = 15
    @ScaledMetric(relativeTo: .footnote) private var timeSize: CGFloat = 13
    @ScaledMetric(relativeTo: .subheadline) private var artSize: CGFloat = 44
    @ScaledMetric(relativeTo: .body) private var buttonSize: CGFloat = 44

    @Binding private var expanded: Bool
    /// The layout on screen. It follows `expanded` on the piece's own choreography.
    @State private var shown: Bool
    /// The card's own controls (grabber, scrubber, times): in after the glass has grown, out before it shrinks.
    @State private var controlsShown: Bool
    @State private var hairlineShown: Bool
    /// The artwork and the two lines. Under Reduce Motion they fade out and back in at their new places instead of flying.
    @State private var fliersShown = true
    /// The two lines blur out as they leave one layout and back in as they reach the other, so they never cross the
    /// growing artwork while it flies.
    @State private var linesShown = true
    /// How far the collapsed bar is pulled up, after the rubber band.
    @State private var stretch: CGFloat = 0
    /// How far the open card is dragged down.
    @State private var drop: CGFloat = 0
    @State private var drag: NowPlayingDrag = .idle
    @GestureState private var touching = false
    @State private var pressing = false
    /// The scrubbed position while a finger is on the track.
    @State private var scrubTime: TimeInterval?
    @GestureState private var scrubTouching = false
    /// A seek the piece reported and the player hasn't caught up with yet, so the thumb never jumps back.
    @State private var pendingSeek: TimeInterval?
    @State private var lastTime: TimeInterval
    @State private var lastUpdate = Date.distantPast
    @State private var updateGap: Double = 0.5
    /// The bubbles: forward joined to play while collapsed; previous and next apart from it while open.
    @State private var buds = PieceBuds()
    /// The bar's frame in the window, and the window's top safe area: how much room the card has above the bar.
    @State private var frame: CGRect = .zero
    @State private var safeTop: CGFloat = 0
    @State private var choreography: Task<Void, Never>?
    @State private var releaseVelocity: CGFloat?
    @State private var announcesTrack = false
    @State private var playTick = 0
    @State private var skipTick = 0
    @State private var landTick = 0
    @State private var grabTick = 0
    @AccessibilityFocusState private var titleFocused: Bool

    private let title: String
    private let artist: String
    private let isPlaying: Bool
    private let time: TimeInterval
    private let duration: TimeInterval
    private let skipping: Skipping
    private let messages: Messages
    private let style: Style
    private let onPlayPause: () -> Void
    private let onSkip: ((Skip) -> Void)?
    private let onSeek: ((TimeInterval) -> Void)?
    private let artwork: AnyView?

    public init<Artwork: View>(title: String, artist: String, isPlaying: Bool, time: TimeInterval, duration: TimeInterval, expanded: Binding<Bool>, skipping: Skipping = .tracks, messages: Messages = .standard, style: Style = .standard, onPlayPause: @escaping () -> Void, onSkip: ((Skip) -> Void)? = nil, onSeek: ((TimeInterval) -> Void)? = nil, @ViewBuilder artwork: () -> Artwork) {
        self.init(title: title, artist: artist, isPlaying: isPlaying, time: time, duration: duration, expanded: expanded, skipping: skipping, messages: messages, style: style, artwork: AnyView(artwork()), onPlayPause: onPlayPause, onSkip: onSkip, onSeek: onSeek)
    }

    /// The same player with the house artwork tile, a note on `style.placeholder`.
    public init(title: String, artist: String, isPlaying: Bool, time: TimeInterval, duration: TimeInterval, expanded: Binding<Bool>, skipping: Skipping = .tracks, messages: Messages = .standard, style: Style = .standard, onPlayPause: @escaping () -> Void, onSkip: ((Skip) -> Void)? = nil, onSeek: ((TimeInterval) -> Void)? = nil) {
        self.init(title: title, artist: artist, isPlaying: isPlaying, time: time, duration: duration, expanded: expanded, skipping: skipping, messages: messages, style: style, artwork: nil, onPlayPause: onPlayPause, onSkip: onSkip, onSeek: onSeek)
    }

    private init(title: String, artist: String, isPlaying: Bool, time: TimeInterval, duration: TimeInterval, expanded: Binding<Bool>, skipping: Skipping, messages: Messages, style: Style, artwork: AnyView?, onPlayPause: @escaping () -> Void, onSkip: ((Skip) -> Void)?, onSeek: ((TimeInterval) -> Void)?) {
        self.title = title
        self.artist = artist
        self.isPlaying = isPlaying
        self.time = time.isFinite ? time : 0
        self.duration = duration
        self._expanded = expanded
        self.skipping = skipping
        self.messages = messages
        self.style = style
        self.artwork = artwork
        self.onPlayPause = onPlayPause
        self.onSkip = onSkip
        self.onSeek = onSeek
        let open = expanded.wrappedValue
        _shown = State(initialValue: open)
        _controlsShown = State(initialValue: open)
        _hairlineShown = State(initialValue: !open)
        _lastTime = State(initialValue: time.isFinite ? time : 0)
    }

    // MARK: Derived

    private var motion: PieceMotion { PieceMotion(reduceMotion: reduceMotion) }
    private var live: Bool { !(duration.isFinite && duration > 0) }
    private var canSeek: Bool { onSeek != nil && !live && isEnabled }
    private var shownTitle: String { title.isEmpty ? messages.untitled : title }
    /// What the hairline, the scrubber and the times show: the finger while scrubbing, then the seek until the player
    /// catches up, then the player's own time.
    private var shownTime: TimeInterval {
        let t = scrubTime ?? pendingSeek ?? time
        return live ? max(t, 0) : min(max(t, 0), duration)
    }
    private var progress: CGFloat { live ? 0 : CGFloat(shownTime / duration) }
    /// The bubbles the skip mode has: none when it can't act.
    private var skipIDs: [String] {
        switch skipping {
        case .tracks: onSkip == nil ? [] : [NowPlayingBud.previous, NowPlayingBud.next]
        case .seconds: onSeek == nil || live ? [] : [NowPlayingBud.previous, NowPlayingBud.next]
        }
    }
    private var hasForward: Bool { !skipIDs.isEmpty }
    /// The title and artwork belong to one track; a change blurs the artwork into the new one.
    private var trackKey: String { "\(title)\u{1F}\(artist)" }
    /// Player time between ticks eases linearly over the gap between updates, so a once-a-second clock still creeps.
    private var creep: Animation? {
        guard isPlaying, scrubTime == nil, pendingSeek == nil, time > lastTime, time - lastTime < 2.5 else { return motion.value }
        return .linear(duration: min(max(updateGap, 0.1), 1))
    }

    private var layout: NowPlayingLayout {
        let room = frame.maxY > 0 ? frame.maxY - safeTop - 8 : 10_000
        return NowPlayingLayout.make(
            width: max(frame.width, 240), room: room, rtl: layoutDirection == .rightToLeft, live: live, forward: hasForward,
            sizes: .init(miniTitle: miniTitleSize, miniArtist: miniArtistSize, cardTitle: cardTitleSize, cardArtist: cardArtistSize, time: timeSize, art: artSize, button: buttonSize)
        )
    }

    // MARK: Body

    public var body: some View {
        let g = layout
        Color.clear
            .frame(height: g.barH)
            .frame(maxWidth: .infinity)
            .accessibilityHidden(true)
            // The bar is this view's whole layout size, so it sits in a safe area inset; the card opens above it.
            .overlay(alignment: .bottom) { stage(g) }
            .fontWeight(.semibold)
            .onGeometryChange(for: CGRect.self) { $0.frame(in: .global) } action: { frame = $0 }
            .background { NowPlayingSafeTop { safeTop = $0 }.accessibilityHidden(true) }
            .opacity(isEnabled ? 1 : 0.45)
            .sensoryFeedback(.impact(weight: .light), trigger: playTick)
            .sensoryFeedback(.selection, trigger: skipTick)
            .sensoryFeedback(.selection, trigger: grabTick)
            .sensoryFeedback(.impact(flexibility: .soft), trigger: landTick)
            .onAppear {
                buds.place(shown ? skipIDs : (hasForward ? [NowPlayingBud.forward] : []))
            }
            .onChange(of: expanded) { _, open in present(open) }
            .onChange(of: time) { _, new in timeChanged(to: new) }
            .onChange(of: trackKey) { _, _ in trackChanged() }
            .onChange(of: skipIDs) { _, ids in skipsChanged(ids) }
            // A touch the system cancels never reaches onEnded, so the stretch or the drop lets go here too.
            .onChange(of: touching) { _, touching in
                if !touching, drag != .idle { letGo(translation: .zero, velocity: 0) }
            }
            .onChange(of: scrubTouching) { _, touching in
                if !touching, scrubTime != nil { scrubTime = nil }
            }
            .task(id: pendingSeek) {
                // A player that never reports the seek back still gets its own time shown again.
                guard pendingSeek != nil else { return }
                try? await Task.sleep(for: .seconds(1.5))
                if !Task.isCancelled { pendingSeek = nil }
            }
            .onDisappear { choreography?.cancel() }
    }

    /// Everything that moves, in one liquid group: the glass surface (bar or card) with its content, the bubbles, and
    /// play on top. Laid out left to right; right to left mirrors the layout itself, never the transport.
    private func stage(_ g: NowPlayingLayout) -> some View {
        PieceLiquidGroup {
            ZStack(alignment: .topLeading) {
                surface(g)
                ForEach(buds.present, id: \.self) { id in bubble(id, g) }
                playButton(g)
            }
            .frame(width: g.width, height: g.stageHeight, alignment: .topLeading)
        }
        .offset(y: drop)
        .environment(\.layoutDirection, .leftToRight)
    }

    // MARK: Surface

    /// The surface's frame in stage coordinates: the bar (taller by the pull) or the card.
    private func surfaceRect(_ g: NowPlayingLayout) -> CGRect {
        if shown { return g.card.offsetBy(dx: 0, dy: g.top) }
        var bar = g.bar.offsetBy(dx: 0, dy: g.top)
        bar.origin.y -= stretch
        bar.size.height += stretch
        return bar
    }

    /// One glass shape that is the bar, then the card. Its content is laid out in stage coordinates and moved against
    /// the surface's own offset, so the artwork and the lines fly on their own paths while the glass grows around them.
    private func surface(_ g: NowPlayingLayout) -> some View {
        let rect = surfaceRect(g)
        let shape = RoundedRectangle(cornerRadius: shown ? NowPlayingLayout.cardRadius : g.barH / 2, style: .continuous)
        return ZStack(alignment: .topLeading) {
            artworkFlier(g)
            lineFlier(g, title: true)
            lineFlier(g, title: false)
            hairline(g)
            cardControls(g)
        }
        .frame(width: g.width, height: g.stageHeight, alignment: .topLeading)
        .offset(x: -rect.minX, y: -rect.minY)
        .frame(width: rect.width, height: rect.height, alignment: .topLeading)
        .clipShape(shape)
        .contentShape(shape)
        .pieceLiquid(shape, interactive: false)
        .pieceLiquidPress(pressing && !shown)
        .offset(x: rect.minX, y: rect.minY)
        .gesture(surfaceDrag(g), isEnabled: isEnabled)
        .accessibilityElement(children: shown ? .contain : .ignore)
        .accessibilityLabel(shown ? "" : String(localized: "\(messages.nowPlaying), \(shownTitle), \(artist)", comment: "Now playing: the mini player's VoiceOver label, then the title and the artist"))
        .accessibilityValue(shown ? "" : (isPlaying ? messages.playing : messages.paused))
        .accessibilityAddTraits(shown ? [] : .isButton)
        .accessibilityAction { if !shown { setExpanded(true) } }
        .accessibilityAction(.escape) { if shown { setExpanded(false) } }
        .accessibilityActions {
            if !shown {
                Button(isPlaying ? messages.pause : messages.play) { playPause() }
                if hasForward { Button(skipLabel(NowPlayingBud.next)) { skip(NowPlayingBud.next) } }
                Button(messages.openPlayer) { setExpanded(true) }
            }
        }
        .focusable(isEnabled)
        .focusEffectDisabled()
        .onKeyPress(.space) { playPause(); return .handled }
        .onKeyPress(.return) {
            guard !shown else { return .ignored }
            setExpanded(true)
            return .handled
        }
        .onKeyPress(.escape) {
            guard shown else { return .ignored }
            setExpanded(false)
            return .handled
        }
        .onKeyPress(.leftArrow) { keySeek(-15) }
        .onKeyPress(.rightArrow) { keySeek(15) }
    }

    /// The artwork, flying between its tile in the bar and its square in the card. A new track blurs into place.
    private func artworkFlier(_ g: NowPlayingLayout) -> some View {
        let r = shown ? g.cardArt : g.miniArt
        let radius: CGFloat = shown ? g.artRadius : 12
        return ZStack {
            Group {
                if let artwork {
                    artwork
                } else {
                    NowPlayingPlaceholder(tint: style.placeholder)
                }
            }
            .frame(width: r.width, height: r.height)
            .id(trackKey)
            .transition(motion.swap)
        }
        .animation(motion.morph, value: trackKey)
        .frame(width: r.width, height: r.height)
        .clipShape(RoundedRectangle(cornerRadius: radius, style: .continuous))
        .opacity(fliersShown ? 1 : 0)
        .offset(x: r.minX, y: r.minY + g.top)
        .allowsHitTesting(false)
        .accessibilityHidden(true)
    }

    /// The title or the artist, drawn at the card's size and scaled down into the bar, so it reads crisp in both and
    /// flies on one path between them.
    private func lineFlier(_ g: NowPlayingLayout, title isTitle: Bool) -> some View {
        let r = isTitle ? (shown ? g.cardTitle : g.miniTitle) : (shown ? g.cardArtist : g.miniArtist)
        let size = isTitle ? cardTitleSize : cardArtistSize
        let mini = isTitle ? miniTitleSize : miniArtistSize
        let scale = shown ? 1 : mini / size
        let text = isTitle ? shownTitle : artist
        // A morph needs the whole line to fit the narrower of its two slots, measured at the card's size.
        let room = min(g.miniTitle.width / (mini / size), (isTitle ? g.cardTitle : g.cardArtist).width)
        let morphs = nowPlayingMorphs(text) && nowPlayingWidth(text, size: size) + 2 <= room
        let alignment: Alignment = g.rtl ? .trailing : .leading
        return NowPlayingLine(text: text, size: size, color: isTitle ? style.label : style.secondaryLabel, morphs: morphs, alignment: alignment)
            .frame(width: r.width / scale, height: r.height / scale, alignment: alignment)
            .scaleEffect(scale, anchor: .topLeading)
            .blur(radius: linesShown ? 0 : 5)
            .opacity(fliersShown && linesShown ? 1 : 0)
            .offset(x: r.minX, y: r.minY + g.top)
            .allowsHitTesting(false)
            .accessibilityAddTraits(isTitle ? .isHeader : [])
            .modifier(NowPlayingFocus(focus: $titleFocused, applies: isTitle))
    }

    /// The bar's progress: a red hairline along its bottom inner edge, under the lines.
    private func hairline(_ g: NowPlayingLayout) -> some View {
        let r = g.hairline
        return ZStack(alignment: .leading) {
            Capsule().fill(style.track)
            Capsule().fill(style.accent).frame(width: r.width * progress)
        }
        .animation(creep, value: time)
        .frame(width: r.width, height: r.height)
        .opacity(hairlineShown && !g.live ? 1 : 0)
        .offset(x: r.minX, y: r.minY + g.top)
        .allowsHitTesting(false)
        .accessibilityHidden(true)
    }

    /// The card's own controls: the grabber, the scrubber and the times (or Live).
    private func cardControls(_ g: NowPlayingLayout) -> some View {
        ZStack(alignment: .topLeading) {
            grabber(g)
            if !g.live {
                scrubber(g)
            }
            times(g)
        }
        .opacity(controlsShown ? 1 : 0)
        .blur(radius: controlsShown ? 0 : 6)
        .allowsHitTesting(controlsShown)
        .accessibilityHidden(!controlsShown)
    }

    private func grabber(_ g: NowPlayingLayout) -> some View {
        Capsule()
            .fill(style.grabber)
            .frame(width: 36, height: 5)
            .frame(width: g.grabber.width, height: g.grabber.height)
            .offset(x: g.grabber.minX, y: g.grabber.minY + g.top)
            .accessibilityElement()
            .accessibilityLabel(messages.closePlayer)
            .accessibilityAddTraits(.isButton)
            .accessibilityAction { setExpanded(false) }
    }

    /// A 6 point track, red where it has played, with a thumb that swells under the finger. Touch anywhere on it and
    /// the thumb jumps there; the times follow, the player's own updates wait, and the seek is reported once on release.
    private func scrubber(_ g: NowPlayingLayout) -> some View {
        let r = g.scrub
        let scrubbing = scrubTime != nil
        let thumb: CGFloat = 28
        return ZStack(alignment: .leading) {
            Capsule().fill(style.track).frame(height: 6)
            Capsule().fill(style.accent).frame(width: max(r.width * progress, 0), height: 6)
            if onSeek != nil {
                Circle()
                    .fill(style.thumb)
                    .shadow(color: .black.opacity(0.22), radius: 3, y: 1)
                    .frame(width: thumb, height: thumb)
                    .scaleEffect(scrubbing ? 1 : 16 / thumb)
                    .animation(scrubbing ? motion.press : motion.release, value: scrubbing)
                    .offset(x: r.width * progress - thumb / 2)
            }
        }
        .animation(creep, value: time)
        .frame(width: r.width, height: r.height)
        .contentShape(Rectangle())
        .gesture(scrubGesture(width: r.width), isEnabled: canSeek)
        .offset(x: r.minX, y: r.minY + g.top)
        .accessibilityElement()
        .accessibilityLabel(messages.position)
        .accessibilityValue(String(localized: "\(nowPlayingSpoken(shownTime)) of \(nowPlayingSpoken(duration))", comment: "Now playing: the scrubber's VoiceOver value, the position of the length"))
        .accessibilityAdjustableAction { direction in
            guard canSeek else { return }
            seek(to: shownTime + (direction == .increment ? 15 : -15))
        }
    }

    private func times(_ g: NowPlayingLayout) -> some View {
        let r = g.times
        return HStack(spacing: 8) {
            if g.live {
                Text(messages.live)
                    .frame(maxWidth: .infinity, alignment: g.rtl ? .trailing : .leading)
            } else {
                Text(nowPlayingClock(shownTime, total: duration, rounding: .down))
                Spacer(minLength: 0)
                Text(nowPlayingClock(-(duration - shownTime), total: duration, rounding: .up))
            }
        }
        .font(.system(size: timeSize, weight: .semibold).monospacedDigit())
        .foregroundStyle(style.secondaryLabel)
        .lineLimit(1)
        .frame(width: r.width, height: r.height)
        .offset(x: r.minX, y: r.minY + g.top)
        .accessibilityHidden(!g.live)
    }

    // MARK: Bubbles

    /// The red play bubble. It flies from the bar's end to the middle of the transport and swells as the card opens.
    private func playButton(_ g: NowPlayingLayout) -> some View {
        let side = shown ? NowPlayingLayout.playSide : g.playMiniSide
        let center = shown ? g.playCard : g.playMini
        return Button(action: playPause) {
            ZStack {
                Image(systemName: isPlaying ? "pause.fill" : "play.fill")
                    // The triangle's weight sits left of its box; nudged so it reads centred.
                    .offset(x: isPlaying ? 0 : 1.5)
                    .id(isPlaying)
                    .transition(motion.swap)
            }
            .font(.system(size: 28, weight: .semibold))
            .foregroundStyle(style.accentInk)
            .scaleEffect(side / NowPlayingLayout.playSide)
            .animation(motion.snap, value: isPlaying)
            .frame(width: side, height: side)
            .contentShape(Circle())
            .pieceLiquid(Circle(), tint: style.accent, interactive: false)
        }
        .buttonStyle(PieceLiquidPressStyle())
        .offset(x: center.x - side / 2, y: center.y - side / 2 + g.top)
        .disabled(!isEnabled)
        .accessibilityLabel(isPlaying ? messages.pause : messages.play)
        .accessibilityHidden(!shown)
    }

    /// Forward, joined to play while collapsed; previous and next, apart from it while open. Each is born inside play
    /// and melts back into it.
    private func bubble(_ id: String, _ g: NowPlayingLayout) -> some View {
        let out = buds.isOut(id)
        let mini = id == NowPlayingBud.forward
        let side = mini ? g.playMiniSide : NowPlayingLayout.skipSide
        let center = shown ? g.playCard : g.playMini
        let dx: CGFloat = mini ? g.fwdOffset : (id == NowPlayingBud.previous ? -NowPlayingLayout.skipOffset : NowPlayingLayout.skipOffset)
        return Button { skip(id) } label: {
            Image(systemName: symbol(id))
                .font(.system(size: mini ? 16 : 20, weight: .semibold))
                .foregroundStyle(style.label)
                .pieceBudContent(out: out)
                .frame(width: side, height: side)
                .contentShape(Circle())
                .pieceLiquid(Circle(), interactive: false)
        }
        .buttonStyle(PieceLiquidPressStyle())
        .pieceBud(out: out, rest: CGSize(width: dx, height: 0), home: .zero)
        .offset(x: center.x - side / 2, y: center.y - side / 2 + g.top)
        .disabled(!out || !isEnabled)
        .accessibilityLabel(skipLabel(id))
        .accessibilityHidden(!out || !shown)
    }

    private func symbol(_ id: String) -> String {
        let back = id == NowPlayingBud.previous
        switch skipping {
        case .tracks:
            return back ? "backward.fill" : "forward.fill"
        case .seconds(let backSeconds, let forwardSeconds):
            let n = back ? backSeconds : forwardSeconds
            let named = [5, 10, 15, 30, 45, 60, 75, 90].contains(n)
            return (back ? "gobackward" : "goforward") + (named ? ".\(n)" : "")
        }
    }

    private func skipLabel(_ id: String) -> String {
        let back = id == NowPlayingBud.previous
        switch skipping {
        case .tracks:
            return back ? messages.previous : messages.next
        case .seconds(let backSeconds, let forwardSeconds):
            let amount = Duration.seconds(back ? backSeconds : forwardSeconds).formatted(.units(allowed: [.minutes, .seconds], width: .wide))
            return String(localized: "\(back ? messages.skipBack : messages.skipForward), \(amount)", comment: "Now playing: a seconds skip button, its action and how far it jumps")
        }
    }

    // MARK: Gestures

    /// One drag for the surface. Collapsed: a tap opens and an upward pull stretches the bar with the finger. Open: a
    /// downward drag carries the card, and a tap on the grabber closes it. Sideways drags are left alone.
    private func surfaceDrag(_ g: NowPlayingLayout) -> some Gesture {
        DragGesture(minimumDistance: 0, coordinateSpace: .global)
            .updating($touching) { _, touching, _ in touching = true }
            .onChanged { value in dragChanged(value.translation) }
            .onEnded { value in
                if drag == .pressing {
                    drag = .idle
                    pressing = false
                    if !shown {
                        setExpanded(true)
                    } else if grabberContains(value.startLocation, g) {
                        setExpanded(false)
                    }
                } else {
                    letGo(translation: value.translation, velocity: value.velocity.height)
                }
            }
    }

    private func dragChanged(_ t: CGSize) {
        if drag == .idle {
            drag = .pressing
            pressing = !shown
        }
        if drag == .pressing, hypot(t.width, t.height) > 8 {
            drag = abs(t.height) > abs(t.width) ? (shown ? .dropping : .pulling) : .passing
            pressing = false
        }
        switch drag {
        case .pulling:
            // The glass reaches up after the finger with a band's resistance. Under Reduce Motion it stays put.
            stretch = reduceMotion ? 0 : PieceMotion.rubberBand(max(-t.height, 0), limit: NowPlayingLayout.pullLimit)
        case .dropping:
            drop = t.height >= 0 ? t.height : PieceMotion.rubberBand(t.height, limit: 36)
        default:
            break
        }
    }

    /// The finger lifts (or the system takes the touch). A pull opens or springs back and a drop closes or springs
    /// back, by where the flick would coast to.
    private func letGo(translation t: CGSize, velocity v: CGFloat) {
        let mode = drag
        drag = .idle
        pressing = false
        switch mode {
        case .pulling:
            if PieceMotion.project(-t.height, velocity: -v) > 72 {
                setExpanded(true, velocity: v)
            } else {
                // The bar's own speed: the finger's, slowed by the band.
                let frame: CGFloat = 1 / 120
                let pull = max(-t.height, 0)
                let speed = (PieceMotion.rubberBand(max(pull - v * frame, 0), limit: NowPlayingLayout.pullLimit) - stretch) / frame
                withAnimation(motion.settle(velocity: speed, from: stretch, to: 0)) { stretch = 0 }
            }
        case .dropping:
            if PieceMotion.project(t.height, velocity: v) > max(110, layout.card.height * 0.22) {
                setExpanded(false, velocity: v)
            } else {
                withAnimation(motion.settle(velocity: v, from: drop, to: 0)) { drop = 0 }
            }
        default:
            break
        }
    }

    /// Whether a touch (in the window) started on the grabber.
    private func grabberContains(_ point: CGPoint, _ g: NowPlayingLayout) -> Bool {
        g.grabber.contains(CGPoint(x: point.x - frame.minX, y: point.y - frame.minY - drop))
    }

    private func scrubGesture(width: CGFloat) -> some Gesture {
        DragGesture(minimumDistance: 0)
            .updating($scrubTouching) { _, touching, _ in touching = true }
            .onChanged { value in
                if scrubTime == nil { grabTick += 1 }
                let x = min(max(value.location.x, 0), width)
                scrubTime = duration * Double(x / max(width, 1))
            }
            .onEnded { _ in
                guard let target = scrubTime else { return }
                scrubTime = nil
                seek(to: target)
            }
    }

    // MARK: Actions

    private func playPause() {
        playTick += 1
        onPlayPause()
    }

    private func skip(_ id: String) {
        skipTick += 1
        switch skipping {
        case .tracks:
            announcesTrack = true
            onSkip?(id == NowPlayingBud.previous ? .previous : .next)
        case .seconds(let back, let forward):
            seek(to: shownTime + Double(id == NowPlayingBud.previous ? -back : forward))
        }
    }

    /// Reports a seek once, and shows it until the player's time catches up.
    private func seek(to target: TimeInterval) {
        guard let onSeek, !live else { return }
        let clamped = min(max(target, 0), duration)
        pendingSeek = clamped
        onSeek(clamped)
    }

    private func keySeek(_ seconds: TimeInterval) -> KeyPress.Result {
        guard shown, canSeek else { return .ignored }
        seek(to: shownTime + seconds)
        return .handled
    }

    private func setExpanded(_ open: Bool, velocity: CGFloat? = nil) {
        releaseVelocity = velocity
        if expanded == open {
            present(open)
        } else {
            expanded = open
        }
    }

    // MARK: Choreography

    /// Opens or closes, from a tap, a pull, a drag or the caller's binding. Opening: the forward bubble melts into
    /// play, the glass grows into the card while the artwork, the lines and play fly, the card's controls blur in, and
    /// previous and next bud out of play. Closing runs it backwards: the controls blur out, previous and next melt
    /// home, the card folds into the bar, and forward buds back out.
    private func present(_ open: Bool) {
        let velocity = releaseVelocity
        releaseVelocity = nil
        choreography?.cancel()
        let rm = reduceMotion
        let g = layout
        let skips = skipIDs
        let forward = hasForward
        choreography = Task { @MainActor in
            if open {
                if buds.contains(NowPlayingBud.forward) {
                    Task { await buds.gather([NowPlayingBud.forward], reduceMotion: rm) }
                }
                withAnimation(motion.dismiss) { hairlineShown = false }
                withAnimation(.easeOut(duration: 0.1)) { linesShown = false }
                if rm {
                    withAnimation(.easeOut(duration: 0.12)) { fliersShown = false }
                    try? await Task.sleep(for: .milliseconds(120))
                    guard !Task.isCancelled else { return }
                }
                let morph = velocity.map { motion.settle(velocity: $0, from: -stretch, to: g.card.minY, spring: PieceMotion.calm) } ?? motion.reveal
                withAnimation(morph) {
                    shown = true
                    stretch = 0
                    drop = 0
                }
                try? await Task.sleep(for: .milliseconds(rm ? 60 : 220))
                guard !Task.isCancelled else { return }
                withAnimation(motion.reveal) {
                    controlsShown = true
                    fliersShown = true
                    linesShown = true
                }
                if !skips.isEmpty { await buds.bloom(skips, reduceMotion: rm, stagger: 0.06) }
                try? await Task.sleep(for: .milliseconds(rm ? 40 : 220))
                guard !Task.isCancelled else { return }
                landTick += 1
                titleFocused = true
            } else {
                withAnimation(motion.dismiss) { controlsShown = false }
                withAnimation(.easeOut(duration: 0.1)) { linesShown = false }
                let home = skips.filter { buds.contains($0) }
                if !home.isEmpty { Task { await buds.gather(home, reduceMotion: rm) } }
                try? await Task.sleep(for: .milliseconds(rm ? 0 : 110))
                guard !Task.isCancelled else { return }
                if rm {
                    withAnimation(.easeOut(duration: 0.12)) { fliersShown = false }
                    try? await Task.sleep(for: .milliseconds(120))
                    guard !Task.isCancelled else { return }
                }
                let fold = velocity.map { motion.settle(velocity: $0, from: g.card.minY + drop, to: 0, spring: PieceMotion.calm) } ?? motion.dismiss
                withAnimation(fold) {
                    shown = false
                    drop = 0
                    stretch = 0
                }
                try? await Task.sleep(for: .milliseconds(rm ? 60 : 280))
                guard !Task.isCancelled else { return }
                withAnimation(motion.reveal) {
                    hairlineShown = true
                    fliersShown = true
                    linesShown = true
                }
                if forward { await buds.bloom([NowPlayingBud.forward], reduceMotion: rm) }
                try? await Task.sleep(for: .milliseconds(rm ? 40 : 160))
                guard !Task.isCancelled else { return }
                landTick += 1
            }
        }
    }

    /// Keeps the bubbles in step when the skip mode or its handler changes underneath.
    private func skipsChanged(_ ids: [String]) {
        let wanted = shown ? ids : (ids.isEmpty ? [] : [NowPlayingBud.forward])
        let leaving = buds.present.filter { !wanted.contains($0) }
        let arriving = wanted.filter { !buds.contains($0) }
        let rm = reduceMotion
        Task {
            if !leaving.isEmpty { await buds.gather(leaving, reduceMotion: rm) }
            if !arriving.isEmpty { await buds.bloom(arriving, reduceMotion: rm) }
        }
    }

    private func timeChanged(to new: TimeInterval) {
        let now = Date()
        let gap = now.timeIntervalSince(lastUpdate)
        if gap > 0.05, gap < 1.5 { updateGap = gap }
        lastUpdate = now
        lastTime = new
        if let pending = pendingSeek, abs(new - pending) < 1.2 { pendingSeek = nil }
    }

    private func trackChanged() {
        scrubTime = nil
        pendingSeek = nil
        if announcesTrack {
            announcesTrack = false
            AccessibilityNotification.Announcement(String(localized: "\(shownTitle), \(artist)", comment: "Now playing: announced after a skip, the new title and artist")).post()
        }
    }
}

// MARK: - Layout

/// Where everything sits, in the bar's coordinates (the bar's top left is the origin; the card is above it, at
/// negative y). Line heights come from the system font, so Dynamic Type grows the bar and the card with the text.
private struct NowPlayingLayout {
    static let cardRadius: CGFloat = 34
    static let playSide: CGFloat = 72
    static let skipSide: CGFloat = 52
    /// Previous and next rest apart from play.
    static var skipOffset: CGFloat { playSide / 2 + PieceLiquid.apart + skipSide / 2 }
    /// The most a pull stretches the bar, after the band.
    static let pullLimit: CGFloat = 150

    struct Sizes {
        var miniTitle: CGFloat
        var miniArtist: CGFloat
        var cardTitle: CGFloat
        var cardArtist: CGFloat
        var time: CGFloat
        var art: CGFloat
        var button: CGFloat
    }

    var width: CGFloat = 0
    var rtl = false
    var live = false
    var compact = false
    var barH: CGFloat = 64
    var bar = CGRect.zero
    var miniArt = CGRect.zero
    var miniTitle = CGRect.zero
    var miniArtist = CGRect.zero
    var hairline = CGRect.zero
    var playMini = CGPoint.zero
    var playMiniSide: CGFloat = 44
    var fwdOffset: CGFloat = 48
    var card = CGRect.zero
    var cardArt = CGRect.zero
    var artRadius: CGFloat = 22
    var cardTitle = CGRect.zero
    var cardArtist = CGRect.zero
    var grabber = CGRect.zero
    var scrub = CGRect.zero
    var times = CGRect.zero
    var playCard = CGPoint.zero
    /// How far the stage reaches above the bar's top.
    var top: CGFloat = 0
    var stageHeight: CGFloat { top + barH + 24 }

    static func make(width w: CGFloat, room: CGFloat, rtl: Bool, live: Bool, forward: Bool, sizes s: Sizes) -> NowPlayingLayout {
        var g = NowPlayingLayout()
        g.width = w
        g.rtl = rtl
        g.live = live

        // The bar: artwork, the two lines and the hairline, then play and forward joined to its end.
        let art = min(max(s.art, 44), 64)
        let button = min(max(s.button, 44), 60)
        let titleLine = nowPlayingLine(s.miniTitle), artistLine = nowPlayingLine(s.miniArtist)
        let block = titleLine + 2 + artistLine + (live ? 0 : 7)
        let barH = ceil(max(64, art + 20, block + 20))
        g.barH = barH
        let tail = button + PieceLiquid.joined + (forward ? button + PieceLiquid.joined : 0)
        let barW = max(w - tail, barH * 2)
        g.bar = CGRect(x: 0, y: 0, width: barW, height: barH)
        let inset = (barH - art) / 2
        g.miniArt = CGRect(x: inset, y: inset, width: art, height: art)
        let x0 = inset + art + 12
        // Clear of the capsule's round end at every height.
        let lineWidth = max(barW - x0 - max(20, barH * 0.32), 24)
        let y0 = (barH - block) / 2
        g.miniTitle = CGRect(x: x0, y: y0, width: lineWidth, height: titleLine)
        g.miniArtist = CGRect(x: x0, y: g.miniTitle.maxY + 2, width: lineWidth, height: artistLine)
        g.hairline = CGRect(x: x0, y: g.miniArtist.maxY + 5, width: lineWidth, height: 2)
        g.playMiniSide = button
        g.playMini = CGPoint(x: barW + PieceLiquid.joined + button / 2, y: barH / 2)
        g.fwdOffset = button + PieceLiquid.joined

        // The card, above the transport row, which rests apart from it with its bottom on the bar's bottom.
        let cardTitleLine = nowPlayingLine(s.cardTitle), cardArtistLine = nowPlayingLine(s.cardArtist), timeLine = nowPlayingLine(s.time)
        let header: CGFloat = 34
        let scrubH: CGFloat = live ? 0 : 30
        let lines = cardTitleLine + 2 + cardArtistLine
        let fixed = header + 18 + lines + 12 + scrubH + timeLine + 20
        let transport = playSide + PieceLiquid.apart
        let maxWidth = min(w, 440)
        // The artwork fills the card's width, shrinking (and the card with it) when the height above the bar is short.
        let artSide = min(maxWidth - 40, room - transport - fixed)
        let bottom = barH - transport
        g.playCard = CGPoint(x: w / 2, y: barH - playSide / 2)
        var controlsTop: CGFloat
        if artSide >= 150 {
            let cw = min(maxWidth, artSide + 40), cx = (w - cw) / 2, ch = fixed + artSide
            g.card = CGRect(x: cx, y: bottom - ch, width: cw, height: ch)
            g.cardArt = CGRect(x: cx + (cw - artSide) / 2, y: g.card.minY + header, width: artSide, height: artSide)
            g.cardTitle = CGRect(x: cx + 20, y: g.cardArt.maxY + 18, width: cw - 40, height: cardTitleLine)
            g.cardArtist = CGRect(x: cx + 20, y: g.cardTitle.maxY + 2, width: cw - 40, height: cardArtistLine)
            controlsTop = g.cardArtist.maxY + 12
        } else {
            // Short of room (landscape, the largest text sizes): a row with a small artwork beside the lines.
            g.compact = true
            g.artRadius = 14
            let thumb: CGFloat = 64, row = max(thumb, lines)
            let cw = maxWidth, cx = (w - cw) / 2, ch = header + row + 14 + scrubH + timeLine + 20
            g.card = CGRect(x: cx, y: bottom - ch, width: cw, height: ch)
            g.cardArt = CGRect(x: cx + 20, y: g.card.minY + header + (row - thumb) / 2, width: thumb, height: thumb)
            let tx = g.cardArt.maxX + 14, ty = g.card.minY + header + (row - lines) / 2
            g.cardTitle = CGRect(x: tx, y: ty, width: max(g.card.maxX - 20 - tx, 24), height: cardTitleLine)
            g.cardArtist = CGRect(x: tx, y: g.cardTitle.maxY + 2, width: g.cardTitle.width, height: cardArtistLine)
            controlsTop = g.card.minY + header + row + 14
        }
        g.scrub = CGRect(x: g.card.minX + 20, y: controlsTop, width: g.card.width - 40, height: scrubH)
        g.times = CGRect(x: g.card.minX + 20, y: g.scrub.maxY, width: g.card.width - 40, height: timeLine)
        g.grabber = CGRect(x: g.card.midX - 44, y: g.card.minY, width: 88, height: 26)
        g.top = max(-g.card.minY, pullLimit) + 48

        // Right to left mirrors the bar and the card's lines. The transport and the scrubber keep left to right, as
        // media controls do, and the card is centred.
        if rtl {
            let flip = { (r: CGRect) in CGRect(x: w - r.maxX, y: r.minY, width: r.width, height: r.height) }
            g.bar = flip(g.bar)
            g.miniArt = flip(g.miniArt)
            g.miniTitle = flip(g.miniTitle)
            g.miniArtist = flip(g.miniArtist)
            g.hairline = flip(g.hairline)
            g.playMini.x = w - g.playMini.x
            g.fwdOffset = -g.fwdOffset
            g.cardArt = flip(g.cardArt)
            g.cardTitle = flip(g.cardTitle)
            g.cardArtist = flip(g.cardArtist)
        }
        return g
    }
}

private enum NowPlayingBud {
    static let forward = "forward"
    static let previous = "previous"
    static let next = "next"
}

private enum NowPlayingDrag: Equatable {
    case idle, pressing, pulling, dropping, passing
}

/// A line that morphs letter by letter when it can, and otherwise truncates and blurs to its new text.
private struct NowPlayingLine: View {
    var text: String
    var size: CGFloat
    var color: Color
    var morphs: Bool
    var alignment: Alignment
    @Environment(\.accessibilityReduceMotion) private var reduceMotion

    var body: some View {
        ZStack(alignment: alignment) {
            if morphs {
                PieceMorphText(text: text, font: .system(size: size, weight: .semibold))
            } else {
                Text(text)
                    .font(.system(size: size, weight: .semibold))
                    .lineLimit(1)
                    .truncationMode(.tail)
                    .id(text)
                    .transition(PieceMotion(reduceMotion: reduceMotion).swap)
            }
        }
        .foregroundStyle(color)
        .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: alignment)
        .animation(PieceMotion(reduceMotion: reduceMotion).snap, value: text)
    }
}

/// Where VoiceOver lands when the player opens: the title, and only the title. `applies` is fixed per line, so the
/// branch never changes a view's identity.
private struct NowPlayingFocus: ViewModifier {
    var focus: AccessibilityFocusState<Bool>.Binding
    var applies: Bool

    func body(content: Content) -> some View {
        if applies {
            content.accessibilityFocused(focus)
        } else {
            content
        }
    }
}

/// The artwork when none is given: a house tile with a note, scaling with the tile as it flies.
private struct NowPlayingPlaceholder: View {
    var tint: Color

    var body: some View {
        tint.overlay {
            GeometryReader { proxy in
                Image(systemName: "music.note")
                    .resizable()
                    .scaledToFit()
                    .fontWeight(.semibold)
                    .foregroundStyle(Color(red: 0.078, green: 0.078, blue: 0.078))
                    .frame(width: proxy.size.width * 0.36, height: proxy.size.height * 0.36)
                    .frame(maxWidth: .infinity, maxHeight: .infinity)
            }
        }
    }
}

/// Reports the window's top safe area, which SwiftUI doesn't offer to a view sitting at the bottom of the screen.
private struct NowPlayingSafeTop: UIViewRepresentable {
    var report: @MainActor (CGFloat) -> Void

    func makeUIView(context: Context) -> NowPlayingSafeTopView {
        let view = NowPlayingSafeTopView()
        view.isUserInteractionEnabled = false
        view.report = report
        return view
    }

    func updateUIView(_ view: NowPlayingSafeTopView, context: Context) {
        view.report = report
    }
}

private final class NowPlayingSafeTopView: UIView {
    var report: (@MainActor (CGFloat) -> Void)?
    private var last: CGFloat = -1

    override func didMoveToWindow() {
        super.didMoveToWindow()
        send()
    }

    override func layoutSubviews() {
        super.layoutSubviews()
        send()
    }

    override func safeAreaInsetsDidChange() {
        super.safeAreaInsetsDidChange()
        send()
    }

    private func send() {
        guard let window else { return }
        let top = window.safeAreaInsets.top
        guard top != last else { return }
        last = top
        // After this layout pass, so SwiftUI isn't updated from inside one.
        Task { @MainActor [weak self] in self?.report?(top) }
    }
}

/// The system font's line height at a size, as SwiftUI lays out one line of it.
private func nowPlayingLine(_ size: CGFloat) -> CGFloat {
    ceil(UIFont.systemFont(ofSize: size, weight: .semibold).lineHeight)
}

/// The width of one line of text at a size.
private func nowPlayingWidth(_ text: String, size: CGFloat) -> CGFloat {
    ceil((text as NSString).size(withAttributes: [.font: UIFont.systemFont(ofSize: size, weight: .semibold)]).width)
}

/// Whether a line can morph letter by letter: left to right scripts without joined letters, and not too long.
private func nowPlayingMorphs(_ text: String) -> Bool {
    text.count <= 48 && text.unicodeScalars.allSatisfy { $0.value < 0x0590 }
}

/// A position as a clock, "1:12" (or "1:02:05" past an hour), counting whole seconds down or up.
private func nowPlayingClock(_ seconds: TimeInterval, total: TimeInterval, rounding: FloatingPointRoundingRule) -> String {
    let whole = seconds.rounded(rounding)
    return Duration.seconds(whole).formatted(.time(pattern: total >= 3600 ? .hourMinuteSecond : .minuteSecond))
}

/// A position as VoiceOver reads it: "1 minute, 12 seconds".
private func nowPlayingSpoken(_ seconds: TimeInterval) -> String {
    Duration.seconds(max(seconds.rounded(.down), 0)).formatted(.units(allowed: [.hours, .minutes, .seconds], width: .wide))
}

/// A house-palette color that follows the interface style.
private func nowPlayingAdaptive(light: UInt32, dark: UInt32, alpha: CGFloat = 1) -> Color {
    Color(uiColor: UIColor { @Sendable traits in
        let hex = traits.userInterfaceStyle == .dark ? dark : light
        return UIColor(red: CGFloat((hex >> 16) & 0xFF) / 255, green: CGFloat((hex >> 8) & 0xFF) / 255, blue: CGFloat(hex & 0xFF) / 255, alpha: alpha)
    })
}

// MARK: - Example

/// Two tracks by one fictional artist, with a player that ticks twice a second, as an app's would.
private struct NowPlayingExample: View {
    @State private var track = 0
    @State private var playing = true
    @State private var time: TimeInterval = 48
    @State private var open = false

    private let tracks: [(title: String, artist: String, duration: TimeInterval)] = [
        ("Slow Mornings", "Aster Vale", 222),
        ("Paper Lanterns", "Aster Vale", 245),
    ]

    var body: some View {
        let current = tracks[track]
        nowPlayingAdaptive(light: 0xF3F2EE, dark: 0x121212)
            .ignoresSafeArea()
            .safeAreaInset(edge: .bottom) {
                NowPlaying(title: current.title, artist: current.artist, isPlaying: playing, time: time, duration: current.duration, expanded: $open,
                           onPlayPause: { playing.toggle() },
                           onSkip: { skip in
                               track = (track + (skip == .next ? 1 : tracks.count - 1)) % tracks.count
                               time = 0
                           },
                           onSeek: { time = $0 }) {
                    NowPlayingExampleArtwork(track: track)
                }
                .padding(.horizontal, 12)
                .padding(.bottom, 8)
            }
            .task(id: playing) {
                while playing, !Task.isCancelled {
                    try? await Task.sleep(for: .milliseconds(500))
                    guard playing else { break }
                    time = min(time + 0.5, tracks[track].duration)
                }
            }
    }
}

/// Block art drawn in code: a butter sun over a sage hill on sky, then lilac with three hanging lanterns.
private struct NowPlayingExampleArtwork: View {
    var track: Int

    var body: some View {
        Canvas { context, size in
            let w = size.width, h = size.height
            func color(_ hex: UInt32) -> Color {
                Color(red: Double((hex >> 16) & 0xFF) / 255, green: Double((hex >> 8) & 0xFF) / 255, blue: Double(hex & 0xFF) / 255)
            }
            if track % 2 == 0 {
                context.fill(Path(CGRect(origin: .zero, size: size)), with: .color(color(0x9CC2FF)))
                context.fill(Path(ellipseIn: CGRect(x: w * 0.52, y: h * 0.16, width: w * 0.3, height: w * 0.3)), with: .color(color(0xFFD976)))
                var hill = Path()
                hill.move(to: CGPoint(x: 0, y: h * 0.7))
                hill.addCurve(to: CGPoint(x: w, y: h * 0.62), control1: CGPoint(x: w * 0.3, y: h * 0.48), control2: CGPoint(x: w * 0.62, y: h * 0.5))
                hill.addLine(to: CGPoint(x: w, y: h))
                hill.addLine(to: CGPoint(x: 0, y: h))
                hill.closeSubpath()
                context.fill(hill, with: .color(color(0xA9DCB7)))
                var near = Path()
                near.move(to: CGPoint(x: 0, y: h * 0.86))
                near.addCurve(to: CGPoint(x: w, y: h * 0.84), control1: CGPoint(x: w * 0.36, y: h * 0.74), control2: CGPoint(x: w * 0.7, y: h * 0.8))
                near.addLine(to: CGPoint(x: w, y: h))
                near.addLine(to: CGPoint(x: 0, y: h))
                near.closeSubpath()
                context.fill(near, with: .color(color(0xE9D5B3)))
            } else {
                context.fill(Path(CGRect(origin: .zero, size: size)), with: .color(color(0xCDB8FF)))
                let lanterns: [(x: CGFloat, y: CGFloat, r: CGFloat, hex: UInt32)] = [(0.26, 0.5, 0.15, 0xFFD976), (0.56, 0.36, 0.12, 0xE9D5B3), (0.78, 0.6, 0.1, 0x9CC2FF)]
                for lantern in lanterns {
                    var string = Path()
                    string.move(to: CGPoint(x: w * lantern.x, y: 0))
                    string.addLine(to: CGPoint(x: w * lantern.x, y: h * (lantern.y - lantern.r)))
                    context.stroke(string, with: .color(color(0x141414)), lineWidth: max(w * 0.008, 0.5))
                    context.fill(Path(ellipseIn: CGRect(x: w * (lantern.x - lantern.r), y: h * lantern.y - w * lantern.r, width: w * lantern.r * 2, height: w * lantern.r * 2)), with: .color(color(lantern.hex)))
                }
            }
        }
    }
}

#Preview("Light") {
    NowPlayingExample()
}

#Preview("Dark") {
    NowPlayingExample()
        .preferredColorScheme(.dark)
}

// MARK: - Piece motion
//
// The SwiftPieces motion language: shared spring tokens and gesture physics, with the same values in every
// piece. Each piece carries a copy of only the parts it uses, so this file stands alone. Generated from
// registry/foundation/PieceMotion.swift in the SwiftPieces repo; edit it there, not here.
// swiftpieces-motion: 1.1.0 (core, momentum, rubberBand, pressMath)

/// The SwiftPieces motion language: five spring tiers, and a named role for every moment a piece moves,
/// each with its Reduce Motion substitute.
///
/// Build one from the environment, `PieceMotion(reduceMotion: reduceMotion)`, and pick the role that names
/// what just happened. Never animate the tracking of a finger: set gesture state directly in `onChanged`, so the
/// surface stays under the finger, and spring only the release.
private struct PieceMotion {
    var reduceMotion = false

    // Tiers. Overshoot and settle times are measured from rest with SwiftUI's Spring.
    /// No overshoot, 90% in about 90ms. A press arriving under the finger.
    static var tight: Spring { Spring(duration: 0.14, bounce: 0) }
    /// About 2.8% overshoot, 90% in about 140ms. Snapping to a detent, page or segment.
    static var responsive: Spring { Spring(duration: 0.32, bounce: 0.25) }
    /// About 8.4% overshoot. Visible give: a release, a return from past an edge, a drag settling home.
    static var elastic: Spring { Spring(duration: 0.42, bounce: 0.38) }
    /// About 15% overshoot. A resolved action landing. At most once per interaction.
    static var expressive: Spring { Spring(duration: 0.48, bounce: 0.48) }
    /// About 1.5% overshoot, unhurried. Opening large surfaces and ambient change.
    static var calm: Spring { Spring(duration: 0.5, bounce: 0.2) }

    /// Reduce Motion: settles become this short spring with no overshoot.
    private static var still: Animation { .spring(duration: 0.25, bounce: 0) }

    // Roles.
    /// Touch-down. Starts on the same frame and never bounces under the finger.
    var press: Animation { .spring(Self.tight) }
    /// The finger lifts off a pressed surface, which springs back through rest.
    var release: Animation { reduceMotion ? Self.still : .spring(Self.elastic) }
    /// A dragged thing comes to rest. With a gesture's velocity, use `settle(velocity:from:to:)` instead.
    var settle: Animation { reduceMotion ? Self.still : .spring(Self.elastic) }
    /// Lands on a detent, page or segment.
    var snap: Animation { reduceMotion ? Self.still : .spring(Self.responsive) }
    /// A number, a chart value or anything else people read moves to its new value. Never overshoots, so it
    /// never shows a value that isn't true.
    var value: Animation { reduceMotion ? Self.still : .spring(duration: 0.35, bounce: 0) }
    /// Comes back from past a limit: a pull beyond the edge, a value pushed against its bound.
    var rebound: Animation { reduceMotion ? Self.still : .spring(Self.elastic) }
    /// A shape or container changes size, corner radius or form, with a little give at the end.
    var morph: Animation { reduceMotion ? Self.still : .spring(duration: 0.4, bounce: 0.2) }
    /// Something appears, opens or expands. Opening is a little slower than closing.
    var reveal: Animation { reduceMotion ? .easeOut(duration: 0.2) : .spring(Self.calm) }
    /// Something leaves, closes or collapses. Quick and firm, out of the way.
    var dismiss: Animation { reduceMotion ? .easeIn(duration: 0.18) : .spring(duration: 0.3, bounce: 0.08) }
    /// A resolved action lands: a check, a sent state, a reaction.
    var success: Animation { reduceMotion ? .easeOut(duration: 0.24) : .spring(Self.expressive) }
    /// A refused action. Firm, no wobble; `pieceShake` adds the movement.
    var error: Animation { reduceMotion ? .easeOut(duration: 0.2) : .spring(Self.responsive) }
    /// Slow ambient change. Loops themselves stop under Reduce Motion: check `allowsAmbient`.
    var ambient: Animation { reduceMotion ? .easeInOut(duration: 0.3) : .spring(Self.calm) }

    /// Loops, drifts, idle breathing and parallax run only when this is true.
    var allowsAmbient: Bool { !reduceMotion }

    /// A moving transition, or a plain fade under Reduce Motion.
    func transition(_ transition: AnyTransition) -> AnyTransition {
        reduceMotion ? .opacity : transition
    }

    /// For content replaced inside a container that stays put (a label, a glyph, a count): the old content
    /// blurs out as the new one sharpens in. A fade under Reduce Motion.
    @MainActor var swap: AnyTransition {
        reduceMotion ? .opacity : AnyTransition(.blurReplace)
    }
}

extension PieceMotion {
    /// Where a flick at `velocity` (pt/s) coasts to. 0.998 coasts like a scroll view; 0.99 suits detents.
    nonisolated static func project(_ position: CGFloat, velocity: CGFloat, decelerationRate: CGFloat = 0.99) -> CGFloat {
        position + velocity / 1000 * decelerationRate / (1 - decelerationRate)
    }

    /// The candidate closest to `value`.
    nonisolated static func nearest(_ value: CGFloat, in candidates: [CGFloat]) -> CGFloat {
        candidates.min { abs($0 - value) < abs($1 - value) } ?? value
    }

    /// A settle that leaves at the finger's speed (pt/s). One per axis, each on its own `.offset(x:)` / `.offset(y:)`:
    /// a spring takes one velocity. SwiftUI's own velocity carry-over is unreliable; this always carries it.
    func settle(velocity: CGFloat, from current: CGFloat, to target: CGFloat, spring: Spring = PieceMotion.elastic) -> Animation {
        let spring = reduceMotion ? Spring(duration: 0.25, bounce: 0) : spring
        let distance = target - current
        guard abs(distance) >= 1 else { return .spring(spring) }
        // In whole distances per second, capped near the spring's frequency: a hard flick adds give, not a slingshot.
        // At exactly the frequency a critically damped spring cannot pass its target, so Reduce Motion stops there.
        let cap = 2 * Double.pi / spring.duration * (reduceMotion ? 1 : 1.5)
        let relative = min(max(Double(velocity / distance), -cap), cap)
        return .interpolatingSpring(spring, initialVelocity: relative)
    }
}

extension PieceMotion {
    /// A scroll view's edge resistance for a pull `overshoot` points past a limit; never reaches `limit`.
    /// About 24 to 40 for thumbs and toggles, 60 to 120 for cards and sheets. Band the total pull, not deltas.
    nonisolated static func rubberBand(_ overshoot: CGFloat, limit: CGFloat, coefficient: CGFloat = 0.55) -> CGFloat {
        guard limit > 0, overshoot != 0 else { return 0 }
        let banded = (1 - 1 / (abs(overshoot) * coefficient / limit + 1)) * limit
        return overshoot < 0 ? -banded : banded
    }

    /// `value` inside `range` passes through unchanged; past either end it moves with rubber-band resistance.
    nonisolated static func rubberBand(_ value: CGFloat, in range: ClosedRange<CGFloat>, limit: CGFloat, coefficient: CGFloat = 0.55) -> CGFloat {
        if value < range.lowerBound { return range.lowerBound + rubberBand(value - range.lowerBound, limit: limit, coefficient: coefficient) }
        if value > range.upperBound { return range.upperBound + rubberBand(value - range.upperBound, limit: limit, coefficient: coefficient) }
        return value
    }
}

extension PieceMotion {
    /// About `depth` points per edge, not a fixed percentage: an icon sinks to 0.92, a pill 0.95, a card 0.985.
    nonisolated static func pressScale(for size: CGSize, depth: CGFloat = 2.5) -> CGFloat {
        let side = (max(size.width, 1) * max(size.height, 1)).squareRoot()
        return min(max(1 - depth * 2 / side, 0.92), 0.985)
    }

    /// An anchor partway from the center toward the touch, so the press leans into the finger without tipping.
    nonisolated static func pressAnchor(touch: CGPoint?, in size: CGSize, lean: CGFloat = 0.6) -> UnitPoint {
        guard let touch, size.width > 0, size.height > 0 else { return .center }
        let x = min(max(touch.x / size.width, 0), 1)
        let y = min(max(touch.y / size.height, 0), 1)
        return UnitPoint(x: 0.5 + (x - 0.5) * lean, y: 0.5 + (y - 0.5) * lean)
    }
}

// swiftpieces-motion: end

// MARK: - Piece liquid
//
// The SwiftPieces liquid glass language: glass shapes that merge through a neck, bubbles that bud out of
// and melt back into each other, and the frosted fallback before iOS 26. Each piece carries a copy of only
// the parts it uses, so this file stands alone. Generated from registry/foundation/PieceLiquid.swift in the
// SwiftPieces repo; edit it there, not here. The rules are in LIQUID_GLASS.md.
// swiftpieces-liquid: 1.7.1 (liquid, liquidPress, bud, morphText)

/// The liquid glass language: one merge distance, two rest gaps, and the springs a bubble leaves and comes home on.
///
/// Glass shapes inside one `PieceLiquidGroup` melt into each other through a neck when they come within `merge`
/// points. Parts of one control rest `joined`, inside that distance, so the neck holds; separate actions rest
/// `apart`, outside it, so they only goo while one buds out of, or melts back into, another.
private enum PieceLiquid {
    /// Glass shapes closer than this share a neck.
    static let merge: CGFloat = 20
    /// The gap between parts of one control (a stepper's buttons, a progress pill and its stop): the neck holds,
    /// short and smooth, about two thirds of the shapes' height at its waist. Joined parts read best at one height.
    static let joined: CGFloat = 4
    /// The gap between separate actions (menu items, confirm and cancel, chips): they rest as their own bubbles.
    static let apart: CGFloat = 26
    /// How far a bubble shrinks while it is home inside its parent.
    static let homeScale: CGFloat = 0.72

    /// A bubble leaving its parent: slow enough that the neck's stretch and snap read.
    static func split(reduceMotion: Bool) -> Animation {
        reduceMotion ? .easeOut(duration: 0.2) : .spring(duration: 0.62, bounce: 0.22)
    }

    /// A bubble going home. No bounce: a bounce would carry it out through the far side of its parent.
    static func home(reduceMotion: Bool) -> Animation {
        reduceMotion ? .easeIn(duration: 0.18) : .spring(duration: 0.5, bounce: 0)
    }
}

/// A group of glass shapes that merge into one liquid surface. On iOS 26 it is a `GlassEffectContainer`; before
/// that, and under Reduce Transparency, the shapes draw on their own and simply don't merge. `lift` adds the soft
/// shadow liquid glass floats on in light mode.
private struct PieceLiquidGroup<Content: View>: View {
    var spacing: CGFloat = PieceLiquid.merge
    var lift = true
    @ViewBuilder var content: Content
    @Environment(\.accessibilityReduceTransparency) private var reduceTransparency
    @Environment(\.colorScheme) private var colorScheme

    var body: some View {
        container
            .shadow(color: .black.opacity(lift && colorScheme == .light ? 0.07 : 0), radius: 18, y: 8)
    }

    @ViewBuilder private var container: some View {
        #if compiler(>=6.2)
        if #available(iOS 26, *), !reduceTransparency {
            GlassEffectContainer(spacing: spacing) { content }
        } else {
            content
        }
        #else
        content
        #endif
    }
}

/// One liquid glass shape: Liquid Glass on iOS 26, carrying `tint` as a solid colour and swelling under a press when
/// `interactive`; a frosted Material with a light rim and a soft shadow before that; a solid fill under Reduce
/// Transparency.
///
/// The tint is painted inside clear glass rather than tinting the glass. Tinted glass in a group bleeds its colour
/// through every neck as a smear, so a red button would glow into the white pill it is joined to; painted inside,
/// the colour stays crisp to the shape's edge, the necks between shapes are clear glass, and a tint change animates
/// like any colour (tinted glass snaps).
private struct PieceLiquidSurface<S: Shape>: ViewModifier {
    var shape: S
    var tint: Color?
    var interactive: Bool
    @Environment(\.accessibilityReduceTransparency) private var reduceTransparency
    @Environment(\.colorScheme) private var colorScheme
    @Environment(\.pieceLiquidScale) private var scale

    func body(content: Content) -> some View {
        #if compiler(>=6.2)
        if #available(iOS 26, *), !reduceTransparency {
            // Scaled as two parts, content and outline, about the same centre. A scaleEffect on a glass view inside a
            // GlassEffectContainer shrinks the glass but leaves what it carries full size, pinned to its corner.
            content
                .background { shape.fill(tint ?? .clear) }
                .scaleEffect(scale)
                .glassEffect(glass, in: shape.scale(scale))
        } else {
            fallback(content).scaleEffect(scale)
        }
        #else
        fallback(content).scaleEffect(scale)
        #endif
    }

    #if compiler(>=6.2)
    @available(iOS 26, *)
    private var glass: Glass {
        interactive ? Glass.regular.interactive() : .regular
    }
    #endif

    private func fallback(_ content: Content) -> some View {
        let dark = colorScheme == .dark
        return content
            .background {
                if reduceTransparency {
                    shape.fill(tint ?? (dark ? Color(white: 0.17) : Color(white: 0.97)))
                } else {
                    ZStack {
                        shape.fill(.regularMaterial)
                        if let tint { shape.fill(tint.opacity(0.88)) }
                    }
                }
            }
            .overlay { shape.stroke(Color.white.opacity(dark ? 0.14 : 0.7), lineWidth: 0.5) }
            .shadow(color: .black.opacity(dark ? 0.32 : 0.08), radius: 10, y: 5)
    }
}

private struct PieceLiquidScaleKey: EnvironmentKey {
    static let defaultValue: CGFloat = 1
}

private extension EnvironmentValues {
    /// How much the liquid shapes below are scaled, about their own centres. Nested scales multiply.
    var pieceLiquidScale: CGFloat {
        get { self[PieceLiquidScaleKey.self] }
        set { self[PieceLiquidScaleKey.self] = newValue }
    }
}

private extension View {
    /// Draws this view on a liquid glass `shape`. Put it inside a `PieceLiquidGroup` so it can merge with its neighbours.
    func pieceLiquid<S: Shape>(_ shape: S, tint: Color? = nil, interactive: Bool = true) -> some View {
        modifier(PieceLiquidSurface(shape: shape, tint: tint, interactive: interactive))
    }

    /// Scales the liquid glass shapes in this view, content and outline together, about their own centres. Use it
    /// instead of `scaleEffect` on a glass view (a press, a lift, a swell): inside a group a plain `scaleEffect` shrinks
    /// the glass but leaves its content full size and off centre. Animates like any other value.
    func pieceLiquidScale(_ scale: CGFloat) -> some View {
        transformEnvironment(\.pieceLiquidScale) { $0 *= scale }
    }
}

/// The press for a glass control: the same size-aware sink and lean as `piecePress`, applied through
/// `pieceLiquidScale` so the glass and what it carries sink together (a plain scaleEffect on glass leaves the content
/// behind). Put the glass inside what it presses: the label of a button, the view this modifies. Under Reduce Motion
/// it shades instead of moving.
private struct PieceLiquidPress: ViewModifier {
    let pressed: Bool
    var touch: CGPoint?
    var depth: CGFloat = 2.5
    @Environment(\.accessibilityReduceMotion) private var reduceMotion
    @Environment(\.colorScheme) private var colorScheme
    @State private var size: CGSize = .zero
    @State private var anchor: UnitPoint = .center

    func body(content: Content) -> some View {
        let motion = PieceMotion(reduceMotion: reduceMotion)
        let scale = pressed && !reduceMotion ? PieceMotion.pressScale(for: size, depth: depth) : 1
        // A scale about `anchor` is a scale about the centre plus this shift toward the anchor.
        let lean = CGSize(width: (anchor.x - 0.5) * size.width * (1 - scale), height: (anchor.y - 0.5) * size.height * (1 - scale))
        content
            .onGeometryChange(for: CGSize.self) { $0.size } action: { size = $0 }
            .brightness(pressed && reduceMotion ? (colorScheme == .dark ? 0.1 : -0.08) : 0)
            .pieceLiquidScale(scale)
            .offset(lean)
            .animation(pressed ? motion.press : motion.release, value: pressed)
            .onChange(of: pressed) { _, isPressed in
                if isPressed { anchor = PieceMotion.pressAnchor(touch: touch, in: size) }
            }
            .onChange(of: touch) { _, touch in
                if pressed, let touch { anchor = PieceMotion.pressAnchor(touch: touch, in: size) }
            }
    }
}

/// `PiecePressStyle` for glass buttons: the label (with its `.pieceLiquid` inside) sinks as one.
private struct PieceLiquidPressStyle: ButtonStyle {
    var depth: CGFloat = 2.5

    func makeBody(configuration: Configuration) -> some View {
        configuration.label.pieceLiquidPress(configuration.isPressed, depth: depth)
    }
}

private extension View {
    /// Sinks this view's glass while `pressed`, leaning toward `touch` (in this view's coordinates) when given.
    func pieceLiquidPress(_ pressed: Bool, touch: CGPoint? = nil, depth: CGFloat = 2.5) -> some View {
        modifier(PieceLiquidPress(pressed: pressed, touch: touch, depth: depth))
    }
}

/// The bud: how a bubble leaves and rejoins its parent, driven explicitly so every bubble shows the whole cycle.
///
/// A bubble is born at `home`, inside its parent, where the two glass shapes are one. It springs out to `rest`, and
/// while it is inside the merge distance a neck holds it to the parent, thinning as it goes, until it snaps free.
/// Going home it springs back on a spring with no bounce, the neck reaches out and re-forms, and only once it has
/// melted all the way in is it removed. Both offsets are relative to where the bubble is laid out. Under Reduce
/// Motion it stays at `rest`: its content fades and its glass closes in place.
private struct PieceBud: ViewModifier {
    var out: Bool
    var rest: CGSize
    var home: CGSize
    @Environment(\.accessibilityReduceMotion) private var reduceMotion

    func body(content: Content) -> some View {
        if reduceMotion {
            // No travel. Inside a group glass ignores opacity, so the glass closes to nothing in place on the short
            // Reduce Motion ease while its content fades; the opacity covers a bubble outside a group.
            content
                .pieceLiquidScale(out ? 1 : 0.001)
                .opacity(out ? 1 : 0)
                .offset(rest)
        } else {
            // The glass shrinks through `pieceLiquidScale`, never a plain scaleEffect (see there), then moves.
            content
                .pieceLiquidScale(out ? 1 : PieceLiquid.homeScale)
                .offset(out ? rest : home)
        }
    }
}

/// A bubble's own content, on its own clock: gone the moment the bubble heads home, so it never rides over the
/// parent's content, and arriving just after the bubble leaves.
private struct PieceBudContent: ViewModifier {
    var out: Bool

    func body(content: Content) -> some View {
        content
            .blur(radius: out ? 0 : 6)
            .opacity(out ? 1 : 0)
            .animation(out ? .easeOut(duration: 0.3).delay(0.1) : .easeOut(duration: 0.14), value: out)
    }
}

private extension View {
    /// Places a bubble at `rest` while `out`, and at `home` (inside its parent, shrunk) while not.
    func pieceBud(out: Bool, rest: CGSize = .zero, home: CGSize) -> some View {
        modifier(PieceBud(out: out, rest: rest, home: home))
    }

    /// Hides a bubble's icon or label while it is home. Put it on the content, inside the glass.
    func pieceBudContent(out: Bool) -> some View {
        modifier(PieceBudContent(out: out))
    }
}

/// Which bubbles exist and which are out. A bubble is added home with no animation, sent out on the next frame,
/// and called home before it is removed, so it always melts in rather than fading. Keep one in `@State`.
@MainActor @Observable
private final class PieceBuds {
    private(set) var present: [String] = []
    private(set) var out: Set<String> = []
    /// The latest call for each bubble. A bloom or gather that has been overtaken (a bubble sent home while it was
    /// still waiting to go out, or called out again while melting) leaves that bubble alone.
    @ObservationIgnored private var turn: [String: Int] = [:]

    func contains(_ id: String) -> Bool { present.contains(id) }
    func isOut(_ id: String) -> Bool { out.contains(id) }

    private func claim(_ ids: [String]) -> [String: Int] {
        var mine: [String: Int] = [:]
        for id in ids {
            let next = (turn[id] ?? 0) + 1
            turn[id] = next
            mine[id] = next
        }
        return mine
    }

    /// Puts bubbles straight out at rest with no motion: a view's first frame, or a state restored.
    func place(_ ids: [String]) {
        _ = claim(ids)
        var quiet = Transaction()
        quiet.disablesAnimations = true
        withTransaction(quiet) {
            for id in ids where !present.contains(id) { present.append(id) }
            out.formUnion(ids)
        }
    }

    /// Adds bubbles home, then sends each out, `stagger` seconds apart, after an optional `delay`.
    func bloom(_ ids: [String], reduceMotion: Bool, stagger: Double = 0.05, delay: Double = 0) async {
        let mine = claim(ids)
        var quiet = Transaction()
        quiet.disablesAnimations = true
        withTransaction(quiet) {
            for id in ids where !present.contains(id) { present.append(id) }
        }
        try? await Task.sleep(for: .milliseconds(24 + Int(max(delay, 0) * 1000)))
        let split = PieceLiquid.split(reduceMotion: reduceMotion)
        for (i, id) in ids.enumerated() where turn[id] == mine[id] {
            withAnimation(split.delay(reduceMotion ? 0 : Double(i) * stagger)) { _ = out.insert(id) }
        }
    }

    /// Calls bubbles home, last first, then removes them once they have melted in.
    func gather(_ ids: [String], reduceMotion: Bool, stagger: Double = 0.04) async {
        let mine = claim(ids)
        let home = PieceLiquid.home(reduceMotion: reduceMotion)
        for (i, id) in ids.reversed().enumerated() {
            withAnimation(home.delay(reduceMotion ? 0 : Double(i) * stagger)) { _ = out.remove(id) }
        }
        try? await Task.sleep(for: .milliseconds(Int((0.52 + Double(ids.count) * stagger) * 1000)))
        var quiet = Transaction()
        quiet.disablesAnimations = true
        withTransaction(quiet) { present.removeAll { ids.contains($0) && !out.contains($0) && turn[$0] == mine[$0] } }
    }
}

/// A label that changes letter by letter: letters both strings share hold still, the rest blur out and the new ones
/// blur in a few milliseconds apart. Under Reduce Motion it cross-fades. VoiceOver reads the whole string.
///
/// Only text whose letters stand on their own is cut into letters: Latin, Greek, Cyrillic, CJK, digits and symbols.
/// Those letters are laid out left to right whatever the layout direction, the order they read in, so "1.5×" stays
/// "1.5×" in an Arabic or Hebrew interface instead of coming out backwards. Text in a right-to-left script, or in one
/// whose letters join or reorder (Arabic, Hebrew, Devanagari, Thai and their neighbours), can't be cut apart without
/// breaking it, so it changes as one: the old string blurs out where it stood while the new one blurs in.
private struct PieceMorphText: View {
    var text: String
    var font: Font = .body.weight(.semibold)
    @Environment(\.accessibilityReduceMotion) private var reduceMotion

    var body: some View {
        Group {
            if Self.splits(text) {
                let glyphs = Array(text)
                HStack(spacing: 0) {
                    ForEach(glyphs.indices, id: \.self) { i in
                        Text(String(glyphs[i]))
                            .id("\(i)\(glyphs[i])")
                            .transition(transition(i, scale: 0.6))
                    }
                }
                // The letters' reading order, not the layout's: an HStack lays its children out backwards under a
                // right-to-left layout.
                .environment(\.layoutDirection, .leftToRight)
            } else {
                ZStack(alignment: .leading) {
                    Text(text)
                        .id(text)
                        .transition(transition(0, scale: 0.9))
                }
            }
        }
        .font(font)
        .fixedSize()
        .accessibilityElement(children: .ignore)
        .accessibilityLabel(text)
    }

    private func transition(_ i: Int, scale: CGFloat) -> AnyTransition {
        guard !reduceMotion else { return .opacity }
        return AnyTransition(.blurReplace(.downUp)).combined(with: .scale(scale: scale, anchor: .bottom))
            .animation(.spring(duration: 0.42, bounce: 0.3).delay(Double(i) * 0.022))
    }

    /// Whether `text` can be cut into letters that each read left to right on their own: no right-to-left script, no
    /// script that joins or reorders its letters, and no bidirectional control characters.
    private static func splits(_ text: String) -> Bool {
        !text.unicodeScalars.contains { scalar in
            switch scalar.value {
            case 0x0590...0x08FF, // Hebrew, Arabic, Syriac, Thaana, NKo, Samaritan, Mandaic
                 0x0900...0x0DFF, // Devanagari to Sinhala
                 0x0E00...0x0FFF, // Thai, Lao, Tibetan
                 0x1000...0x109F, // Myanmar
                 0x1780...0x17FF, // Khmer
                 0xA8E0...0xA8FF, // Devanagari Extended
                 0xFB1D...0xFDFF, // Hebrew and Arabic presentation forms
                 0xFE70...0xFEFF, // Arabic presentation forms B
                 0x200E...0x200F, 0x202A...0x202E, 0x2066...0x2069, // bidirectional marks, embeddings and isolates
                 0x10800...0x10FFF, // historic right-to-left scripts
                 0x1E800...0x1EFFF: // Adlam and other right-to-left scripts
                return true
            default:
                return false
            }
        }
    }
}

// swiftpieces-liquid: end

Install

Pick one. Run it from the folder that contains your .xcodeproj and the files land inside your app.

Terminal

$npx swiftpieces add NowPlaying

Or ask your coding agent · Claude Code, Cursor or Xcode, once the MCP server is connected

>Add the SwiftPieces "Now Playing" piece to my app

Or copy the source above into your app.

New to SwiftUI?If you know React, CSS or Tailwind, SwiftUI is easier to pick up than it looks.Try real app patterns first →

Building a whole app? See SwiftPieces Pro →

All media →

Build it yourself: SwiftUI animations guide

What's new

On this page

SwiftPiecesPro

Build the whole app with one library.

Production-ready screens, whole flows, complete app templates, a Build Kit your agent follows and Pro remixing.

54 screens · 48 flows · 12 templates · 31 Build Kit skills

Explore Pro

Sponsors

Keep it free

Your logo hereBecome a sponsor