diff --git a/AppleNugs/App/AppModel.swift b/AppleNugs/App/AppModel.swift index 038c002..7c587bf 100644 --- a/AppleNugs/App/AppModel.swift +++ b/AppleNugs/App/AppModel.swift @@ -140,8 +140,10 @@ final class AppModel { /// Sign in on nugs.net's own page in a system browser sheet — the app's /// only sign-in (it never sees a password). A user-cancelled sheet is not - /// surfaced as an error. + /// surfaced as an error. A second call while one is in flight is a no-op: + /// the views guard the double tap too, but the model owns the rule. func loginWithBrowser() async { + guard !isLoggingIn else { return } isLoggingIn = true loginError = nil defer { isLoggingIn = false } diff --git a/AppleNugs/App/UIState.swift b/AppleNugs/App/UIState.swift index bb05645..5c416f6 100644 --- a/AppleNugs/App/UIState.swift +++ b/AppleNugs/App/UIState.swift @@ -69,11 +69,19 @@ final class UIState { // --- toasts (queue-op confirmations, same as the web layout) --------------- - private(set) var toast: String? + /// A transient message. `kind` only changes the capsule's mark (a check or + /// a warning mark). + struct Toast: Equatable { + enum Kind: Equatable { case success, failure } + let text: String + let kind: Kind + } + + private(set) var toast: Toast? private var toastTask: Task? - func showToast(_ text: String) { - toast = text + func showToast(_ text: String, kind: Toast.Kind = .success) { + toast = Toast(text: text, kind: kind) toastTask?.cancel() toastTask = Task { try? await Task.sleep(for: .seconds(1.8)) diff --git a/AppleNugs/AppIcon.icon/Assets/bars.svg b/AppleNugs/AppIcon.icon/Assets/bars.svg new file mode 100644 index 0000000..a6fa46d --- /dev/null +++ b/AppleNugs/AppIcon.icon/Assets/bars.svg @@ -0,0 +1 @@ + diff --git a/AppleNugs/AppIcon.icon/Assets/disc.svg b/AppleNugs/AppIcon.icon/Assets/disc.svg new file mode 100644 index 0000000..e18b671 --- /dev/null +++ b/AppleNugs/AppIcon.icon/Assets/disc.svg @@ -0,0 +1 @@ + diff --git a/AppleNugs/AppIcon.icon/Assets/film.svg b/AppleNugs/AppIcon.icon/Assets/film.svg new file mode 100644 index 0000000..42d8127 --- /dev/null +++ b/AppleNugs/AppIcon.icon/Assets/film.svg @@ -0,0 +1 @@ + diff --git a/AppleNugs/AppIcon.icon/Assets/flange.svg b/AppleNugs/AppIcon.icon/Assets/flange.svg new file mode 100644 index 0000000..f4ad8a1 --- /dev/null +++ b/AppleNugs/AppIcon.icon/Assets/flange.svg @@ -0,0 +1 @@ + diff --git a/AppleNugs/AppIcon.icon/Assets/foil.svg b/AppleNugs/AppIcon.icon/Assets/foil.svg new file mode 100644 index 0000000..fa58e2b --- /dev/null +++ b/AppleNugs/AppIcon.icon/Assets/foil.svg @@ -0,0 +1 @@ + diff --git a/AppleNugs/AppIcon.icon/Assets/glow.svg b/AppleNugs/AppIcon.icon/Assets/glow.svg new file mode 100644 index 0000000..c0124e9 --- /dev/null +++ b/AppleNugs/AppIcon.icon/Assets/glow.svg @@ -0,0 +1 @@ + diff --git a/AppleNugs/AppIcon.icon/Assets/interior.svg b/AppleNugs/AppIcon.icon/Assets/interior.svg new file mode 100644 index 0000000..c68d5cf --- /dev/null +++ b/AppleNugs/AppIcon.icon/Assets/interior.svg @@ -0,0 +1 @@ + diff --git a/AppleNugs/AppIcon.icon/Assets/rim.svg b/AppleNugs/AppIcon.icon/Assets/rim.svg new file mode 100644 index 0000000..3623d00 --- /dev/null +++ b/AppleNugs/AppIcon.icon/Assets/rim.svg @@ -0,0 +1 @@ + diff --git a/AppleNugs/AppIcon.icon/icon.json b/AppleNugs/AppIcon.icon/icon.json new file mode 100644 index 0000000..df46e65 --- /dev/null +++ b/AppleNugs/AppIcon.icon/icon.json @@ -0,0 +1,149 @@ +{ + "fill": { + "linear-gradient": [ + "srgb:0.08235,0.10980,0.20000,1.00000", + "srgb:0.04314,0.05882,0.10980,1.00000" + ] + }, + "fill-specializations": [ + { + "value": { + "linear-gradient": [ + "srgb:0.08235,0.10980,0.20000,1.00000", + "srgb:0.04314,0.05882,0.10980,1.00000" + ] + } + }, + { + "appearance": "dark", + "value": "automatic" + } + ], + "groups": [ + { + "layers": [ + { + "glass": false, + "hidden": false, + "image-name": "foil.svg", + "name": "Foil glint" + } + ], + "name": "Foil", + "shadow": { + "kind": "none", + "opacity": 0.5 + }, + "specular": false, + "translucency": { + "enabled": false, + "value": 0.5 + } + }, + { + "layers": [ + { + "glass": true, + "hidden": false, + "image-name": "rim.svg", + "name": "Rim" + }, + { + "glass": false, + "hidden": false, + "image-name": "film.svg", + "name": "Film" + } + ], + "name": "Bubble", + "shadow": { + "kind": "none", + "opacity": 0.5 + }, + "specular": true, + "translucency": { + "enabled": false, + "value": 0.5 + } + }, + { + "layers": [ + { + "glass": false, + "hidden": false, + "image-name": "bars.svg", + "name": "Bars" + }, + { + "fill-specializations": [ + { + "appearance": "tinted", + "value": { + "solid": "srgb:1.00000,1.00000,1.00000,1.00000" + } + } + ], + "glass": false, + "hidden": false, + "image-name": "disc.svg", + "name": "Disc" + } + ], + "name": "Sticker", + "shadow": { + "kind": "neutral", + "opacity": 0.5 + }, + "specular": false, + "translucency": { + "enabled": false, + "value": 0.5 + } + }, + { + "layers": [ + { + "glass": false, + "hidden": false, + "image-name": "interior.svg", + "name": "Interior" + }, + { + "glass": false, + "hidden": false, + "image-name": "flange.svg", + "name": "Flange" + }, + { + "glass": false, + "hidden": false, + "hidden-specializations": [ + { + "appearance": "dark", + "value": true + }, + { + "appearance": "tinted", + "value": true + } + ], + "image-name": "glow.svg", + "name": "Glow" + } + ], + "name": "Card", + "shadow": { + "kind": "neutral", + "opacity": 0.6 + }, + "specular": false, + "translucency": { + "enabled": false, + "value": 0.5 + } + } + ], + "supported-platforms": { + "squares": "shared" + } +} diff --git a/AppleNugs/Assets.xcassets/AppIcon.appiconset/Contents.json b/AppleNugs/Assets.xcassets/AppIcon.appiconset/Contents.json deleted file mode 100644 index ebc345b..0000000 --- a/AppleNugs/Assets.xcassets/AppIcon.appiconset/Contents.json +++ /dev/null @@ -1,16 +0,0 @@ -{ - "images" : [ - { "idiom" : "mac", "scale" : "1x", "size" : "16x16", "filename" : "icon_16x16.png" }, - { "idiom" : "mac", "scale" : "2x", "size" : "16x16", "filename" : "icon_16x16@2x.png" }, - { "idiom" : "mac", "scale" : "1x", "size" : "32x32", "filename" : "icon_32x32.png" }, - { "idiom" : "mac", "scale" : "2x", "size" : "32x32", "filename" : "icon_32x32@2x.png" }, - { "idiom" : "mac", "scale" : "1x", "size" : "128x128", "filename" : "icon_128x128.png" }, - { "idiom" : "mac", "scale" : "2x", "size" : "128x128", "filename" : "icon_128x128@2x.png" }, - { "idiom" : "mac", "scale" : "1x", "size" : "256x256", "filename" : "icon_256x256.png" }, - { "idiom" : "mac", "scale" : "2x", "size" : "256x256", "filename" : "icon_256x256@2x.png" }, - { "idiom" : "mac", "scale" : "1x", "size" : "512x512", "filename" : "icon_512x512.png" }, - { "idiom" : "mac", "scale" : "2x", "size" : "512x512", "filename" : "icon_512x512@2x.png" }, - { "idiom" : "universal", "platform" : "ios", "size" : "1024x1024", "filename" : "icon_ios_1024.png" } - ], - "info" : { "author" : "xcode", "version" : 1 } -} diff --git a/AppleNugs/Assets.xcassets/AppIcon.appiconset/icon_128x128.png b/AppleNugs/Assets.xcassets/AppIcon.appiconset/icon_128x128.png deleted file mode 100644 index 5fcfd02..0000000 Binary files a/AppleNugs/Assets.xcassets/AppIcon.appiconset/icon_128x128.png and /dev/null differ diff --git a/AppleNugs/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x.png b/AppleNugs/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x.png deleted file mode 100644 index 132586e..0000000 Binary files a/AppleNugs/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x.png and /dev/null differ diff --git a/AppleNugs/Assets.xcassets/AppIcon.appiconset/icon_16x16.png b/AppleNugs/Assets.xcassets/AppIcon.appiconset/icon_16x16.png deleted file mode 100644 index 688f7d0..0000000 Binary files a/AppleNugs/Assets.xcassets/AppIcon.appiconset/icon_16x16.png and /dev/null differ diff --git a/AppleNugs/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x.png b/AppleNugs/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x.png deleted file mode 100644 index 303d8aa..0000000 Binary files a/AppleNugs/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x.png and /dev/null differ diff --git a/AppleNugs/Assets.xcassets/AppIcon.appiconset/icon_256x256.png b/AppleNugs/Assets.xcassets/AppIcon.appiconset/icon_256x256.png deleted file mode 100644 index 132586e..0000000 Binary files a/AppleNugs/Assets.xcassets/AppIcon.appiconset/icon_256x256.png and /dev/null differ diff --git a/AppleNugs/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x.png b/AppleNugs/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x.png deleted file mode 100644 index 68576ba..0000000 Binary files a/AppleNugs/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x.png and /dev/null differ diff --git a/AppleNugs/Assets.xcassets/AppIcon.appiconset/icon_32x32.png b/AppleNugs/Assets.xcassets/AppIcon.appiconset/icon_32x32.png deleted file mode 100644 index 303d8aa..0000000 Binary files a/AppleNugs/Assets.xcassets/AppIcon.appiconset/icon_32x32.png and /dev/null differ diff --git a/AppleNugs/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png b/AppleNugs/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png deleted file mode 100644 index 0b8a35f..0000000 Binary files a/AppleNugs/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png and /dev/null differ diff --git a/AppleNugs/Assets.xcassets/AppIcon.appiconset/icon_512x512.png b/AppleNugs/Assets.xcassets/AppIcon.appiconset/icon_512x512.png deleted file mode 100644 index 68576ba..0000000 Binary files a/AppleNugs/Assets.xcassets/AppIcon.appiconset/icon_512x512.png and /dev/null differ diff --git a/AppleNugs/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png b/AppleNugs/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png deleted file mode 100644 index 3f721ba..0000000 Binary files a/AppleNugs/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png and /dev/null differ diff --git a/AppleNugs/Assets.xcassets/AppIcon.appiconset/icon_ios_1024.png b/AppleNugs/Assets.xcassets/AppIcon.appiconset/icon_ios_1024.png deleted file mode 100644 index 73425c5..0000000 Binary files a/AppleNugs/Assets.xcassets/AppIcon.appiconset/icon_ios_1024.png and /dev/null differ diff --git a/AppleNugs/Core/DeepLinkRouter.swift b/AppleNugs/Core/DeepLinkRouter.swift index c145a5c..7221522 100644 --- a/AppleNugs/Core/DeepLinkRouter.swift +++ b/AppleNugs/Core/DeepLinkRouter.swift @@ -17,7 +17,7 @@ enum DeepLinkRouter { case .video: try await openVideo(link, app: app, ui: ui) } } catch { - ui.showToast("Couldn't open that show on nugs") + ui.showToast("Couldn't open that show on nugs", kind: .failure) } } @@ -25,7 +25,7 @@ enum DeepLinkRouter { private static func openAudio(_ link: DeepLink, app: AppModel, ui: UIState) async throws { guard let containerId = try await resolveContainerId(link, app: app) else { - ui.showToast("That show isn't on nugs") + ui.showToast("That show isn't on nugs", kind: .failure) return } // Do the fallible fetch + queue build BEFORE touching navigation, so a @@ -101,7 +101,7 @@ enum DeepLinkRouter { /// would miss. Page through (bounded) until the date is found or the list ends. private static func openVideo(_ link: DeepLink, app: AppModel, ui: UIState) async throws { guard let artistId = try await artistId(matching: link.artist, app: app) else { - ui.showToast("That show isn't on nugs"); return + ui.showToast("That show isn't on nugs", kind: .failure); return } let pageSize = 100 let maxPages = 10 // safety cap (≤1000 videos) @@ -116,7 +116,7 @@ enum DeepLinkRouter { if page.count < pageSize { break } // last page offset += page.count } - ui.showToast("No video for that show on nugs") + ui.showToast("No video for that show on nugs", kind: .failure) } // MARK: - helpers diff --git a/AppleNugs/Core/FoilSweepTrigger.swift b/AppleNugs/Core/FoilSweepTrigger.swift new file mode 100644 index 0000000..bd2e769 --- /dev/null +++ b/AppleNugs/Core/FoilSweepTrigger.swift @@ -0,0 +1,51 @@ +import Foundation + +/// When Yeti's one flourish — the foil band crossing the now-playing title — +/// should run. Pure Foundation so the four rules are pinned host-free in +/// `AppleNugsTests`; the `foilSweep(identity:isPlaying:)` modifier holds one +/// of these and runs the band when `observe` says so. +/// +/// The whole state is the identity that last swept. The rule: **fire when +/// playback is running for an identity that isn't the one that last swept**, +/// then remember it. That single rule gives every behavior the design asks for: +/// +/// - a new track while playing fires once; +/// - pause → resume does not (same identity, already swept), so a three-hour +/// show with ten pauses sees it once per track; +/// - a launch-restored queue (current set, NOT playing) fires nothing until +/// its first play, then fires; +/// - the same track coming back later (re-queued, or skipped away from and +/// back to) fires again only if a DIFFERENT track swept in between. A track +/// that was current but never played doesn't count as "in between", and +/// neither does an empty queue (`nil` never overwrites the memory). +/// +/// The identity is the catalog track id (`QueueTrack.trackId`), not the +/// per-entry `QueueTrack.id` UUID: keyed on the UUID, re-queueing a track +/// would always look new. It reads **no playback time** — identity and +/// `isPlaying` only — so it can never put the sweep on the 4 Hz tick. +struct FoilSweepTrigger: Equatable, Sendable { + /// The identity the band last crossed (or was marked as seen), if any. + private(set) var lastSwept: String? + + init(lastSwept: String? = nil) { + self.lastSwept = lastSwept + } + + /// Feed the latest playback state (on every identity or `isPlaying` + /// change). Returns `true` exactly when the sweep should run now. + mutating func observe(identity: String?, isPlaying: Bool) -> Bool { + guard isPlaying, let identity, identity != lastSwept else { return false } + lastSwept = identity + return true + } + + /// The title's view appeared. A track that is ALREADY playing when the + /// view shows up (opening the player mid-song, the Duo rebuilding the + /// player on rotation) is marked as seen without a sweep — the band marks + /// a track change, not a screen opening. A track that is loaded but not + /// playing is left unmarked, so a launch-restored queue still sweeps on + /// its first play. + mutating func appear(identity: String?, isPlaying: Bool) { + _ = observe(identity: identity, isPlaying: isPlaying) + } +} diff --git a/AppleNugs/Core/LedgerSummary.swift b/AppleNugs/Core/LedgerSummary.swift index 6a80f01..67aebf8 100644 --- a/AppleNugs/Core/LedgerSummary.swift +++ b/AppleNugs/Core/LedgerSummary.swift @@ -20,12 +20,24 @@ enum LedgerSummary { var seconds: Double } + /// A number and its unit, kept apart so a ledger can set them in two + /// weights (the ledger's "**41** plays", "**62** h", "**112** hours + /// listened"). Read together they say exactly what the joined string says. + struct Figure: Equatable { + let value: String + let unit: String + } + struct Row: Identifiable, Equatable { let rank: Int let key: String let displayName: String let playsText: String let hoursText: String + /// The same two numbers, split for the column layout ("41" "plays", + /// "62" "h"; under an hour, "41" "m"). + let plays: Figure + let hours: Figure var id: String { key } } @@ -34,6 +46,9 @@ enum LedgerSummary { let hoursText: String let artistCountText: String let artistCount: Int + /// The one-line totals ("**112** hours listened · **9** artists"). + let hours: Figure + let artists: Figure } /// The ledger lives inline on Home, so it is capped at what fits there. @@ -55,7 +70,9 @@ enum LedgerSummary { .reduce(0, +) return Totals(hoursText: totalHoursText(seconds: seconds), artistCountText: String(tallies.count), - artistCount: tallies.count) + artistCount: tallies.count, + hours: totalHoursFigure(seconds: seconds), + artists: artistsFigure(tallies.count)) } /// Top artists by seconds. Ties break by plays, then by name, then by key, @@ -76,7 +93,9 @@ enum LedgerSummary { key: tally.key, displayName: tally.displayName, playsText: playsText(tally.plays), - hoursText: hoursText(seconds: tally.seconds)) + hoursText: hoursText(seconds: tally.seconds), + plays: playsFigure(tally.plays), + hours: hoursFigure(seconds: tally.seconds)) } } @@ -101,4 +120,33 @@ enum LedgerSummary { static func playsText(_ plays: Int) -> String { plays == 1 ? "1 play" : "\(plays) plays" } + + // --- split figures (the ledger's columns and totals line) ---------------- + + /// The totals line's hours: the same number as `totalHoursText`, with the + /// unit spelled out. "1 hour listened", and "<1 hour listened" below one — + /// less than one hour is still singular. + static func totalHoursFigure(seconds: Double) -> Figure { + let value = totalHoursText(seconds: seconds) + let unit = (value == "1" || value == "<1") ? "hour listened" : "hours listened" + return Figure(value: value, unit: unit) + } + + static func artistsFigure(_ count: Int) -> Figure { + Figure(value: String(count), unit: count == 1 ? "artist" : "artists") + } + + /// A row's plays column: "41" "plays", "1" "play". + static func playsFigure(_ plays: Int) -> Figure { + Figure(value: String(plays), unit: plays == 1 ? "play" : "plays") + } + + /// A row's hours column: the same rule as `hoursText` (whole hours once + /// there is one, else whole minutes), split into "62" "h" / "41" "m". + static func hoursFigure(seconds: Double) -> Figure { + guard seconds.isFinite, seconds > 0 else { return Figure(value: "0", unit: "m") } + let hours = Int(seconds / 3600) + if hours > 0 { return Figure(value: String(hours), unit: "h") } + return Figure(value: String(max(1, Int(seconds / 60))), unit: "m") + } } diff --git a/AppleNugs/Core/ListeningStatsStore.swift b/AppleNugs/Core/ListeningStatsStore.swift index 4dd47ec..92844b0 100644 --- a/AppleNugs/Core/ListeningStatsStore.swift +++ b/AppleNugs/Core/ListeningStatsStore.swift @@ -24,8 +24,6 @@ final class ListeningStatsStore { /// during playback, plus every track change and every pause — would /// invalidate the greeting, the resume hero, the favorites strip and the /// crate grid, not just the ledger. This flips at most once per install. - /// Same class of hazard as the `artColor` read HomeView guards behind - /// `#if os(macOS)` for exactly the same reason. private(set) var hasAnyPlay = false /// Latched when `listening.json` exists but could not be read cleanly. diff --git a/AppleNugs/Core/Models.swift b/AppleNugs/Core/Models.swift index ee099bd..b2d4dc2 100644 --- a/AppleNugs/Core/Models.swift +++ b/AppleNugs/Core/Models.swift @@ -123,19 +123,6 @@ enum AudioFormat: String { } } - /// Short badge text for the transport bar. - var badge: String { - switch self { - case .flac16: return "FLAC" - case .mqa24: return "MQA" - case .alac16: return "ALAC" - case .s360ra: return "360RA" - case .aac150: return "AAC" - case .hls: return "HLS" - case .unknown: return "?" - } - } - /// Native playback preference (lower is better). Unlike the web port we /// put ALAC first — it ships in an MP4 container, AVFoundation's home /// turf — and HLS stays playable (AVPlayer speaks it natively; the diff --git a/AppleNugs/Core/NowPlayingMetaRouter.swift b/AppleNugs/Core/NowPlayingMetaRouter.swift index 064d43a..965a379 100644 --- a/AppleNugs/Core/NowPlayingMetaRouter.swift +++ b/AppleNugs/Core/NowPlayingMetaRouter.swift @@ -37,7 +37,7 @@ enum NowPlayingMetaRouter { inFlight = Task { await previous?.value guard let entry = try? await app.artist(named: name) else { - ui.showToast("Couldn't find that artist on nugs") + ui.showToast("Couldn't find that artist on nugs", kind: .failure) return } raiseMainWindowIfNeeded() diff --git a/AppleNugs/Core/PriceStickerLabel.swift b/AppleNugs/Core/PriceStickerLabel.swift new file mode 100644 index 0000000..6b99bf6 --- /dev/null +++ b/AppleNugs/Core/PriceStickerLabel.swift @@ -0,0 +1,71 @@ +import Foundation + +/// What the Yeti price sticker (the lossless badge) says — or `nil` when it +/// should not be shown at all. +/// +/// Pure Foundation so the rules are pinned host-free in `AppleNugsTests`. The +/// view (`PriceSticker`) feeds it plain values; `PriceStickerLabel(player:)` +/// in `PriceSticker.swift` reads them off `PlayerService` correctly. +/// +/// **Presence is the CALLER's job and is gated on `player.current`, never on +/// the stream pick or the specs.** `PlayerService.startCurrent()` nils both +/// `nowPick` and `specs` on every track change, so a sticker gated on them +/// would blink off and on at each track boundary (the Signal-section bug class, +/// one sticker at a time). That is why an unknown pick still yields a label: +/// +/// - pick unknown → "Lossless", no spec line. Tier 7 (ALAC 16) is always tried +/// first (`StreamTierPlan`), so lossless is the honest default while the +/// pick resolves. Any depth/rate passed in is ignored: it can only be stale. +/// - pick known and lossless → "Lossless" over "24/96" (bit depth / sample rate +/// in whole kHz, 44.1 → "44"); no spec line while either value is unknown. +/// - pick known and NOT lossless → `nil`: hidden (the view keeps its footprint). +struct PriceStickerLabel: Equatable, Sendable { + /// The top line, always "Lossless". + let title: String + /// The bottom line, e.g. "24/96" or "16/44"; `nil` while depth or rate is + /// unknown (or the pick is). + let spec: String? + /// What VoiceOver reads: "Lossless, 24-bit 96 kilohertz" / "Lossless". + /// Speaks the true rate ("44.1 kilohertz") where the printed line rounds. + let accessibilityLabel: String + + static let title = "Lossless" + + /// - Parameters: + /// - pickKnown: whether a stream pick (format) is known for the current track. + /// - isLossless: the pick's `AudioFormat.isLossless`; ignored when the pick is unknown. + /// - bitDepth: bits per sample; `nil` or `<= 0` is unknown. + /// - sampleRate: in Hz; `nil`, `<= 0` or non-finite is unknown. + init?(pickKnown: Bool, isLossless: Bool, bitDepth: Int?, sampleRate: Double?) { + title = Self.title + guard pickKnown else { + spec = nil + accessibilityLabel = Self.title + return + } + guard isLossless else { return nil } + + guard let bits = bitDepth, bits > 0, + let hz = sampleRate, hz > 0, hz.isFinite else { + spec = nil + accessibilityLabel = Self.title + return + } + spec = "\(bits)/\(Self.wholeKilohertz(hz))" + accessibilityLabel = "\(Self.title), \(bits)-bit \(Self.spokenKilohertz(hz)) kilohertz" + } + + /// Whole kHz for the printed line, truncated the way rates are written on + /// the box: 44,100 → 44, 88,200 → 88, 96,000 → 96. Rounds to whole Hz + /// first so a decoder's 47,999.99 still reads 48. + private static func wholeKilohertz(_ hz: Double) -> Int { + Int(hz.rounded()) / 1000 + } + + /// kHz to one decimal, trailing ".0" dropped: 44,100 → "44.1", 96,000 → + /// "96". Integer arithmetic, so no locale decides the decimal separator. + private static func spokenKilohertz(_ hz: Double) -> String { + let tenths = Int((hz / 100).rounded()) + return tenths % 10 == 0 ? "\(tenths / 10)" : "\(tenths / 10).\(tenths % 10)" + } +} diff --git a/AppleNugs/Core/SignalReadout.swift b/AppleNugs/Core/SignalReadout.swift index fb1ed99..08b39a2 100644 --- a/AppleNugs/Core/SignalReadout.swift +++ b/AppleNugs/Core/SignalReadout.swift @@ -14,7 +14,7 @@ import Foundation /// ANY combination of nil / present / zero inputs, `rows(...)` returns the same /// `rowCount` rows in the same `Kind` order. Unknown values degrade to /// `unknown`; a row never disappears. Height then falls out of the row count -/// and the theme's own fonts, so it is automatically right in all five themes +/// and the theme's own fonts, so it is automatically right at any type size /// and under iOS Dynamic Type — no reserved heights, no magic numbers. /// /// Deliberately Foundation-only (no SwiftUI, no AVFoundation) and taking loose @@ -23,9 +23,8 @@ import Foundation enum SignalReadout { /// The one placeholder for every unknown scalar. Matches the em-dash the - /// rest of the app already uses for "no value yet" (FaceplateTransport, - /// ClickWheelScreen, TouchFaceplate, CrateItem); `--:--` stays reserved for - /// clocks. + /// rest of the app already uses for "no value yet" (e.g. CrateItem); + /// `--:--` stays reserved for clocks. static let unknown = "—" /// Row identity. The `ForEach` keys on this rather than on the label, diff --git a/AppleNugs/Downloads/OfflineAccessPolicy.swift b/AppleNugs/Downloads/OfflineAccessPolicy.swift index 9f75bb4..feb6559 100644 --- a/AppleNugs/Downloads/OfflineAccessPolicy.swift +++ b/AppleNugs/Downloads/OfflineAccessPolicy.swift @@ -13,6 +13,15 @@ enum OfflineAccessPolicy { static let futureTolerance: TimeInterval = 24 * 60 * 60 static let expiredMessage = "Go online to keep listening offline" + /// The second line under `expiredMessage`, wherever it is shown: why. + static let expiredDetail = + "Offline play needs nugs.net to confirm your subscription at least once every 30 days." + + /// The explanatory line to print under a playback error, if it has one. + /// Only the offline-expiry error does; every other message stands alone. + static func detail(forError message: String) -> String? { + message == expiredMessage ? expiredDetail : nil + } static func allowsOfflinePlay(lastVerifiedAt: Date?, now: Date) -> Bool { guard let lastVerifiedAt else { return false } diff --git a/AppleNugs/Player/PlayerService.swift b/AppleNugs/Player/PlayerService.swift index 804d4a7..0ae2cd7 100644 --- a/AppleNugs/Player/PlayerService.swift +++ b/AppleNugs/Player/PlayerService.swift @@ -175,6 +175,15 @@ final class PlayerService { /// audio and video never both drive the system playback UI. private var suspendedForVideo = false + /// Set by the video player: asks it to step aside (pause and hand Now + /// Playing back) when the listener deliberately starts audio while a video + /// owns playback. Deliberate starts are the public transport entry points + /// (see `takePlaybackFromVideo`); automatic ones — a resolve or retry + /// finishing, a removed track's slot refilling — never call it, and + /// `startPlayback()` refuses them while suspended, so audio can't start + /// underneath a playing video. + var yieldExternalOwner: (@MainActor () -> Void)? + /// True only between an audio-session interruption pausing playback and /// that interruption ending — the gate that keeps `.shouldResume` from /// restarting audio the user paused themselves (iOS only; inert on macOS). @@ -491,6 +500,7 @@ final class PlayerService { // Settle while the head is still this track: once it advances, // its playhead can no longer be read. settleListening() + takePlaybackFromVideo() player.advanceToNextItem() adoptPreload(p) isPlaying = startPlayback() @@ -522,6 +532,11 @@ final class PlayerService { /// an ongoing call. @discardableResult private func startPlayback() -> Bool { + // A video owns playback: an automatic start (a stream resolve or retry + // landing after the video took over) must not play underneath it. The + // item stays loaded, so the video's hand-back can resume it. Deliberate + // starts called `takePlaybackFromVideo()` first and are never blocked. + guard !suspendedForVideo else { return false } #if os(iOS) do { try AVAudioSession.sharedInstance().setActive(true) @@ -539,10 +554,14 @@ final class PlayerService { // A restored (or finished) queue with nothing loaded yet — // (re)start the current track; pendingSeekPosition resumes // where the last session left off. - if current != nil { startCurrent() } + if current != nil { + takePlaybackFromVideo() + startCurrent() + } return } if player.timeControlStatus == .paused { + takePlaybackFromVideo() rebaseListening() isPlaying = startPlayback() } else { @@ -555,6 +574,7 @@ final class PlayerService { } func resume() { + takePlaybackFromVideo() guard player.currentItem != nil else { if current != nil { startCurrent() } return @@ -586,11 +606,25 @@ final class PlayerService { // Video now owns playback — a pending interruption-resume must not // fire underneath it once the interruption ends. pausedByInterruption = false - let wasPlaying = isPlaying + // Ask the engine too, not just `isPlaying`: the flag can trail the + // player (a head item we haven't adopted yet), and a stale false must + // neither leave audio running under the video nor forget to resume it. + let wasPlaying = isPlaying || player.rate != 0 if wasPlaying { pause() } + player.pause() return wasPlaying } + /// The listener deliberately started audio: if a video owns playback, ask + /// it to pause and hand playback back (without resuming us — we're about + /// to start). Clears the suspension so the start goes through. + private func takePlaybackFromVideo() { + guard suspendedForVideo else { return } + yieldExternalOwner?() + // The video's hand-back clears this; make sure even without a hook. + suspendedForVideo = false + } + /// Counterpart to `pauseForExternalAudio`: the external (video) owner has /// relinquished. Clears the suspension — so audio owns Now Playing and the /// remote commands again — and optionally resumes playback. @@ -624,7 +658,12 @@ final class PlayerService { // --- playback engine -------------------------------------------------------- - private func startAt(_ i: Int) { + /// `deliberate`: the listener chose this track (play, enqueue or play-next + /// into an idle queue, jump, next, previous), so audio takes playback back + /// from a video. The one automatic caller — a track ending with no + /// successor preloaded — passes false and stays silent under a video. + private func startAt(_ i: Int, deliberate: Bool = true) { + if deliberate { takePlaybackFromVideo() } pendingSeekPosition = nil index = i ended = false @@ -875,7 +914,7 @@ final class PlayerService { } else if hasNext { // No preload ready (resolution in flight, or it failed) — fall // back to a full load and accept the small gap. - startAt(index + 1) + startAt(index + 1, deliberate: false) } else { ended = true // queue finished — next enqueue/play-next restarts playback commitListening() @@ -1251,9 +1290,8 @@ final class PlayerService { // --- artwork -------------------------------------------------------------- /// The cached cover image for the now-playing track, if it has loaded. - /// Reused by themed now-playing chips and the album-art color extractor — - /// reading the observed cache means views update when art arrives, with no - /// extra fetch. + /// Reused by the now-playing chips and art tiles — reading the observed + /// cache means views update when art arrives, with no extra fetch. var nowPlayingImage: PlatformImage? { current?.artworkPath.flatMap { artworkCache[$0] } } diff --git a/AppleNugs/Player/VideoPlayerService.swift b/AppleNugs/Player/VideoPlayerService.swift index ed182f1..dedcc2f 100644 --- a/AppleNugs/Player/VideoPlayerService.swift +++ b/AppleNugs/Player/VideoPlayerService.swift @@ -24,8 +24,9 @@ enum VideoQuality: Hashable { /// from the audio queue's `AVQueuePlayer`. Starting a video pauses the audio /// `PlayerService` (remembering whether it was playing) and claims the system /// Now Playing / remote-command center; `stop()` relinquishes both and resumes -/// audio if it had been playing. Only one of audio/video owns Now Playing at a -/// time — the arbiter rule. +/// audio if it had been playing. Starting audio on purpose while a video plays +/// pauses the video (`yieldToAudio`). Only one of audio/video owns Now Playing +/// — and plays — at a time: the arbiter rule. @MainActor @Observable final class VideoPlayerService { @@ -101,6 +102,9 @@ final class VideoPlayerService { if let code = UserDefaults.standard.string(forKey: Self.qualityKey) { selectedQuality = Self.quality(from: code) } + // The other half of the arbiter: a deliberate audio start (the pill's + // play, a show's Play) while a video owns playback pauses the video. + audio.yieldExternalOwner = { [weak self] in self?.yieldToAudio() } let interval = CMTime(seconds: 0.5, preferredTimescale: 600) timeObserver = player.addPeriodicTimeObserver( forInterval: interval, queue: .main) { [weak self] _ in @@ -248,13 +252,19 @@ final class VideoPlayerService { pushNowPlayingInfo() } - /// Take over system Now Playing + remote commands from audio, unless we - /// already own them. Captures whether audio was playing so it can be resumed - /// on relinquish; guarding on `ownsNowPlaying` stops a re-entrant claim from - /// overwriting that captured state and stranding audio paused. + /// Take over system Now Playing + remote commands from audio. Audio is + /// silenced on EVERY claim, even one we think we already hold — a video + /// starting must never leave audio running, whatever our bookkeeping says. + /// Whether audio was playing is OR-ed into `resumeAudioOnStop`, so a + /// re-entrant claim (which finds audio already paused) can't overwrite the + /// state captured by the first one and strand audio paused. private func claimArbiterIfNeeded() { - guard !ownsNowPlaying else { return } - resumeAudioOnStop = audio.pauseForExternalAudio() + let audioWasPlaying = audio.pauseForExternalAudio() + guard !ownsNowPlaying else { + resumeAudioOnStop = resumeAudioOnStop || audioWasPlaying + return + } + resumeAudioOnStop = audioWasPlaying claimNowPlaying() } @@ -269,6 +279,20 @@ final class VideoPlayerService { resumeAudioOnStop = false } + /// The listener started audio on purpose while this video owned playback: + /// pause the video and hand Now Playing back WITHOUT resuming audio (it is + /// starting itself). The item stays loaded, so the video's own Play + /// re-claims and pauses audio again — the last thing started wins. + private func yieldToAudio() { + guard ownsNowPlaying else { return } + player.pause() + isPlaying = false + recordProgress(force: true) + relinquishOnFullScreenExit = false + resumeAudioOnStop = false + relinquishArbiter() + } + // --- full screen -------------------------------------------------------- /// True while the player view is inside AVKit's full-screen presentation. diff --git a/AppleNugs/Theme/ArtColorModifier.swift b/AppleNugs/Theme/ArtColorModifier.swift deleted file mode 100644 index 918d67d..0000000 --- a/AppleNugs/Theme/ArtColorModifier.swift +++ /dev/null @@ -1,42 +0,0 @@ -import SwiftUI - -/// Owns an `ArtColorProvider` and applies a window root's theming + live -/// art-color environment. Extracted from `RootView` so the main window and the -/// macOS Now Playing window tint from the same value. -struct ArtColorProvision: ViewModifier { - let app: AppModel - let theme: Theme - - @State private var artProvider = ArtColorProvider() - - /// The accent the chrome should use right now: the live art color when the - /// active theme is art-driven, else nil (so static themes are untouched). - private var activeArtColor: Color? { - theme.consumesArtColor ? artProvider.color : nil - } - - /// Refires the extractor when the track changes, when its art finishes - /// loading, or when the theme's appetite for art changes. - private var artTaskID: String { - let track = app.player.current?.id.uuidString ?? "none" - return "\(track)|\(app.player.nowPlayingImage != nil)|\(theme.consumesArtColor)" - } - - func body(content: Content) -> some View { - content - .themed(theme, art: activeArtColor) - .environment(\.artColor, activeArtColor) - .task(id: artTaskID) { - artProvider.update( - image: app.player.nowPlayingImage, - key: app.player.current?.artworkPath, - enabled: theme.consumesArtColor) - } - } -} - -extension View { - func providesArtColor(app: AppModel, theme: Theme) -> some View { - modifier(ArtColorProvision(app: app, theme: theme)) - } -} diff --git a/AppleNugs/Theme/ArtColorProvider.swift b/AppleNugs/Theme/ArtColorProvider.swift deleted file mode 100644 index 8400dba..0000000 --- a/AppleNugs/Theme/ArtColorProvider.swift +++ /dev/null @@ -1,92 +0,0 @@ -import CoreImage -import Observation -import SwiftUI - -/// Extracts a clamped, legible accent color from the now-playing cover art for -/// the themes that want it (Soundboard, The Receiver). Runs off the main actor, -/// caches per artwork path, and is never invoked for static-accent themes. -@MainActor -@Observable -final class ArtColorProvider { - private(set) var color: Color? - - private var cache: [String: Color] = [:] - private var task: Task? - - nonisolated static let fallback = Color(hex: 0xE0902E) - - /// Resolve the color for the current art. `enabled` is the active theme's - /// `consumesArtColor`, so static themes pay nothing. - func update(image: PlatformImage?, key: String?, enabled: Bool) { - task?.cancel() - guard enabled else { - set(nil) - return - } - guard let key, let image else { - set(Self.fallback) - return - } - if let cached = cache[key] { - set(cached) - return - } - task = Task.detached(priority: .utility) { - let resolved = ArtColorProvider.dominantColor(of: image) ?? Self.fallback - await MainActor.run { [weak self] in - self?.cache[key] = resolved - self?.set(resolved) - } - } - } - - private func set(_ newColor: Color?) { - withAnimation(.easeInOut(duration: 0.4)) { color = newColor } - } - - // --- extraction (nonisolated; runs on a background task) ---------------- - - nonisolated private static let ciContext = CIContext(options: [.workingColorSpace: NSNull()]) - - nonisolated private static func dominantColor(of image: PlatformImage) -> Color? { - guard let cg = image.cgImageForAnalysis else { return nil } - - let ci = CIImage(cgImage: cg) - guard let filter = CIFilter(name: "CIAreaAverage", parameters: [ - kCIInputImageKey: ci, - kCIInputExtentKey: CIVector(cgRect: ci.extent), - ]), let output = filter.outputImage else { return nil } - - var px = [UInt8](repeating: 0, count: 4) - ciContext.render( - output, toBitmap: &px, rowBytes: 4, - bounds: CGRect(x: 0, y: 0, width: 1, height: 1), - format: .RGBA8, colorSpace: nil) - - let (h, s, b) = Self.hsb( - r: CGFloat(px[0]) / 255, g: CGFloat(px[1]) / 255, b: CGFloat(px[2]) / 255) - // Clamp so a muddy or dark cover still yields a legible, lively tint. - let clampedS = max(s, 0.5) - let clampedB = min(max(b, 0.5), 0.66) - return Color(hue: Double(h), saturation: Double(clampedS), brightness: Double(clampedB)) - } - - /// RGB→HSB without NSColor/UIColor so the math is identical on both - /// platforms (and no device color-space conversion is involved). - nonisolated private static func hsb(r: CGFloat, g: CGFloat, b: CGFloat) - -> (h: CGFloat, s: CGFloat, b: CGFloat) { - let maxV = max(r, g, b), minV = min(r, g, b) - let delta = maxV - minV - var h: CGFloat = 0 - if delta > 0 { - switch maxV { - case r: h = ((g - b) / delta).truncatingRemainder(dividingBy: 6) / 6 - case g: h = ((b - r) / delta + 2) / 6 - default: h = ((r - g) / delta + 4) / 6 - } - if h < 0 { h += 1 } - } - let s = maxV == 0 ? 0 : delta / maxV - return (h, s, maxV) - } -} diff --git a/AppleNugs/Theme/Components/ArtWashBackground.swift b/AppleNugs/Theme/Components/ArtWashBackground.swift deleted file mode 100644 index c315cf3..0000000 --- a/AppleNugs/Theme/Components/ArtWashBackground.swift +++ /dev/null @@ -1,30 +0,0 @@ -import SwiftUI - -/// The album-art color wash behind a region. The look varies by theme: -/// a leading gradient (Soundboard), a faint full wash (Shoebox), or a soft -/// bloom (The Receiver). Animates as the color changes between tracks. -struct ArtWashBackground: View { - let style: WashStyle - let color: Color - - var body: some View { - Group { - switch style { - case .none: - Color.clear - case .linear: - LinearGradient( - colors: [color.opacity(0.45), .clear], - startPoint: .leading, endPoint: .trailing) - case .warmLow: - color.opacity(0.10) - case .bloom18: - RadialGradient( - colors: [color.opacity(0.18), .clear], - center: .center, startRadius: 0, endRadius: 180) - } - } - .allowsHitTesting(false) - .animation(.easeInOut(duration: 0.4), value: color) - } -} diff --git a/AppleNugs/Theme/Components/ArtistRow.swift b/AppleNugs/Theme/Components/ArtistRow.swift deleted file mode 100644 index ede526a..0000000 --- a/AppleNugs/Theme/Components/ArtistRow.swift +++ /dev/null @@ -1,26 +0,0 @@ -import SwiftUI - -/// One row in the artist list. Always a monogram tile + name (artists carry no -/// art); Shoebox adds an index-letter tab on the leading edge. -struct ArtistRow: View { - @Environment(\.theme) private var theme - let entry: ArtistEntry - - var body: some View { - HStack(spacing: 10) { - if theme.caps.contains(.indexLetterTab) { - Text(entry.name.first.map { String($0).uppercased() } ?? "·") - .font(theme.type.numeric(11)) - .foregroundStyle(theme.palette.textIdle) - .frame(width: 12, alignment: .center) - } - MonogramTile(text: entry.name, size: 28) - Text(entry.name) - .font(theme.type.body(14)) - .foregroundStyle(theme.palette.textPrimary) - .lineLimit(1) - Spacer(minLength: 0) - } - .padding(.vertical, 2) - } -} diff --git a/AppleNugs/Theme/Components/BlisterPack.swift b/AppleNugs/Theme/Components/BlisterPack.swift new file mode 100644 index 0000000..8af02d7 --- /dev/null +++ b/AppleNugs/Theme/Components/BlisterPack.swift @@ -0,0 +1,584 @@ +import SwiftUI + +// Yeti's one material: the cover sealed in a clear plastic bubble on a +// backing card, hung on a peg like a vintage action figure. Translated from +// the design sheets' `.card`, `.card::before` (the hang slot), `.bubble` (+ +// `::before` specular arc, `::after` glare streaks, `.art` glaze), +// `.bubble.sm`, `.bubble.empty .hollow` and `.card.pack`. +// +// **Art must yield height** (CLAUDE.md, iPhone Duo layout rules): the bubble +// is square and takes what the proposal leaves after the content printed +// below it, so on a short canvas (the Duo's flat inner display, a 300pt +// upright column) the bubble shrinks first and the nameplate keeps its lines. +// It is never a rigid tile. +// +// The glass colors are `Palette` tokens read through `BlisterGlass`; the card +// stock, keyline and the night showing through the hang slot come from the +// environment's theme. + +// MARK: - Pack + +/// A backing card (card stock + punched hang slot) holding a `BlisterBubble`, +/// with optional content printed on the card below the bubble (the +/// now-playing nameplate and meta, the sign-in "Yeti"), and an optional +/// `PriceSticker` slapped on the bubble's corner. +/// +/// Sizing: the card takes the proposed WIDTH. The bubble is square and gets +/// what the proposed HEIGHT leaves after `below` has its natural height +/// (`below` carries a raised layout priority), capped at `maxBubbleSide` if +/// given; a bubble narrower than the card sits centered. With no height +/// proposed (a scroll view) the bubble is as wide as the card's inner width. +/// +/// BlisterPack(price: PriceStickerLabel(player: player)) { +/// PackArt(image: player.nowPlayingImage, monogram: track.artist ?? "?") +/// } below: { +/// Text(track.title).foilSweep(identity: track.trackId, isPlaying: player.isPlaying) +/// } +/// +/// BlisterPack(emptyStyle: .small) // connection failed, empty states +struct BlisterPack: View { + typealias Style = BlisterPackStyle + + private let style: Style + private let price: PriceStickerLabel? + private let priceSize: PriceSticker.Size? + private let maxBubbleSide: CGFloat? + private let belowAlignment: HorizontalAlignment + private let art: Art? + private let below: Below + + /// A pack holding `art` (use `PackArt` for cover-or-monogram). + /// - `price`: the corner sticker's label; `nil` draws no sticker. Pass + /// `PriceStickerLabel(player:)` — it applies the presence rule. + /// - `priceSize`: forces a sticker size; `nil` picks one from the bubble's + /// current side (62pt on a full-size bubble, smaller as it yields). + /// - `maxBubbleSide`: caps the bubble (e.g. the old 280pt art cap). + init(style: Style = .regular, + price: PriceStickerLabel? = nil, + priceSize: PriceSticker.Size? = nil, + maxBubbleSide: CGFloat? = nil, + belowAlignment: HorizontalAlignment = .leading, + @ViewBuilder art: () -> Art, + @ViewBuilder below: () -> Below) { + self.style = style + self.price = price + self.priceSize = priceSize + self.maxBubbleSide = maxBubbleSide + self.belowAlignment = belowAlignment + self.art = art() + self.below = below() + } + + var body: some View { + let m = style.metrics + VStack(alignment: belowAlignment, spacing: m.gap) { + bubble + // Only consulted when nothing is proposed (an unconstrained + // ideal-size pass): the design's bubble size. + .frame(idealWidth: m.idealBubble, idealHeight: m.idealBubble) + .frame(maxWidth: maxBubbleSide, maxHeight: maxBubbleSide) + // Keep the slot square. A max-only frame reports + // max(child, proposal) up to its cap, so in a column narrower + // than it is tall (the Duo's 300pt upright column) a capped + // slot stood 320pt tall around a 208pt bubble, padding the + // card with dead space. Fitting a square first proposes the + // frame the bubble's own side. + .aspectRatio(1, contentMode: .fit) + .frame(maxWidth: .infinity) + below + .frame(maxWidth: .infinity, + alignment: Alignment(horizontal: belowAlignment, vertical: .center)) + // The bubble yields first: below is offered its natural + // height before the bubble takes the rest. + .layoutPriority(1) + } + .padding(m.cardPadding) + .frame(maxWidth: .infinity) + .background { BlisterCard() } + } + + @ViewBuilder private var bubble: some View { + let m = style.metrics + Group { + if let art { + BlisterBubble(metrics: m.bubble) { art } + } else { + BlisterBubble(emptyMetrics: m.bubble) + } + } + .overlay { + if let price { + GeometryReader { geo in + let side = min(geo.size.width, geo.size.height) + let size = priceSize ?? Self.autoPriceSize(bubbleSide: side) + let d = size.diameter + // From the sheet: a 62pt sticker centered 21pt in from the + // bubble's trailing edge and 33pt up from its bottom, so it + // rides over the edge onto the card. Scaled with the sticker. + PriceSticker(label: price, size: size) + .position(x: geo.size.width - d * (21.0 / 62.0), + y: geo.size.height - d * (33.0 / 62.0)) + } + } + } + } + + /// The corner sticker's size for a bubble of `side` points: the sheet's + /// 62pt on a full bubble, stepping down as the bubble yields so the + /// sticker never swamps the art. + static func autoPriceSize(bubbleSide side: CGFloat) -> PriceSticker.Size { + switch side { + case 190...: return .s62 + case 140..<190: return .s44 + case 110..<140: return .s36 + default: return .s32 + } + } +} + +extension BlisterPack where Below == EmptyView { + /// A pack with nothing printed below the bubble. + init(style: Style = .regular, + price: PriceStickerLabel? = nil, + priceSize: PriceSticker.Size? = nil, + maxBubbleSide: CGFloat? = nil, + @ViewBuilder art: () -> Art) { + self.init(style: style, price: price, priceSize: priceSize, + maxBubbleSide: maxBubbleSide, art: art, below: { EmptyView() }) + } +} + +extension BlisterPack where Art == EmptyView { + /// The EMPTY pack — the backing card whose bubble holds nothing yet — with + /// content printed below (sign-in's "Yeti" nameplate). Never animated, + /// never holds a glyph. + init(emptyStyle style: Style = .regular, + maxBubbleSide: CGFloat? = nil, + belowAlignment: HorizontalAlignment = .leading, + @ViewBuilder below: () -> Below) { + self.style = style + self.price = nil + self.priceSize = nil + self.maxBubbleSide = maxBubbleSide + self.belowAlignment = belowAlignment + self.art = nil + self.below = below() + } +} + +extension BlisterPack where Art == EmptyView, Below == EmptyView { + /// The EMPTY pack on its own (connection failed, empty states). + init(emptyStyle style: Style = .regular, maxBubbleSide: CGFloat? = nil) { + self.init(emptyStyle: style, maxBubbleSide: maxBubbleSide, below: { EmptyView() }) + } +} + +/// The three pack sizes the sheets draw. +enum BlisterPackStyle: Sendable { + /// The now-playing card: 34/26/20 card padding around a full bubble. + case regular + /// The iPhone Home hero (`.hero-card`): a full bubble on a tighter card, + /// 32/18/16, with the name panel 16pt below. + case hero + /// `.card.pack.sm`: 30/16/16 padding around a `.bubble.sm`. + case small + + struct Metrics { + let bubble: BlisterBubbleMetrics + let cardPadding: EdgeInsets + /// Bubble to printed content (`.np .name { margin-top: 18px }`). + let gap: CGFloat + /// The bubble's side when nothing is proposed. + let idealBubble: CGFloat + } + + var metrics: Metrics { + switch self { + case .regular: + // `.np .card { padding: 34px 26px 20px }` in a 350pt card — bar + // the top, which clears the hang slot (see `topPadding`). + return Metrics(bubble: .regular, + cardPadding: EdgeInsets(top: Self.topPadding(.regular, clear: 8), + leading: 26, bottom: 20, trailing: 26), + gap: 18, idealBubble: 298) + case .hero: + // `.hero-card { padding: 32px 18px 16px }`, `.hero-card .bubble + // { width: 186px }`, `.hero-card .name { margin-top: 16px }`. + return Metrics(bubble: .regular, + cardPadding: EdgeInsets(top: Self.topPadding(.regular, clear: 8), + leading: 18, bottom: 16, trailing: 18), + gap: 16, idealBubble: 186) + case .small: + // `.card.pack.sm { width: 156px; padding: 30px 16px 16px }`. + return Metrics(bubble: .small, + cardPadding: EdgeInsets(top: Self.topPadding(.small, clear: 6), + leading: 16, bottom: 16, trailing: 16), + gap: 14, idealBubble: 124) + } + } + + /// The card's top padding, derived rather than copied from the sheet. The + /// bubble's outer halo ring is drawn OUTSIDE its frame (`haloOuter` wide), + /// and the sheet's 30–34pt tops left that ring touching — on the hero, + /// overlapping — the hang slot. So: the slot's bottom edge, then `clear` + /// points of bare card stock, then the halo, then the bubble. + private static func topPadding(_ bubble: BlisterBubbleMetrics, clear: CGFloat) -> CGFloat { + BlisterCard.slotBottom + clear + bubble.haloOuter + } +} + +// MARK: - Card + +/// The backing card: `cardStock`, a 1pt frost keyline, a soft drop +/// shadow, and the punched hang slot at the top showing the night behind. +/// Fills the frame it is given. +struct BlisterCard: View { + @Environment(\.theme) private var theme + + /// `.card { border-radius: 22px }` + static let cornerRadius: CGFloat = 22 + /// The hang slot's inset from the card's top edge (the sheet's 13, raised + /// 2pt to give the bubble's halo room below it). + nonisolated static let slotTop: CGFloat = 11 + /// Where the slot ends, light catch included — what the bubble's halo + /// must clear (`BlisterPackStyle.topPadding`). + nonisolated static let slotBottom: CGFloat = slotTop + HangSlot.height + HangSlot.lightCatch + + /// `0 18px 30px -22px rgba(0,0,0,.8)` + static let lift = StickerLift(layers: [ + .init(opacity: 0.8, y: 18, blur: 30, spread: -22), + ]) + + var body: some View { + let shape = RoundedRectangle(cornerRadius: Self.cornerRadius, style: .continuous) + shape + .fill(theme.palette.cardStock) + // `inset 0 0 0 1px var(--frost)` + .overlay { shape.strokeBorder(theme.palette.hairline, lineWidth: 1) } + .overlay(alignment: .top) { + HangSlot().padding(.top, Self.slotTop) + } + .stickerLift(Self.lift, in: shape) + .accessibilityHidden(true) + } +} + +/// `.card::before`: a 46×12 capsule punched through the card, showing the +/// night (`palette.base`), with a dark inner lip at the top and a faint light +/// catch on the card just below it. +struct HangSlot: View { + @Environment(\.theme) private var theme + + nonisolated static let width: CGFloat = 46 + nonisolated static let height: CGFloat = 12 + /// The 1pt light catch drawn just below the slot. + nonisolated static let lightCatch: CGFloat = 1 + + var body: some View { + let shape = Capsule() + shape + .fill(theme.palette.base) + // `inset 0 2px 3px rgba(0,0,0,.6)` + .overlay { InnerShadow(shape: shape, color: .black.opacity(0.6), x: 0, y: 2, blur: 3) } + // `0 1px 0 rgba(233,238,247,.08)`: the card's edge catching light. + .background { + OuterShadow(shape: shape, color: BlisterGlass.snow.opacity(0.08), + y: Self.lightCatch, blur: 0, spread: 0) + } + .frame(width: Self.width, height: Self.height) + } +} + +// MARK: - Bubble + +/// One bubble's measurements: the sheet's `.bubble` and `.bubble.sm`. +struct BlisterBubbleMetrics: Sendable { + let cornerRadius: CGFloat + let padding: CGFloat + let artRadius: CGFloat + /// `inset 0 0 0 Npx rgba(233,238,247,.34)` + let rim: CGFloat + /// `inset 0 Npx 0 rgba(255,255,255,a)` + let topCatch: CGFloat + let topCatchOpacity: Double + /// `inset -x -y blur rgba(4,7,16,.5)`: the shade pooling bottom-trailing. + let shade: (x: CGFloat, y: CGFloat, blur: CGFloat) + /// `inset x y blur rgba(233,238,247,.10)`: the glow along the top-leading. + let glow: (x: CGFloat, y: CGFloat, blur: CGFloat) + /// `0 0 0 Npx` rings: the faint 0.035 halo and the 0.15 outer edge. + let haloInner: CGFloat + let haloOuter: CGFloat + /// `0 y blur spread rgba(0,0,0,.75)` + let drop: StickerLift.Layer + + static let regular = BlisterBubbleMetrics( + cornerRadius: 30, padding: 14, artRadius: 18, + rim: 1.5, topCatch: 2, topCatchOpacity: 0.32, + shade: (-12, -16, 26), glow: (10, 12, 20), + haloInner: 7, haloOuter: 8, + drop: .init(opacity: 0.75, y: 22, blur: 28, spread: -16)) + + static let small = BlisterBubbleMetrics( + cornerRadius: 18, padding: 7, artRadius: 11, + rim: 1, topCatch: 1.5, topCatchOpacity: 0.3, + shade: (-6, -8, 14), glow: (5, 6, 10), + haloInner: 4, haloOuter: 5, + drop: .init(opacity: 0.75, y: 12, blur: 16, spread: -10)) +} + +/// The clear plastic bubble: layered glass gradient, inset rim, top catch +/// light, shade and glow, two outer halo rings and a drop shadow, with the +/// art (or the empty hollow) inside and the specular arc and glare streaks +/// over it. Always square; fills the largest square its proposal allows. +/// Usable without a card (the Mac miniplayer's small bubble). +struct BlisterBubble: View { + private let metrics: BlisterBubbleMetrics + private let art: Art? + + init(metrics: BlisterBubbleMetrics = .regular, @ViewBuilder art: () -> Art) { + self.metrics = metrics + self.art = art() + } + + var body: some View { + let m = metrics + let shape = RoundedRectangle(cornerRadius: m.cornerRadius, style: .continuous) + let artShape = RoundedRectangle(cornerRadius: m.artRadius, style: .continuous) + + Group { + if let art { + // `.bubble .art`: clipped, `saturate(.82) brightness(.94)`, + // under a cool 160° glaze. + Color.clear + .overlay { art } + .saturation(0.82) + .colorMultiply(Color(white: 0.94)) + .overlay { + GeometryReader { geo in + let p = CSSGradient.points(degrees: 160, in: geo.size) + LinearGradient(colors: [BlisterGlass.glazeTop.opacity(0.10), + BlisterGlass.glazeBottom.opacity(0.18)], + startPoint: p.start, endPoint: p.end) + } + } + .clipShape(artShape) + } else { + BlisterHollow(cornerRadius: m.artRadius) + } + } + .padding(m.padding) + .background { glass(shape) } + .overlay { sheen(shape) } + .background { outside(shape) } + .aspectRatio(1, contentMode: .fit) + } + + /// Under the art: the glass fill and its inset shadows. + private func glass(_ shape: RoundedRectangle) -> some View { + let m = metrics + return ZStack { + GeometryReader { geo in + let p = CSSGradient.points(degrees: 155, in: geo.size) + shape.fill(LinearGradient( + stops: [.init(color: BlisterGlass.glass.opacity(0.17), location: 0), + .init(color: BlisterGlass.glass.opacity(0.04), location: 0.42), + .init(color: BlisterGlass.glass.opacity(0.09), location: 1)], + startPoint: p.start, endPoint: p.end)) + } + InnerShadow(shape: shape, color: BlisterGlass.shade.opacity(0.5), + x: m.shade.x, y: m.shade.y, blur: m.shade.blur) + InnerShadow(shape: shape, color: BlisterGlass.snow.opacity(0.10), + x: m.glow.x, y: m.glow.y, blur: m.glow.blur) + InnerShadow(shape: shape, color: Color.white.opacity(m.topCatchOpacity), + x: 0, y: m.topCatch, blur: 0) + shape.strokeBorder(BlisterGlass.snow.opacity(0.34), lineWidth: m.rim) + } + .allowsHitTesting(false) + } + + /// Over the art: `::before`, the specular arc, and `::after`, two glare + /// streaks across the whole bubble. + private func sheen(_ shape: RoundedRectangle) -> some View { + GeometryReader { geo in + let w = geo.size.width, h = geo.size.height + ZStack(alignment: .topLeading) { + let p = CSSGradient.points(degrees: 118, in: geo.size) + shape.fill(LinearGradient( + stops: [.init(color: .white.opacity(0), location: 0.58), + .init(color: .white.opacity(0.16), location: 0.61), + .init(color: .white.opacity(0), location: 0.64), + .init(color: .white.opacity(0), location: 0.70), + .init(color: .white.opacity(0.07), location: 0.72), + .init(color: .white.opacity(0), location: 0.75)], + startPoint: p.start, endPoint: p.end)) + // top 7%, left 7%, 46% × 20%, rotated −20°, closest-side fade. + Ellipse() + .fill(EllipticalGradient(colors: [.white.opacity(0.42), .white.opacity(0)])) + .frame(width: w * 0.46, height: h * 0.20) + .rotationEffect(.degrees(-20)) + .offset(x: w * 0.07, y: h * 0.07) + } + } + .allowsHitTesting(false) + .accessibilityHidden(true) + } + + /// Outside the bubble: the drop shadow and the two halo rings. Clipped + /// to OUTSIDE the shape, as CSS outer shadows are — the glass is + /// translucent, so a shadow painted under it would darken it. + private func outside(_ shape: RoundedRectangle) -> some View { + let m = metrics + return ZStack { + OuterShadow(shape: shape, color: .black.opacity(m.drop.opacity), + y: m.drop.y, blur: m.drop.blur, spread: m.drop.spread) + shape.inset(by: -m.haloOuter) + .strokeBorder(BlisterGlass.snow.opacity(0.15), lineWidth: m.haloOuter) + shape.inset(by: -m.haloInner) + .strokeBorder(BlisterGlass.snow.opacity(0.035), lineWidth: m.haloInner) + } + .allowsHitTesting(false) + .accessibilityHidden(true) + } +} + +extension BlisterBubble where Art == EmptyView { + /// The empty bubble: holds the hollow and nothing else. + init(emptyMetrics metrics: BlisterBubbleMetrics = .regular) { + self.metrics = metrics + self.art = nil + } +} + +/// `.bubble.empty .hollow`: where the art would sit — a faint cool light +/// pooled a little above center and a barely-there keyline. +struct BlisterHollow: View { + let cornerRadius: CGFloat + + var body: some View { + let shape = RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) + GeometryReader { geo in + let w = geo.size.width, h = geo.size.height + // radial-gradient(70% 60% at 50% 38%, snow .06, snow 0 72%): an + // ellipse with radii 70%×60% of the box, centered at (50%, 38%). + Ellipse() + .fill(EllipticalGradient( + stops: [.init(color: BlisterGlass.snow.opacity(0.06), location: 0), + .init(color: BlisterGlass.snow.opacity(0), location: 0.72)], + center: .center, startRadiusFraction: 0, endRadiusFraction: 0.5)) + .frame(width: w * 1.4, height: h * 1.2) + .position(x: w * 0.5, y: h * 0.38) + } + .clipShape(shape) + .overlay { shape.strokeBorder(BlisterGlass.snow.opacity(0.05), lineWidth: 1) } + .aspectRatio(1, contentMode: .fit) + .accessibilityHidden(true) + } +} + +// MARK: - Art + +/// What goes in a now-playing bubble: the cover filling the art square, or — +/// with no cover — the initials monogram, which scales with the box it is +/// given exactly like real artwork (never a rigid tile; see the iPhone Duo +/// layout rules). +struct PackArt: View { + let image: PlatformImage? + /// The text the monogram's initials come from (artist, else title). + let monogram: String + + var body: some View { + if let image { + Color.clear + .overlay { + Image(platformImage: image) + .resizable() + .aspectRatio(contentMode: .fill) + } + .clipped() + } else { + GeometryReader { geo in + MonogramTile(text: monogram, size: min(geo.size.width, geo.size.height)) + .frame(width: geo.size.width, height: geo.size.height) + } + } + } +} + +// MARK: - Glass constants and drawing helpers + +/// The plastic's colors, from `Palette` (the bubble's drawing helpers are +/// environment-free, so they read `Theme.yeti`). +enum BlisterGlass { + /// rgba(233,238,247,·): snow, as light on plastic. + static var snow: Color { Theme.yeti.palette.textPrimary } + /// rgba(225,233,250,·): the bubble's glass tint. + static var glass: Color { Theme.yeti.palette.bubbleGlass } + /// rgba(4,7,16,·): the shade pooling in the plastic. + static var shade: Color { Theme.yeti.palette.bubbleShade } + /// rgba(190,210,255,·) → rgba(15,20,36,·): the glaze over the art. + static var glazeTop: Color { Theme.yeti.palette.bubbleGlaze } + static var glazeBottom: Color { Theme.yeti.palette.base } +} + +/// CSS `linear-gradient(deg, …)` endpoints for a box: the gradient +/// line runs through the center at `degrees` (0 = up, 90 = right), long +/// enough that the corners land on 0% and 100%. +enum CSSGradient { + static func points(degrees: Double, in size: CGSize) -> (start: UnitPoint, end: UnitPoint) { + guard size.width > 0, size.height > 0 else { return (.top, .bottom) } + let theta = degrees * .pi / 180 + let dx = sin(theta), dy = -cos(theta) + let half = (abs(size.width * dx) + abs(size.height * dy)) / 2 + let ux = dx * half / size.width, uy = dy * half / size.height + return (UnitPoint(x: 0.5 - ux, y: 0.5 - uy), UnitPoint(x: 0.5 + ux, y: 0.5 + uy)) + } +} + +/// CSS `inset x y blur color`: color everywhere except the shape shifted by +/// (x, y), blurred, clipped to the shape. +private struct InnerShadow: View { + let shape: S + let color: Color + let x: CGFloat + let y: CGFloat + let blur: CGFloat + + var body: some View { + let pad = blur * 2 + max(abs(x), abs(y)) + 4 + ZStack { + Rectangle().fill(color).padding(-pad) + shape.fill(Color.black) + .offset(x: x, y: y) + .blendMode(.destinationOut) + } + .compositingGroup() + .blur(radius: blur / 2) + .clipShape(shape) + .allowsHitTesting(false) + } +} + +/// CSS outer `0 y blur spread color`, painted only OUTSIDE the shape. +private struct OuterShadow: View { + let shape: S + let color: Color + let y: CGFloat + let blur: CGFloat + let spread: CGFloat + + var body: some View { + let pad = blur * 2 + abs(y) + abs(spread) + 4 + shape.inset(by: -spread) + .fill(color) + .blur(radius: blur / 2) + .offset(y: y) + .mask { + ZStack { + Rectangle().padding(-pad) + shape.fill(Color.black).blendMode(.destinationOut) + } + .compositingGroup() + } + .allowsHitTesting(false) + } +} diff --git a/AppleNugs/Theme/Components/EmptyPackMessage.swift b/AppleNugs/Theme/Components/EmptyPackMessage.swift new file mode 100644 index 0000000..5f8a91d --- /dev/null +++ b/AppleNugs/Theme/Components/EmptyPackMessage.swift @@ -0,0 +1,36 @@ +import SwiftUI + +/// Yeti's empty state (`.es` under a `.card.pack.sm` in the surfaces sheet): +/// the EMPTY blister pack, the app before anything has arrived, over a heavy +/// snow title and one slush line. Shared by the Library's Downloads and +/// Favorites, and the Videos tab. +struct EmptyPackMessage: View { + @Environment(\.theme) private var theme + + let title: String + let message: String + + /// The sheet's `.card.pack.sm` in the Library figure: 120pt wide. + static let packWidth: CGFloat = 120 + + var body: some View { + VStack(spacing: 0) { + BlisterPack(emptyStyle: .small) + .frame(width: Self.packWidth) + .accessibilityHidden(true) + Text(title) + .font(theme.type.title(20)) + .foregroundStyle(theme.palette.textPrimary) + .multilineTextAlignment(.center) + .padding(.top, 22) + Text(message) + .font(theme.type.body(15)) + .foregroundStyle(theme.palette.textSecondary) + .multilineTextAlignment(.center) + .frame(maxWidth: 290) + .padding(.top, 6) + } + .padding(.horizontal, 28) + .accessibilityElement(children: .combine) + } +} diff --git a/AppleNugs/Theme/Components/EqualizerBars.swift b/AppleNugs/Theme/Components/EqualizerBars.swift deleted file mode 100644 index 16b3136..0000000 --- a/AppleNugs/Theme/Components/EqualizerBars.swift +++ /dev/null @@ -1,29 +0,0 @@ -import SwiftUI - -/// A 3-bar animated meter for the now-playing row. Driven by a TimelineView at -/// ~8fps and frozen flat when paused, so it costs nothing when idle. -struct EqualizerBars: View { - @Environment(\.theme) private var theme - @Environment(\.artColor) private var artColor - let isPlaying: Bool - - var body: some View { - TimelineView(.animation(minimumInterval: 0.11, paused: !isPlaying)) { timeline in - let t = timeline.date.timeIntervalSinceReferenceDate - HStack(alignment: .center, spacing: 2) { - ForEach(0..<3, id: \.self) { i in - Capsule() - .fill(theme.effectiveAccent(art: artColor)) - .frame(width: 2.5, height: height(bar: i, at: t)) - } - } - .frame(width: 16, height: 13) - } - } - - private func height(bar i: Int, at t: Double) -> CGFloat { - guard isPlaying else { return 3 } - let phase = t * 5.5 + Double(i) * 1.9 - return 4 + 8 * (0.5 + 0.5 * sin(phase)) - } -} diff --git a/AppleNugs/Theme/Components/FaceplateParts.swift b/AppleNugs/Theme/Components/FaceplateParts.swift deleted file mode 100644 index c0dc084..0000000 --- a/AppleNugs/Theme/Components/FaceplateParts.swift +++ /dev/null @@ -1,85 +0,0 @@ -import SwiftUI - -// Shared hardware-flavored controls for The Receiver's transports — the Mac -// FaceplateTransport and the iOS TouchFaceplate render the same parts. - -/// Two horizontal LED-segment level bars (L/R) that sway while playing and rest -/// at the floor when paused. Synthesized motion (no FFT), throttled to ~12fps -/// and frozen when not playing. -struct VUMeter: View { - @Environment(\.theme) private var theme - let isPlaying: Bool - - private let segments = 14 - - var body: some View { - TimelineView(.animation(minimumInterval: 1.0 / 12.0, paused: !isPlaying)) { timeline in - let t = timeline.date.timeIntervalSinceReferenceDate - VStack(spacing: 3) { - channel(level: level(t, seed: 0.0), label: "L") - channel(level: level(t, seed: 1.7), label: "R") - } - } - } - - private func level(_ t: Double, seed: Double) -> Double { - guard isPlaying else { return 0.06 } - let a = 0.5 + 0.5 * sin(t * 6.0 + seed) - let b = 0.5 + 0.5 * sin(t * 11.3 + seed * 2.3) - return min(1, 0.22 + 0.62 * (0.6 * a + 0.4 * b)) - } - - private func channel(level: Double, label: String) -> some View { - let lit = Int((level * Double(segments)).rounded()) - return HStack(spacing: 4) { - Text(label) - .font(theme.type.numeric(8).weight(.bold)) - .foregroundStyle(theme.palette.textSecondary) - .frame(width: 8) - HStack(spacing: 1.5) { - ForEach(0.. Color { - guard lit else { return theme.palette.hairline } - let isPeak = index >= segments - 2 - return isPeak ? (theme.palette.vuPeak ?? theme.palette.accent) : theme.palette.accent - } -} - -/// A round machined-metal transport button: radial-gradient face, hairline rim, -/// accent glyph, optional glow while engaged. -struct KnurledButton: View { - @Environment(\.theme) private var theme - let system: String - let size: CGFloat - let glow: Bool - let action: () -> Void - - var body: some View { - // The whole point of a machined button is that it thunks; a silent one - // is a picture of a button. - HapticButton(.machinedPress, action: action) { - ZStack { - Circle() - .fill(RadialGradient( - colors: [Color(hex: 0x2A2622), Color(hex: 0x141210)], - center: .center, startRadius: 1, endRadius: size)) - Circle().strokeBorder(theme.palette.hairline, lineWidth: 1) - Image(systemName: system) - .font(.system(size: size * 0.4, weight: .bold)) - .foregroundStyle(theme.palette.accent) - } - .frame(width: size, height: size) - .shadow(color: glow ? theme.palette.accent.opacity(0.55) : .clear, radius: glow ? 6 : 0) - } - .buttonStyle(.plain) - } -} diff --git a/AppleNugs/Theme/Components/FoilSweep.swift b/AppleNugs/Theme/Components/FoilSweep.swift new file mode 100644 index 0000000..733f087 --- /dev/null +++ b/AppleNugs/Theme/Components/FoilSweep.swift @@ -0,0 +1,135 @@ +import SwiftUI + +// Yeti's one flourish: when a new track starts, a band of foil pink crosses +// the now-playing title once, left to right, over 1,020 ms on the standard +// ease-in-out, then the title rests in snow. Translated from the design +// sheet's `.sweep` rules: +// +// linear-gradient(100deg, snow 0 42%, foil 48%, foil 52%, snow 58% 100%) +// background-size: 220% 100%; background-clip: text; +// animation: 1.02s cubic-bezier(.4, 0, .2, 1), position 100% → 0% +// +// Sites (spec): the now-playing card title and the Mac miniplayer title. +// Nowhere else. + +extension View { + /// Runs Yeti's foil sweep across this title when `FoilSweepTrigger` says a + /// new track has started playing. Apply it directly to the title `Text`, + /// BEFORE any frame or padding, so the band spans the glyphs and not the + /// slack around them. + /// + /// - `identity`: the current track's catalog id — pass + /// `player.current?.trackId` (see `FoilSweepTrigger` for why not the + /// per-entry `id`). + /// - `isPlaying`: `player.isPlaying`. + /// + /// Reads **no playback time**: the inputs are plain values and the band + /// runs on its own keyframe clock, so it adds nothing to the 4 Hz tick and + /// the host must not pass anything derived from `currentTime` either. + /// The band is an overlay masked by the title's own glyphs, so the title + /// never changes size or wraps differently mid-pass. Skipped (the title + /// simply stays snow) under Reduce Motion. + func foilSweep(identity: String?, isPlaying: Bool) -> some View { + modifier(FoilSweepModifier(identity: identity, isPlaying: isPlaying)) + } +} + +struct FoilSweepModifier: ViewModifier { + let identity: String? + let isPlaying: Bool + + @Environment(\.theme) private var theme + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + @State private var trigger = FoilSweepTrigger() + /// Bumped once per sweep; the keyframe animator replays on each change. + @State private var runs = 0 + + /// 1,020 ms on `cubic-bezier(.4, 0, .2, 1)`. + static let duration: TimeInterval = 1.02 + static let curve = UnitCurve.bezier(startControlPoint: UnitPoint(x: 0.4, y: 0), + endControlPoint: UnitPoint(x: 0.2, y: 1)) + + func body(content: Content) -> some View { + // Read here, on the main actor: the animator's content closure is not. + let foil = theme.palette.accent + return content + .overlay { + // At rest the animator sits at 1: the band has left the + // title on the trailing side, so nothing is drawn. + Color.clear + .keyframeAnimator(initialValue: 1.0, trigger: runs) { clear, phase in + clear.overlay { + if phase > 0, phase < 1 { + FoilBand(phase: phase, foil: foil) + } + } + } keyframes: { _ in + KeyframeTrack { + MoveKeyframe(0.0) + LinearKeyframe(1.0, duration: Self.duration, timingCurve: Self.curve) + } + } + .mask { content } + .allowsHitTesting(false) + .accessibilityHidden(true) + } + .onAppear { trigger.appear(identity: identity, isPlaying: isPlaying) } + .onChange(of: identity) { evaluate() } + .onChange(of: isPlaying) { evaluate() } + } + + private func evaluate() { + // The trigger advances even when the band won't run (Reduce Motion), + // so toggling it later can't replay a stale track's sweep. + guard trigger.observe(identity: identity, isPlaying: isPlaying), + !reduceMotion else { return } + runs &+= 1 + } +} + +/// The foil band at one moment of the sweep, drawn across the whole frame it +/// is given (the host masks it to the glyphs). `phase` is the EASED progress, +/// 0...1. Pure function of `phase` so a review still can be rendered at any +/// point without a clock. +/// +/// Proportions follow the CSS: the gradient image is 220% of the title's +/// width, so the band (snow→foil fade 6%, solid foil 4%, foil→snow fade 6% of +/// that image) is 35.2% of the title's width, tilted 10° (the gradient's 100°). +/// One deliberate difference: the CSS band starts and ends partly ON the +/// title (its 100%/0% positions leave ~58% foil on the first/last glyph, which +/// the `.rest` class then pops off). Here the band travels from fully off the +/// leading edge to fully off the trailing edge — the same crossing at the +/// midpoint, with no pop at either end, so the title rests in plain snow. +struct FoilBand: View { + let phase: CGFloat + let foil: Color + + /// Band width as a fraction of the title's width: 16% of a 220% image. + static let widthFraction: CGFloat = 0.16 * 2.2 + /// The gradient's 100° is 10° off a vertical band. + static let tilt = Angle.degrees(10) + + var body: some View { + GeometryReader { geo in + let w = geo.size.width, h = geo.size.height + let band = max(w * Self.widthFraction, 1) + // Travel far enough that the TILTED band is clear of the title at + // both ends: its top and bottom lean half of h·tan(10°) either way. + let lean = h * CGFloat(tan(Self.tilt.radians)) / 2 + let start = -band / 2 - lean + let end = w + band / 2 + lean + let centerX = start + (end - start) * phase + Rectangle() + .fill(LinearGradient( + stops: [.init(color: foil.opacity(0), location: 0), + .init(color: foil, location: 6 / 16), + .init(color: foil, location: 10 / 16), + .init(color: foil.opacity(0), location: 1)], + startPoint: .leading, endPoint: .trailing)) + .frame(width: band, height: h + 2 * band + 2 * lean) + .rotationEffect(Self.tilt) + .position(x: centerX, y: h / 2) + } + } +} diff --git a/AppleNugs/Theme/Components/LabelSticker.swift b/AppleNugs/Theme/Components/LabelSticker.swift new file mode 100644 index 0000000..4a6df5a --- /dev/null +++ b/AppleNugs/Theme/Components/LabelSticker.swift @@ -0,0 +1,57 @@ +import SwiftUI + +// Yeti's two small tags, translated from the surfaces sheet's `.tag` and +// `.tag.plain`: one 19pt-tall shape, two meanings. Offers and states get a +// sticker; facts get a plain tag, never sticker stock. + +/// A label sticker for an offer or state on a poster or row — "Live", "Free". +/// Night ink on sticker stock, tilted −4°, with the tag lift. +struct LabelSticker: View { + @Environment(\.theme) private var theme + let text: String + + init(_ text: String) { self.text = text } + + var body: some View { + let shape = RoundedRectangle(cornerRadius: 5, style: .continuous) + TagText(text: text) + .foregroundStyle(theme.palette.stickerInk) + .background { StickerStock(shape: shape, lift: .tag) } + .rotationEffect(.degrees(-4)) + } +} + +/// A plain tag for a fact — "4K", "Audio", "PPV". Snow type on translucent +/// night, a 1pt frost outline, set square (no tilt, no lift). +struct PlainTag: View { + @Environment(\.theme) private var theme + let text: String + + init(_ text: String) { self.text = text } + + var body: some View { + let shape = RoundedRectangle(cornerRadius: 5, style: .continuous) + TagText(text: text) + .foregroundStyle(theme.palette.textPrimary) + .background { + // rgba(15,20,36,.74): night ink; inset 1px rgba(233,238,247,.3): snow. + shape.fill(theme.palette.stickerInk.opacity(0.74)) + shape.strokeBorder(theme.palette.textPrimary.opacity(0.3), lineWidth: 1) + } + } +} + +/// `.tag`'s box: 19pt tall, 7pt side padding, 10.5pt black rounded, +0.01em. +private struct TagText: View { + let text: String + + var body: some View { + Text(text) + .font(.system(size: 10.5, weight: .black, design: .rounded)) + .tracking(0.105) + .lineLimit(1) + .fixedSize() + .padding(.horizontal, 7) + .frame(height: 19) + } +} diff --git a/AppleNugs/Theme/Components/MonogramTile.swift b/AppleNugs/Theme/Components/MonogramTile.swift index 72f179b..be3dc5d 100644 --- a/AppleNugs/Theme/Components/MonogramTile.swift +++ b/AppleNugs/Theme/Components/MonogramTile.swift @@ -16,12 +16,15 @@ struct MonogramTile: View { } var body: some View { + // Printed on card stock in slush (`.mono` in the surfaces sheet): + // accent letters would be pink type on the bubble, the very fill the + // design keeps for time. RoundedRectangle(cornerRadius: size * 0.18, style: .continuous) - .fill(theme.palette.raised) + .fill(theme.palette.cardStock) .overlay { Text(initials) .font(theme.type.section(size * 0.4)) - .foregroundStyle(theme.palette.accent) + .foregroundStyle(theme.palette.textSecondary) } .overlay { RoundedRectangle(cornerRadius: size * 0.18, style: .continuous) diff --git a/AppleNugs/Theme/Components/PlaySticker.swift b/AppleNugs/Theme/Components/PlaySticker.swift new file mode 100644 index 0000000..3282eb3 --- /dev/null +++ b/AppleNugs/Theme/Components/PlaySticker.swift @@ -0,0 +1,117 @@ +import SwiftUI + +/// Yeti's play/pause control: a disc of sticker stock with the play or pause +/// glyph in night ink, set a few degrees off true. Translated from the design +/// sheet's `.play-sticker` (+ its per-site sizes and the `#i-play`/`#i-pause` +/// symbols, drawn here as shapes from the same 24-unit paths). +/// +/// A view, not a button: hosts wrap it in their own `Button`/`HapticButton` +/// with `.buttonStyle(.plain)`, so haptics, `.disabled` and the action stay +/// theirs. It carries its own "Play"/"Pause" accessibility label; a host that +/// labels the button itself overrides it. +/// +/// While `isBuffering`, the glyph gives way to the spinner the 68pt circle +/// shows today — inside the same disc, so the control never changes size. +struct PlaySticker: View { + /// The design's four sites. + enum Size: CGFloat, CaseIterable, Sendable { + /// iPhone / Duo now playing. + case s64 = 64 + /// Home heroes. + case s54 = 54 + /// iOS expanded panel, Mac miniplayer. + case s46 = 46 + /// Mac transport bar. + case s34 = 34 + + var diameter: CGFloat { rawValue } + + /// The glyph box the design draws at each size (28/24/22/16 of 64/54/46/34). + var glyph: CGFloat { + switch self { + case .s64: return 28 + case .s54: return 24 + case .s46: return 22 + case .s34: return 16 + } + } + } + + /// The `.play-sticker` CSS sets no rotation; the plan asks for "a few + /// degrees off true". The sheet's gentlest sticker tilt (the label tag's). + static let tilt: Angle = .degrees(-4) + + @Environment(\.theme) private var theme + + let isPlaying: Bool + var isBuffering: Bool = false + var size: Size = .s64 + + var body: some View { + let ink = theme.palette.stickerInk + ZStack { + StickerStock(shape: Circle(), lift: .play) + if isBuffering { + // `.tint` colors the spinner on iOS; AppKit's spinner ignores it + // and draws light-gray under the app's forced dark scheme, which + // vanishes on the stock. A light scheme makes it dark there. + ProgressView() + .progressViewStyle(.circular) + .controlSize(size.diameter >= 54 ? .regular : .small) + .tint(ink) + .environment(\.colorScheme, .light) + } else { + Group { + if isPlaying { + PauseGlyph().fill(ink) + } else { + PlayGlyph().fill(ink) + } + } + .frame(width: size.glyph, height: size.glyph) + } + } + .frame(width: size.diameter, height: size.diameter) + .rotationEffect(Self.tilt) + .contentShape(Circle()) + .accessibilityElement(children: .ignore) + .accessibilityLabel(isPlaying ? "Pause" : "Play") + } +} + +/// `#i-play`: M8 5.5v13 a1 1 0 0 0 1.5.86 l10.4-6.5 a1 1 0 0 0 0-1.72 L9.5 4.64 +/// A1 1 0 0 0 8 5.5Z — a triangle with 1-unit rounded corners, optically set +/// right of center, in a 24-unit box. +struct PlayGlyph: Shape { + func path(in rect: CGRect) -> Path { + let u = min(rect.width, rect.height) / 24 + let o = CGPoint(x: rect.midX - 12 * u, y: rect.midY - 12 * u) + func p(_ x: CGFloat, _ y: CGFloat) -> CGPoint { CGPoint(x: o.x + x * u, y: o.y + y * u) } + // The sharp triangle the rounded path is cut from (the path's straight + // edges extended to their intersections). + let top = p(8, 3.7), bottom = p(8, 20.3), tip = p(21.28, 12) + let r = 1.1 * u + var path = Path() + path.move(to: p(8, 12)) + path.addArc(tangent1End: bottom, tangent2End: tip, radius: r) + path.addArc(tangent1End: tip, tangent2End: top, radius: r) + path.addArc(tangent1End: top, tangent2End: bottom, radius: r) + path.closeSubpath() + return path + } +} + +/// `#i-pause`: two 4×14 bars, rx 1.6, at x 6.5 and 13.5 (y 5) in a 24-unit box. +struct PauseGlyph: Shape { + func path(in rect: CGRect) -> Path { + let u = min(rect.width, rect.height) / 24 + let o = CGPoint(x: rect.midX - 12 * u, y: rect.midY - 12 * u) + var path = Path() + for x in [6.5, 13.5] as [CGFloat] { + path.addRoundedRect( + in: CGRect(x: o.x + x * u, y: o.y + 5 * u, width: 4 * u, height: 14 * u), + cornerSize: CGSize(width: 1.6 * u, height: 1.6 * u)) + } + return path + } +} diff --git a/AppleNugs/Theme/Components/PriceSticker+Player.swift b/AppleNugs/Theme/Components/PriceSticker+Player.swift new file mode 100644 index 0000000..4ef95a3 --- /dev/null +++ b/AppleNugs/Theme/Components/PriceSticker+Player.swift @@ -0,0 +1,33 @@ +import SwiftUI + +// The player-reading half of the price sticker, kept apart from the view so an +// offscreen render can compile `PriceSticker` without `PlayerService`. + +extension PriceSticker { + /// The sticker for whatever `player` has loaded, with the presence rule + /// applied (hidden only when nothing is current, or the pick is known lossy). + @MainActor + init(player: PlayerService, size: Size = .s62) { + self.init(label: PriceStickerLabel(player: player), size: size) + } +} + +extension PriceStickerLabel { + /// Reads the player the one correct way. `nil` (hidden) when nothing is + /// current. A current track with no pick yet — `startCurrent()` nils + /// `nowPick` and `specs` on every track change — reads "Lossless" rather + /// than blinking off. Depth falls back to the format's implied depth until + /// the decoder reports one; the rate has no such fallback, so the spec line + /// waits for `specs`. + @MainActor + init?(player: PlayerService) { + guard player.current != nil else { return nil } + let pick = player.nowPick + // A decoder reports 0 bits for some streams; treat it like nil here. + let reportedDepth = player.specs?.bitDepth.flatMap { $0 > 0 ? $0 : nil } + self.init(pickKnown: pick != nil, + isLossless: pick?.format.isLossless ?? false, + bitDepth: reportedDepth ?? pick?.format.impliedBitDepth, + sampleRate: player.specs?.sampleRate) + } +} diff --git a/AppleNugs/Theme/Components/PriceSticker.swift b/AppleNugs/Theme/Components/PriceSticker.swift new file mode 100644 index 0000000..60adba1 --- /dev/null +++ b/AppleNugs/Theme/Components/PriceSticker.swift @@ -0,0 +1,80 @@ +import SwiftUI + +/// Yeti's lossless badge: "Lossless" over "24/96" in night ink on a disc of +/// sticker stock, tilted like a price sticker slapped on the box. Replaces the +/// old format badge. Translated from the design sheet's +/// `.sticker` (62pt, −11°) and `.sticker.tiny` (smaller, −9°). +/// +/// What it says comes from the pure `PriceStickerLabel`; a `nil` label means +/// hidden. **Hidden or shown, it holds one fixed frame per size**, so a row it +/// sits in never reflows when a known lossy pick hides it, or when the spec line +/// appears. Its presence belongs to the host, gated on `player.current` — never +/// on `nowPick`/`specs` (see `PriceStickerLabel`); `init(player:size:)` does it +/// right. +struct PriceSticker: View { + /// The design's five diameters. + enum Size: CGFloat, CaseIterable, Sendable { + case s62 = 62, s44 = 44, s40 = 40, s36 = 36, s32 = 32 + + var diameter: CGFloat { rawValue } + + /// (title, spec) point sizes and the gap between them, from the sheet: + /// 62 → 10/15 (gap 2); the tiny stickers 7.5/11 (gap 1), shrunk where + /// "Lossless" would otherwise crowd the disc's edge. + fileprivate var type: (title: CGFloat, spec: CGFloat, gap: CGFloat) { + switch self { + case .s62: return (10, 15, 2) + case .s44, .s40: return (7.5, 11, 1) + case .s36: return (6.5, 10, 1) + case .s32: return (6, 9, 0.5) + } + } + + fileprivate var tilt: Angle { self == .s62 ? .degrees(-11) : .degrees(-9) } + } + + @Environment(\.theme) private var theme + + let label: PriceStickerLabel? + var size: Size = .s62 + + var body: some View { + ZStack { + if let label { + sticker(label) + } else { + Color.clear.accessibilityHidden(true) + } + } + .frame(width: size.diameter, height: size.diameter) + } + + private func sticker(_ label: PriceStickerLabel) -> some View { + let ink = theme.palette.stickerInk + let type = size.type + return ZStack { + StickerStock(shape: Circle(), lift: .price) + // `inset 0 0 0 2px rgba(15,20,36,.07)`: the faint printed rim. + Circle().strokeBorder(ink.opacity(0.07), lineWidth: 2) + VStack(spacing: type.gap) { + Text(label.title) + .font(.system(size: type.title, weight: .heavy, design: .rounded)) + if let spec = label.spec { + // The sizes are set for five characters ("24/96"); a longer + // line ("24/192") shrinks in proportion so it stays on the + // disc. The disc never grows and the text never truncates. + Text(spec) + .font(.system(size: type.spec * min(1, 5 / CGFloat(max(spec.count, 1))), + weight: .black, design: .rounded)) + .monospacedDigit() + } + } + .foregroundStyle(ink) + .lineLimit(1) + .fixedSize() + } + .rotationEffect(size.tilt) + .accessibilityElement(children: .ignore) + .accessibilityLabel(label.accessibilityLabel) + } +} diff --git a/AppleNugs/Theme/Components/RowSticker.swift b/AppleNugs/Theme/Components/RowSticker.swift new file mode 100644 index 0000000..a980309 --- /dev/null +++ b/AppleNugs/Theme/Components/RowSticker.swift @@ -0,0 +1,56 @@ +import SwiftUI + +/// Yeti's play-state disc for a row: four night-ink bars stepping down +/// 100/75/50/25 on sticker stock while playing; the same disc with the bars +/// lying flat on idle-gray stock (`palette.textIdle`) while paused. Marks the +/// queue's playing slot and the setlist's playing row. Translated from the +/// design sheet's `.rs` / `.rs .stair` / `.rs.paused` / `.rs.sm`. +/// +/// **One constant frame in both states** — a pause must never resize a row. +/// Static: no `TimelineView`, no animation, so it costs nothing per frame. +struct RowSticker: View { + enum Size: CGFloat, CaseIterable, Sendable { + /// Setlist rows (the number column of the playing row). + case s20 = 20 + /// The queue's playing slot. + case s16 = 16 + + var diameter: CGFloat { rawValue } + } + + /// The bar heights while playing, as fractions of the stair's height. + static let stair: [CGFloat] = [1, 0.75, 0.5, 0.25] + /// Paused, every bar lies at the stair's lowest step. + static let flat: CGFloat = 0.25 + static let tilt: Angle = .degrees(-9) + + @Environment(\.theme) private var theme + + let isPlaying: Bool + var size: Size = .s20 + + var body: some View { + let d = size.diameter + let stairHeight = d * 0.46 + ZStack { + if isPlaying { + StickerStock(shape: Circle()) + } else { + Circle().fill(theme.palette.textIdle) + } + HStack(alignment: .bottom, spacing: d * 0.07) { + ForEach(Self.stair.indices, id: \.self) { i in + RoundedRectangle(cornerRadius: 1) + .fill(theme.palette.stickerInk) + .frame(width: d * 0.1, + height: stairHeight * (isPlaying ? Self.stair[i] : Self.flat)) + } + } + .frame(height: stairHeight, alignment: .bottom) + } + .frame(width: d, height: d) + .rotationEffect(Self.tilt) + .accessibilityElement(children: .ignore) + .accessibilityLabel(isPlaying ? "Playing" : "Paused") + } +} diff --git a/AppleNugs/Theme/Components/ShowCard.swift b/AppleNugs/Theme/Components/ShowCard.swift index bff95dc..3439351 100644 --- a/AppleNugs/Theme/Components/ShowCard.swift +++ b/AppleNugs/Theme/Components/ShowCard.swift @@ -11,13 +11,15 @@ struct ShowCard: View { VStack(alignment: .leading, spacing: 6) { CoverArt(url: show.imageURL.flatMap { URL(string: $0) }) .frame(width: width) + // The poster rule cut square — title heavy + // 15, artist slush (the flat cover + keyline is `CoverArt`'s). Text(show.title) - .font(theme.type.body(12)) + .font(theme.type.title(15)) .foregroundStyle(theme.palette.textPrimary) .lineLimit(2) .multilineTextAlignment(.leading) Text(show.artistName) - .font(theme.type.numeric(10)) + .font(theme.type.body(13)) .foregroundStyle(theme.palette.textSecondary) .lineLimit(1) } diff --git a/AppleNugs/Theme/Components/StickerStock.swift b/AppleNugs/Theme/Components/StickerStock.swift new file mode 100644 index 0000000..148d4b5 --- /dev/null +++ b/AppleNugs/Theme/Components/StickerStock.swift @@ -0,0 +1,100 @@ +import SwiftUI + +// Yeti's sticker stock: the sale-yellow paper every sticker is printed on +// (price, play, row, label), with night ink (`palette.stickerInk`) on top. Pink +// is never printed on it. Translated from the design sheet's `.stock` rule: +// +// radial-gradient(circle at 34% 30%, #FBF1A6, #F2E27A 55%, #E3D267) +// +// The three stops are `Palette`'s `stickerHighlight` / `stickerSale` / +// `stickerEdge`; the gradient is built statically (no environment), so it +// reads them from `Theme.yeti`. + +/// The sale-yellow radial fill in `shape`, under an optional drop-shadow lift. +/// The gradient is laid out from the view's own size, so one stock serves a +/// 16pt row disc and a 19pt-tall label tag alike. +struct StickerStock: View { + let shape: S + var lift: StickerLift = .none + + static var highlight: Color { Theme.yeti.palette.stickerHighlight } + static var sale: Color { Theme.yeti.palette.stickerSale } + static var edge: Color { Theme.yeti.palette.stickerEdge } + + var body: some View { + GeometryReader { geo in + shape.fill(Self.gradient(in: geo.size)) + } + .stickerLift(lift, in: shape) + } + + /// CSS `circle at 34% 30%` with no size keyword is `farthest-corner`: the + /// gradient reaches 100% at the bounding box's farthest corner, which from + /// (34%, 30%) is the bottom-trailing one. + static func gradient(in size: CGSize) -> RadialGradient { + let dx = size.width * 0.66, dy = size.height * 0.70 + return RadialGradient( + stops: [.init(color: highlight, location: 0), + .init(color: sale, location: 0.55), + .init(color: edge, location: 1)], + center: UnitPoint(x: 0.34, y: 0.30), + startRadius: 0, + endRadius: (dx * dx + dy * dy).squareRoot()) + } +} + +/// A sticker's drop shadow: the design's two `box-shadow` layers, a hard 0-blur +/// lip plus a soft fall-off. Drawn as separate blurred fills of the sticker's +/// shape BEHIND it — stacking two `.shadow` modifiers would shadow the first +/// shadow's silhouette as well, and `.shadow` has no spread. +struct StickerLift: Sendable, Equatable { + struct Layer: Sendable, Equatable { + /// Black at this opacity. + let opacity: Double + /// CSS y offset. + let y: CGFloat + /// CSS blur length; a Gaussian of sigma `blur / 2`. + let blur: CGFloat + /// CSS spread: negative insets the shape before blurring. + let spread: CGFloat + } + + let layers: [Layer] + + static let none = StickerLift(layers: []) + /// `.sticker`: 0 2px 0 rgba(0,0,0,.28), 0 6px 10px -4px rgba(0,0,0,.5) + static let price = StickerLift(layers: [ + Layer(opacity: 0.28, y: 2, blur: 0, spread: 0), + Layer(opacity: 0.5, y: 6, blur: 10, spread: -4), + ]) + /// `.play-sticker`: 0 2px 0 rgba(0,0,0,.28), 0 8px 14px -6px rgba(0,0,0,.6) + static let play = StickerLift(layers: [ + Layer(opacity: 0.28, y: 2, blur: 0, spread: 0), + Layer(opacity: 0.6, y: 8, blur: 14, spread: -6), + ]) + /// `.tag`: 0 1px 0 rgba(0,0,0,.3), 0 3px 6px -3px rgba(0,0,0,.5) + static let tag = StickerLift(layers: [ + Layer(opacity: 0.3, y: 1, blur: 0, spread: 0), + Layer(opacity: 0.5, y: 3, blur: 6, spread: -3), + ]) +} + +extension View { + /// Draws `lift`'s shadow layers as `shape` fills behind this view. Paints + /// outside the frame only; the layout footprint is unchanged. + func stickerLift(_ lift: StickerLift, in shape: S) -> some View { + background { + ZStack { + ForEach(Array(lift.layers.enumerated()), id: \.offset) { _, layer in + shape + .inset(by: -layer.spread) + .fill(Color.black.opacity(layer.opacity)) + .blur(radius: layer.blur / 2) + .offset(y: layer.y) + } + } + .allowsHitTesting(false) + .accessibilityHidden(true) + } + } +} diff --git a/AppleNugs/Theme/Components/VideoThumbnail.swift b/AppleNugs/Theme/Components/VideoThumbnail.swift index b459e80..bb0035f 100644 --- a/AppleNugs/Theme/Components/VideoThumbnail.swift +++ b/AppleNugs/Theme/Components/VideoThumbnail.swift @@ -5,6 +5,12 @@ import SwiftUI /// line. Used by the Videos grid, the Live & Upcoming row, and (later) the /// per-artist Videos section. Clones ShowCard's text block but draws a 16:9 /// poster instead of the square CoverArt used for audio shows. +/// +/// The surfaces sheet's `.vt`: no centered play glyph (the poster is the +/// button); the badges are a `LabelSticker` for offers (Live, Free) and a +/// `PlainTag` for facts (PPV, Audio, 4K); the poster sits flat with a 1pt +/// frost keyline, the placeholder glyph is slush, and the text block takes +/// ShowCard's heavy title over a slush artist line. struct VideoThumbnail: View { @Environment(\.theme) private var theme let video: VideoSummary @@ -27,13 +33,13 @@ struct VideoThumbnail: View { .frame(width: width) posterAccessory() Text(video.title) - .font(theme.type.body(12)) + .font(theme.type.title(15)) .foregroundStyle(theme.palette.textPrimary) .lineLimit(2) .multilineTextAlignment(.leading) if let artist = video.artistName { Text(artist) - .font(theme.type.numeric(10)) + .font(theme.type.body(13)) .foregroundStyle(theme.palette.textSecondary) .lineLimit(1) } @@ -44,12 +50,14 @@ struct VideoThumbnail: View { private var poster: some View { ZStack { posterImage - playOverlay badges } .aspectRatio(16.0 / 9.0, contentMode: .fit) .frame(maxWidth: .infinity) .clipShape(RoundedRectangle(cornerRadius: 6)) + .overlay { + RoundedRectangle(cornerRadius: 6).strokeBorder(theme.palette.hairline, lineWidth: 1) + } } @ViewBuilder @@ -74,43 +82,24 @@ struct VideoThumbnail: View { Rectangle() .fill(theme.palette.raised) .overlay(Image(systemName: "play.rectangle") - .foregroundStyle(theme.palette.accent.opacity(0.7))) - } - - private var playOverlay: some View { - Image(systemName: "play.circle.fill") - .font(.system(size: 34)) - .symbolRenderingMode(.hierarchical) - .foregroundStyle(theme.palette.accent) - .shadow(color: .black.opacity(0.45), radius: 6, y: 2) + .foregroundStyle(theme.palette.textSecondary)) } + /// `.vt .badges`: offers on the left, facts on the right, 8pt in, 5pt + /// apart (the inset also clears the sticker's tilt and lift). private var badges: some View { VStack { - HStack(alignment: .top, spacing: 4) { - if video.isLive { badge("LIVE", filled: true) } - if video.access == .free { badge("FREE", filled: true) } - if video.access == .ppv { badge("PPV", filled: false) } + HStack(alignment: .top, spacing: 5) { + if video.isLive { LabelSticker("Live") } + if video.access == .free { LabelSticker("Free") } + if video.access == .ppv { PlainTag("PPV") } Spacer(minLength: 0) - if video.isAudio { badge("AUDIO", filled: false) } - if video.has4K { badge("4K", filled: false) } + if video.isAudio { PlainTag("Audio") } + if video.has4K { PlainTag("4K") } } Spacer(minLength: 0) } - .padding(6) - } - - private func badge(_ text: String, filled: Bool) -> some View { - Text(text) - .font(theme.type.numeric(9).weight(.semibold)) - .tracking(0.5) - .foregroundStyle(filled ? Color.white : theme.palette.textPrimary) - .padding(.horizontal, 6) - .padding(.vertical, 2) - .background { - RoundedRectangle(cornerRadius: 4, style: .continuous) - .fill(filled ? theme.palette.accent : theme.palette.raised.opacity(0.9)) - } + .padding(8) } } diff --git a/AppleNugs/Theme/Components/YetiButtonStyles.swift b/AppleNugs/Theme/Components/YetiButtonStyles.swift new file mode 100644 index 0000000..bf282f9 --- /dev/null +++ b/AppleNugs/Theme/Components/YetiButtonStyles.swift @@ -0,0 +1,227 @@ +import SwiftUI + +// Yeti's two button shapes. Primaries are snow with night ink and secondaries +// are frost-outlined with a snow label, never an accent fill: the root tint is +// snow (`ThemedRoot`), and a snow-tinted +// `.borderedProminent` would still draw a white label on the snow capsule. +// +// Colors come from the theme's tokens: snow = `textPrimary`, night ink = +// `base`, frost = `hairline`, shelf = `raised`. Size follows the environment's +// `controlSize` (`.small` 30pt, regular 34pt, `.large`/`.extraLarge` 50pt), +// the three button heights the design pages draw. A full-width button puts +// `.frame(maxWidth: .infinity)` on its label. + +/// Snow capsule, night-ink heavy rounded label. +struct YetiPrimaryButtonStyle: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + YetiButtonBody(configuration: configuration, kind: .primary) + } +} + +/// 1pt frost outline, snow heavy rounded label. +struct YetiSecondaryButtonStyle: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + YetiButtonBody(configuration: configuration, kind: .secondary) + } +} + +extension ButtonStyle where Self == YetiPrimaryButtonStyle { + static var yetiPrimary: YetiPrimaryButtonStyle { YetiPrimaryButtonStyle() } +} + +extension ButtonStyle where Self == YetiSecondaryButtonStyle { + static var yetiSecondary: YetiSecondaryButtonStyle { YetiSecondaryButtonStyle() } +} + +/// The shared body: a view (not the style itself) so it can read the theme, +/// enabled state and control size from the environment. +private struct YetiButtonBody: View { + enum Kind { case primary, secondary } + + @Environment(\.theme) private var theme + @Environment(\.isEnabled) private var isEnabled + @Environment(\.controlSize) private var controlSize + + let configuration: ButtonStyleConfiguration + let kind: Kind + + private struct Metrics { + let height, padding, fontSize: CGFloat + } + + private var metrics: Metrics { + switch controlSize { + case .mini, .small: return Metrics(height: 30, padding: 12, fontSize: 13) + case .large, .extraLarge: return Metrics(height: 50, padding: 22, fontSize: 17) + default: return Metrics(height: 34, padding: 16, fontSize: 14) + } + } + + var body: some View { + let m = metrics + configuration.label + .font(.system(size: m.fontSize, weight: .heavy, design: .rounded)) + .lineLimit(1) + .foregroundStyle(kind == .primary ? theme.palette.base : theme.palette.textPrimary) + .padding(.horizontal, m.padding) + .frame(minHeight: m.height) + .background { + switch kind { + case .primary: + Capsule().fill(theme.palette.textPrimary + .opacity(configuration.isPressed ? 0.78 : 1)) + case .secondary: + Capsule().fill(theme.palette.raised + .opacity(configuration.isPressed ? 1 : 0)) + } + } + .overlay { + if kind == .secondary { + Capsule().strokeBorder(theme.palette.hairline, lineWidth: 1) + } + } + .contentShape(Capsule()) + .foilFocusOutline(Capsule()) + .opacity(isEnabled ? 1 : 0.5) + .animation(.easeOut(duration: 0.12), value: configuration.isPressed) + } +} + +// MARK: - Icon buttons + +/// Yeti's round icon button (`.ib` in the surfaces sheet): a 36pt disc with a +/// 1pt frost ring around a snow glyph, the show page's Play next / Queue / +/// Save on iPhone. `ringed: false` is `.ib.bare`, the glyph alone: the show +/// page's Downloaded state, a filled snow disc at `glyphSize` 26. Pair it with +/// `.labelStyle(.iconOnly)` so a `Label` keeps its title for accessibility. +struct YetiIconButtonStyle: ButtonStyle { + var ringed = true + var glyphSize: CGFloat = 16 + + func makeBody(configuration: Configuration) -> some View { + YetiIconButtonBody(configuration: configuration, ringed: ringed, glyphSize: glyphSize) + } +} + +extension ButtonStyle where Self == YetiIconButtonStyle { + static var yetiIcon: YetiIconButtonStyle { YetiIconButtonStyle() } +} + +private struct YetiIconButtonBody: View { + @Environment(\.theme) private var theme + @Environment(\.isEnabled) private var isEnabled + + let configuration: ButtonStyleConfiguration + let ringed: Bool + let glyphSize: CGFloat + + static let side: CGFloat = 36 + + var body: some View { + configuration.label + .font(.system(size: glyphSize, weight: .semibold)) + .foregroundStyle(theme.palette.textPrimary) + .frame(width: Self.side, height: Self.side) + .background { + Circle().fill(theme.palette.raised.opacity(configuration.isPressed ? 1 : 0)) + } + .overlay { + if ringed { + Circle().strokeBorder(theme.palette.hairline, lineWidth: 1) + } + } + .contentShape(Circle()) + .foilFocusOutline(Circle()) + .opacity(isEnabled ? 1 : 0.5) + .animation(.easeOut(duration: 0.12), value: configuration.isPressed) + } +} + +// MARK: - Page actions (show + video pages) + +extension View { + /// A secondary action on the show and video pages: a 36pt frost ring with + /// the glyph alone on iPhone (`.actrow .ib`; the label stays for + /// accessibility), a frost-outlined labeled button on the Mac. Never the + /// accent — saving, queueing and downloading aren't time moving. + @ViewBuilder + func yetiPageAction() -> some View { + #if os(iOS) + labelStyle(.iconOnly).buttonStyle(.yetiIcon) + #else + buttonStyle(.yetiSecondary) + #endif + } + + /// The show page's Downloaded state: a filled SNOW disc, the glyph bare + /// (`.ib` with no ring) on iPhone, a frost-outlined button on the Mac; + /// never an accent fill. + @ViewBuilder + func yetiDownloadedAction() -> some View { + #if os(iOS) + labelStyle(.iconOnly) + .buttonStyle(YetiIconButtonStyle(ringed: false, glyphSize: 26)) + #else + buttonStyle(.yetiSecondary) + #endif + } +} + +// MARK: - Keyboard focus (macOS) + +extension View { + /// The design sheet's `:focus-visible`: on macOS, while the enclosing + /// focusable control (the button this sits inside) has keyboard focus, a + /// 2pt foil (`palette.accent`) outline 3pt outside `shape`, in place of the + /// system focus ring. Apply it inside a button's style or label, where + /// `\.isFocused` reflects that button. An overlay only — it never changes + /// the control's size. iOS: no change. + /// + /// Our own controls only; system controls (text fields, lists, menus, + /// sliders) keep the system ring. + func foilFocusOutline(_ shape: S) -> some View { + modifier(FoilFocusOutline(shape: shape)) + } + + /// `.focusEffectDisabled()` on macOS only, for a `.plain` button whose + /// label draws `foilFocusOutline` — so the foil outline replaces the + /// system ring instead of joining it. iOS keeps its focus effects. + @ViewBuilder + func macFocusEffectDisabled() -> some View { + #if os(macOS) + focusEffectDisabled() + #else + self + #endif + } +} + +private struct FoilFocusOutline: ViewModifier { + let shape: S + + #if os(macOS) + @Environment(\.theme) private var theme + @Environment(\.isFocused) private var isFocused + + /// CSS `outline: 2px; outline-offset: 3px` — the stroke spans 3...5pt + /// outside the control's edge. + static var width: CGFloat { 2 } + static var offset: CGFloat { 3 } + + func body(content: Content) -> some View { + content + .focusEffectDisabled() + .overlay { + if isFocused { + shape + .inset(by: -(Self.offset + Self.width)) + .strokeBorder(theme.palette.accent, lineWidth: Self.width) + .allowsHitTesting(false) + .accessibilityHidden(true) + } + } + } + #else + func body(content: Content) -> some View { content } + #endif +} diff --git a/AppleNugs/Theme/Components/YetiSegmentedControl.swift b/AppleNugs/Theme/Components/YetiSegmentedControl.swift new file mode 100644 index 0000000..d9587af --- /dev/null +++ b/AppleNugs/Theme/Components/YetiSegmentedControl.swift @@ -0,0 +1,94 @@ +import SwiftUI + +/// Yeti's segmented control (`.seg` in the surfaces sheet): a 34pt shelf +/// track, radius 10, 3pt inset, and one lighter thumb under the selected +/// segment that slides between them. Labels are heavy rounded 13.5 — snow on +/// the thumb, slush off it. Hosts use it in place of a system +/// `Picker(.segmented)`. +/// +/// The font is the same selected or not (only the color changes), so a +/// segment's label never changes width as the thumb moves. The thumb's +/// animation is scoped to the thumb itself: whatever the host swaps in below +/// on a selection change is not animated by this control. +/// +/// Accessibility: the group carries `label` (the label the system picker had) +/// and each segment is a button with `.isSelected` on the chosen one, the +/// shape VoiceOver reads for a system segmented control. +struct YetiSegmentedControl: View { + struct Option { + let value: Value + let title: String + } + + let label: String + @Binding var selection: Value + let options: [Option] + + init(_ label: String, selection: Binding, options: [Option]) { + self.label = label + self._selection = selection + self.options = options + } + + @Environment(\.theme) private var theme + @Environment(\.accessibilityReduceMotion) private var reduceMotion + + private static var height: CGFloat { 34 } + private static var inset: CGFloat { 3 } + + private var selectedIndex: Int { + options.firstIndex { $0.value == selection } ?? 0 + } + + var body: some View { + HStack(spacing: 0) { + ForEach(Array(options.enumerated()), id: \.offset) { _, option in + segment(option) + } + } + .background(alignment: .leading) { thumb } + .padding(Self.inset) + .frame(height: Self.height) + .background(theme.palette.raised, in: RoundedRectangle(cornerRadius: 10, style: .continuous)) + .accessibilityElement(children: .contain) + .accessibilityLabel(label) + } + + private func segment(_ option: Option) -> some View { + let on = option.value == selection + return Button { + selection = option.value + } label: { + Text(option.title) + .font(theme.type.title(13.5)) + .foregroundStyle(on ? theme.palette.textPrimary : theme.palette.textSecondary) + .lineLimit(1) + .minimumScaleFactor(0.8) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .contentShape(Rectangle()) + // The thumb's shape: the segment's own box, which the thumb fills. + .foilFocusOutline(RoundedRectangle(cornerRadius: 8, style: .continuous)) + } + .buttonStyle(.plain) + // The label draws the foil focus outline (macOS); no system ring too. + .macFocusEffectDisabled() + .accessibilityAddTraits(on ? .isSelected : []) + .accessibilityRemoveTraits(on ? [] : .isSelected) + } + + /// One thumb, offset to the selected segment, so it slides rather than + /// cross-fading between segments. + private var thumb: some View { + GeometryReader { geo in + let count = CGFloat(max(options.count, 1)) + let width = geo.size.width / count + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(theme.palette.segmentThumb) + .shadow(color: .black.opacity(0.45), radius: 1, y: 1) + .frame(width: width, height: geo.size.height) + .offset(x: width * CGFloat(selectedIndex)) + .animation(reduceMotion ? nil : .spring(duration: 0.3), value: selectedIndex) + } + .accessibilityHidden(true) + } +} diff --git a/AppleNugs/Theme/Theme.swift b/AppleNugs/Theme/Theme.swift index e4637b9..52f3577 100644 --- a/AppleNugs/Theme/Theme.swift +++ b/AppleNugs/Theme/Theme.swift @@ -1,52 +1,45 @@ import SwiftUI -// MARK: - Variation axes - -/// Which bespoke now-playing treatment a theme uses in the transport bar. -enum TransportSignature: Sendable { - case standard // the original text block, token-styled - case tapeLabel // Tape Room: art chip + amber under-rule progress - case jCard // Shoebox: cassette J-card strip - case faceplate // The Receiver: full brushed-metal VU faceplate (deferred) - case clickWheel // Click Wheel: monochrome circular-pad player (iOS full screen) -} - -/// Whether the accent is a fixed color or derived from the current cover art. -enum AccentMode: Sendable { - case staticAccent - case artDriven(fallback: Color) -} - -/// How (if at all) the extracted album-art color washes into the chrome. -enum WashStyle: Sendable { - case none - case linear // Soundboard: gradient behind transport + now-playing - case warmLow // Shoebox: faint warm wash - case bloom18 // The Receiver: 18% bloom behind the inspector art only -} - -/// Opt-in bespoke behaviors, kept off the host views so nothing forks. -struct Capabilities: OptionSet, Sendable { - let rawValue: Int - static let artWash = Capabilities(rawValue: 1 << 0) - static let equalizerRows = Capabilities(rawValue: 1 << 1) - static let vuMeter = Capabilities(rawValue: 1 << 2) - static let condensedHeaders = Capabilities(rawValue: 1 << 3) - static let indexLetterTab = Capabilities(rawValue: 1 << 4) -} +// The design system: ONE token struct, `Theme.yeti`, read from the environment +// as `@Environment(\.theme)` (its default value — nothing has to inject it). +// Views ask for roles (`theme.palette.textSecondary`, `theme.type.title(17)`, +// `theme.copy.homeLede`), never for literal colors or strings. This file is +// the only place literal hex lives. The design pages behind every value are +// `docs/design/yeti-theme/` (the token sheet is the first panel of +// `yeti-theme.html`). // MARK: - Token groups -/// Semantic color roles. Views ask for `textSecondary`, never "taupe". +/// Semantic color roles. Views ask for `textSecondary`, never "slush". struct Palette: Sendable { - let base, raised, hairline: Color + /// Night: the window background. + let base: Color + /// Shelves: rows, cards, sheets. + let raised: Color + /// Frost: 1pt keylines and dividers. + let hairline: Color + /// Snow, slush and the idle gray. let textPrimary, textSecondary, textIdle: Color - let accent: Color // static fallback; art may override at runtime - let losslessBadge: Color? // distinct lossless tint (Tape Room teal), else nil - let playState: Color // active/playing emphasis - let vuPeak: Color? // VU peak tick (The Receiver only) - let labelPaper: Color? // write-on label stock (Tape Room only) - let labelInk: Color? // pen on that stock (Tape Room only) + /// Foil: kept for things that move with time (scrubs, the pill ring, the + /// foil sweep, the caret, download/resume progress). Never a control fill. + let accent: Color + /// Active/playing emphasis: the sale yellow. + let playState: Color + /// The backing card stock under the blister pack, monograms and posters. + let cardStock: Color + /// Night ink printed on sticker stock. + let stickerInk: Color + /// Sticker stock's radial gradient stops (`StickerStock`): highlight → + /// sale yellow at 55% → edge. + let stickerHighlight, stickerSale, stickerEdge: Color + /// `YetiSegmentedControl`'s selected segment: a lighter shelf than `raised`. + let segmentThumb: Color + /// The near-black behind a video error drawn over the player. + let videoErrorBackdrop: Color + /// The blister bubble's plastic (`BlisterGlass`): its glass tint, the shade + /// pooling in it, and the top of the glaze over the art. Light on the + /// plastic is `textPrimary`; the glaze's bottom is `base`. + let bubbleGlass, bubbleShade, bubbleGlaze: Color } /// Fonts as size-taking closures so a point size is a call, not six stored fonts. @@ -55,199 +48,76 @@ struct Typography: Sendable { let body, numeric: @Sendable (CGFloat) -> Font } -/// Per-theme copy so idle/empty states and section headers carry personality. -struct IdleCopy: Sendable { - let nowPlaying: String - let dashboardIdle: String - let dashHeaders: (now: String, quality: String, upNext: String) +/// The app's voice: idle/empty states and section headers. +struct Copy: Sendable { + /// The now-playing surfaces' idle line. + let nowPlaying = "It's on its way" + /// The dashboard's idle line. + let dashboardIdle = "It's on its way" + /// `DashboardPanel`'s three section headers. + let dashNow = "Here it comes" + let dashQuality = "Says so on the box" + let dashUpNext = "Coming up next" + /// Downloads' empty-state title (`DownloadsView`). + let downloadsEmptyTitle = "Nothing boxed up yet" + /// Videos' three section titles (`VideosView`). + let videosResume = "Pick up where you left off" + let videosLive = "Live and coming up" + let videosExclusives = "Just streamed" + /// The video detail's live-edge button label while it is disabled + /// (`VideoDetailView`); enabled, it reads "Go Live". + let liveEdgeDisabled = "At the live edge" + /// Home's line under the greeting (`HomeView`). + let homeLede = "Pick up where you left off, or dig through the crate." + /// The listening ledger's section header (`ListeningLedger`). + let ledgerTitle = "The nice list" + + /// Search's no-results line for a query (`SearchView`, `ArtistListView`). + func searchNoResults(_ query: String) -> String { + "Nothing on the shelf for \u{201C}\(query)\u{201D}" + } } // MARK: - Theme -/// A complete look, assembled from tokens. Value type read from the environment, -/// so switching republishes one value and re-renders only the affected subtrees. -struct Theme: Equatable, Sendable { - let id: ThemeID +/// The complete look, assembled from tokens. A value type read from the +/// environment (`\.theme`, whose default is `Theme.yeti`). +struct Theme: Sendable { let palette: Palette let type: Typography - let textureOpacity: Double - let copy: IdleCopy - let caps: Capabilities - let transport: TransportSignature - let accentMode: AccentMode - let washStyle: WashStyle - let darkOnly: Bool - - // Closure-typed Typography isn't Equatable; identity is the key that matters. - static func == (a: Theme, b: Theme) -> Bool { a.id == b.id } - - /// The accent to actually use: the live art color when the theme is - /// art-driven and a color is available, else the static accent. - func effectiveAccent(art: Color?) -> Color { - switch accentMode { - case .staticAccent: return palette.accent - case .artDriven(let fb): return art ?? fb - } - } - - /// Color for the currently-playing emphasis (active row, now-playing glyph). - /// Art-driven themes follow the cover color so it matches the rest of the - /// chrome; static themes keep their deliberate play-state color (Tape Room - /// amber, Shoebox rust, The Receiver tube-teal). - func activeEmphasis(art: Color?) -> Color { - if case .artDriven = accentMode { return effectiveAccent(art: art) } - return palette.playState - } - - var consumesArtColor: Bool { - if case .artDriven = accentMode { return true } - return washStyle != .none - } - - static func make(_ id: ThemeID) -> Theme { - switch id { - case .tapeRoom: return tapeRoom - case .soundboard: return soundboard - case .shoebox: return shoebox - case .tapeDeck: return tapeDeck - case .clickWheel: return clickWheel - } - } -} - -// MARK: - The four themes (the only place literal hex lives) - -extension Theme { - static let tapeRoom = Theme( - id: .tapeRoom, - palette: Palette( - base: Color(hex: 0x14110E), raised: Color(hex: 0x1B1714), hairline: Color(hex: 0x2E2823), - textPrimary: Color(hex: 0xF2EADF), textSecondary: Color(hex: 0xA89B8B), textIdle: Color(hex: 0x6E6357), - accent: Color(hex: 0xE8A13A), losslessBadge: Color(hex: 0x5FB6A6), - playState: Color(hex: 0xE8A13A), vuPeak: nil, - labelPaper: Color(hex: 0xFBF9F3), labelInk: Color(hex: 0x241C12)), - type: Typography( - hero: { .system(size: $0, weight: .semibold, design: .serif) }, - title: { .system(size: $0, weight: .medium, design: .serif) }, - section: { .system(size: $0, weight: .semibold, design: .serif) }, - body: { .system(size: $0) }, - numeric: { .system(size: $0, design: .monospaced) }), - textureOpacity: 0, - copy: IdleCopy( - nowPlaying: "Nothing playing. Press / to search.", - dashboardIdle: "Idle", - dashHeaders: ("Now Playing", "Quality", "Up Next")), - caps: [], - transport: .tapeLabel, - accentMode: .staticAccent, - washStyle: .none, - darkOnly: false) - - static let soundboard = Theme( - id: .soundboard, - palette: Palette( - base: Color(hex: 0x0C0B0E), raised: Color(hex: 0x17161A), hairline: Color(hex: 0x26262A), - textPrimary: Color(hex: 0xF4F1EC), textSecondary: Color(hex: 0x9A958E), textIdle: Color(hex: 0x6E6A63), - accent: Color(hex: 0xE0902E), losslessBadge: nil, - playState: Color(hex: 0xE0902E), vuPeak: nil, - labelPaper: nil, labelInk: nil), - type: Typography( - hero: { .system(size: $0, weight: .bold, design: .serif) }, - title: { .system(size: $0, weight: .semibold, design: .serif) }, - section: { .system(size: $0, weight: .semibold) }, - body: { .system(size: $0) }, - numeric: { .system(size: $0, design: .monospaced) }), - textureOpacity: 0, - copy: IdleCopy( - nowPlaying: "Nothing playing — press / to search.", - dashboardIdle: "Idle", - dashHeaders: ("Now Playing", "Signal", "Up Next")), - caps: [.artWash, .equalizerRows], - transport: .standard, - accentMode: .artDriven(fallback: Color(hex: 0xE0902E)), - washStyle: .linear, - darkOnly: false) - - static let shoebox = Theme( - id: .shoebox, - palette: Palette( - base: Color(hex: 0x16110D), raised: Color(hex: 0x1E1712), hairline: Color(hex: 0x2C2118), - textPrimary: Color(hex: 0xEDE3CF), textSecondary: Color(hex: 0xA8987E), textIdle: Color(hex: 0x6E6151), - accent: Color(hex: 0xE0922F), losslessBadge: nil, - playState: Color(hex: 0xB5561F), vuPeak: nil, - labelPaper: nil, labelInk: nil), - type: Typography( - hero: { .custom("AvenirNextCondensed-Heavy", size: $0) }, - title: { .system(size: $0, weight: .medium, design: .serif) }, - section: { .custom("AvenirNextCondensed-Bold", size: $0) }, - body: { .system(size: $0) }, - numeric: { .system(size: $0, design: .monospaced) }), - textureOpacity: 0.035, - copy: IdleCopy( - nowPlaying: "B-side's empty. Press / to find a show.", - dashboardIdle: "Nothing cued up.", - dashHeaders: ("Now Playing", "Quality", "Up Next")), - caps: [.condensedHeaders, .indexLetterTab, .artWash], - transport: .jCard, - accentMode: .staticAccent, - washStyle: .warmLow, - darkOnly: false) - - static let tapeDeck = Theme( - id: .tapeDeck, - palette: Palette( - base: Color(hex: 0x0B0A08), raised: Color(hex: 0x1C1A17), hairline: Color(hex: 0x14120F), - textPrimary: Color(hex: 0xEFE7D8), textSecondary: Color(hex: 0xB5701A), textIdle: Color(hex: 0x7A5414), - accent: Color(hex: 0xFFA62B), losslessBadge: nil, - playState: Color(hex: 0x36C9C0), vuPeak: Color(hex: 0xFF4D2E), - labelPaper: nil, labelInk: nil), - type: Typography( - hero: { .system(size: $0, weight: .semibold, design: .serif) }, - title: { .system(size: $0, weight: .medium, design: .serif) }, - section: { .system(size: $0, weight: .heavy, design: .monospaced) }, - body: { .system(size: $0) }, - numeric: { .system(size: $0, weight: .heavy, design: .monospaced) }), - textureOpacity: 0, - copy: IdleCopy( - nowPlaying: "No signal. Press / to tune in.", - dashboardIdle: "NO SIGNAL", - dashHeaders: ("TUNED TO", "SIGNAL", "REELS")), - caps: [.vuMeter, .artWash], - transport: .faceplate, - accentMode: .staticAccent, - washStyle: .bloom18, - darkOnly: true) + let copy: Copy } extension Theme { - /// Monochrome pocket-player homage: graphite surfaces, white glyphs, soft - /// raised circles, zero color — the album art is the only pigment on - /// screen. iOS gets the circular-pad full-screen player; the Mac bar keeps - /// its standard shape in these tokens. - static let clickWheel = Theme( - id: .clickWheel, + /// Yeti, an unofficial player's own look: night-blue shelves, snow type, + /// sale-yellow stickers and one foil accent kept for things that move with + /// time. Heavy rounded type, dark only. + static let yeti = Theme( palette: Palette( - base: Color(hex: 0x0E0E0F), raised: Color(hex: 0x1C1C1E), hairline: Color(hex: 0x2A2A2C), - textPrimary: Color(hex: 0xF5F5F5), textSecondary: Color(hex: 0x9A9A9E), textIdle: Color(hex: 0x5C5C60), - accent: Color(hex: 0xE9E9EB), losslessBadge: nil, - playState: Color(hex: 0xF5F5F5), vuPeak: nil, - labelPaper: nil, labelInk: nil), + base: Color(hex: 0x0F1424), raised: Color(hex: 0x18203A), + hairline: Color(hex: 0x2E3A5C), + textPrimary: Color(hex: 0xE9EEF7), + textSecondary: Color(hex: 0x9FABCE), + textIdle: Color(hex: 0x8590B0), + accent: Color(hex: 0xFF8FC4), + playState: Color(hex: 0xF2E27A), + cardStock: Color(hex: 0x1B2443), + stickerInk: Color(hex: 0x0F1424), + stickerHighlight: Color(hex: 0xFBF1A6), + stickerSale: Color(hex: 0xF2E27A), + stickerEdge: Color(hex: 0xE3D267), + segmentThumb: Color(hex: 0x2A355A), + videoErrorBackdrop: Color(hex: 0x05070D), + bubbleGlass: Color(hex: 0xE1E9FA), + bubbleShade: Color(hex: 0x040710), + bubbleGlaze: Color(hex: 0xBED2FF)), type: Typography( - hero: { .system(size: $0, weight: .bold) }, - title: { .system(size: $0, weight: .semibold) }, - section: { .system(size: $0, weight: .semibold) }, - body: { .system(size: $0) }, - numeric: { .system(size: $0, design: .monospaced) }), - textureOpacity: 0, - copy: IdleCopy( - nowPlaying: "Nothing playing. Give the wheel a spin.", - dashboardIdle: "Idle", - dashHeaders: ("Now Playing", "Quality", "Up Next")), - caps: [], - transport: .clickWheel, - accentMode: .staticAccent, - washStyle: .none, - darkOnly: true) + hero: { .system(size: $0, weight: .heavy, design: .rounded) }, + title: { .system(size: $0, weight: .heavy, design: .rounded) }, + section: { .system(size: $0, weight: .heavy, design: .rounded) }, + body: { .system(size: $0, design: .rounded) }, + numeric: { .system(size: $0, design: .rounded).monospacedDigit() }), + copy: Copy()) } // MARK: - Color hex helper diff --git a/AppleNugs/Theme/ThemeEnvironment.swift b/AppleNugs/Theme/ThemeEnvironment.swift index 61188dc..bd45458 100644 --- a/AppleNugs/Theme/ThemeEnvironment.swift +++ b/AppleNugs/Theme/ThemeEnvironment.swift @@ -3,75 +3,52 @@ import SwiftUI // MARK: - Environment keys private struct ThemeKey: EnvironmentKey { - static let defaultValue = Theme.tapeRoom -} - -private struct ArtColorKey: EnvironmentKey { - static let defaultValue: Color? = nil + static let defaultValue = Theme.yeti } extension EnvironmentValues { + /// The design's tokens. Its default is `Theme.yeti`, the only design, so + /// nothing has to inject it. var theme: Theme { get { self[ThemeKey.self] } set { self[ThemeKey.self] = newValue } } - /// The clamped dominant color of the current cover, when an art-driven - /// theme is active; nil otherwise. - var artColor: Color? { - get { self[ArtColorKey.self] } - set { self[ArtColorKey.self] = newValue } - } } // MARK: - Root modifier /// Recolors the bulk of the app from one place: tint, default text color, -/// window-ish background, default font, dark scheme, and a crossfade on switch. +/// window-ish background, default font, dark scheme. +/// +/// The tint is snow (`textPrimary`), so system-tinted controls (prominent +/// buttons, the selected tab, toggles, links, untinted sliders) never paint an +/// accent fill; the accent is applied explicitly, only where time moves +/// (scrub sliders, the pill ring, progress). private struct ThemedRoot: ViewModifier { let theme: Theme - let artColor: Color? func body(content: Content) -> some View { content - .tint(theme.effectiveAccent(art: artColor)) + .tint(theme.palette.textPrimary) .foregroundStyle(theme.palette.textPrimary) .background(theme.palette.base) .environment(\.font, theme.type.body(13)) .preferredColorScheme(.dark) - .animation(.easeInOut(duration: 0.35), value: theme.id) } } extension View { - func themed(_ theme: Theme, art: Color?) -> some View { - modifier(ThemedRoot(theme: theme, artColor: art)) + func themed(_ theme: Theme) -> some View { + modifier(ThemedRoot(theme: theme)) } } // MARK: - Surface helpers extension View { - /// A themed background surface (sidebar / inspector / transport) that also - /// carries the faint paper grain when the theme asks for it. + /// A themed background surface (sidebar / inspector / transport). func themedSurface(_ theme: Theme, raised: Bool = true) -> some View { background(raised ? theme.palette.raised : theme.palette.base) - .overlay { - if theme.textureOpacity > 0 { - PaperGrain() - .opacity(theme.textureOpacity) - .allowsHitTesting(false) - } - } - } - - /// The album-art wash behind a region, a no-op for themes without one. - @ViewBuilder - func artWash(_ style: WashStyle, color: Color?) -> some View { - if style == .none || color == nil { - self - } else { - background(ArtWashBackground(style: style, color: color ?? .clear)) - } } /// iOS List rows paint an opaque system background over the themed base @@ -85,43 +62,3 @@ extension View { #endif } } - -// MARK: - Procedural paper grain (no bundled asset) - -/// A cheap monochrome grain drawn with Canvas, tiling-free and theme-tintable. -/// Kept subtle; only used on raised surfaces at low opacity. -struct PaperGrain: View { - var body: some View { - Canvas { ctx, size in - // A coarse deterministic speckle — enough tooth to kill flat - // paint. Speckles batch into two alpha buckets (visually - // indistinguishable under the themes' ≤4% overlay) so a redraw - // issues two fills instead of thousands of 1x1 fills. - let step: CGFloat = 3 - var dim = Path() - var bright = Path() - var y: CGFloat = 0 - var seed: UInt64 = 0x9E3779B97F4A7C15 - while y < size.height { - var x: CGFloat = 0 - while x < size.width { - seed = seed &* 6364136223846793005 &+ 1442695040888963407 - let v = Double((seed >> 33) & 0xFF) / 255.0 - if v > 0.72 { - let rect = CGRect(x: x, y: y, width: 1, height: 1) - if v > 0.86 { - bright.addRect(rect) - } else { - dim.addRect(rect) - } - } - x += step - } - y += step - } - ctx.fill(dim, with: .color(.white.opacity(0.28))) - ctx.fill(bright, with: .color(.white.opacity(0.43))) - } - .blendMode(.overlay) - } -} diff --git a/AppleNugs/Theme/ThemeID.swift b/AppleNugs/Theme/ThemeID.swift deleted file mode 100644 index 14042a2..0000000 --- a/AppleNugs/Theme/ThemeID.swift +++ /dev/null @@ -1,26 +0,0 @@ -import SwiftUI - -/// Stable, persisted identity for each theme. -enum ThemeID: String, CaseIterable, Identifiable, Codable { - case tapeRoom - case soundboard - case shoebox - case tapeDeck - case clickWheel - - var id: String { rawValue } - - var displayName: String { - switch self { - case .tapeRoom: return "Tape Room" - case .soundboard: return "Soundboard" - case .shoebox: return "Shoebox" - case .tapeDeck: return "The Receiver" - case .clickWheel: return "Click Wheel" - } - } - - /// Accent swatch for the picker (the static accent; art-driven themes show - /// their fallback). - var swatch: Color { Theme.make(self).palette.accent } -} diff --git a/AppleNugs/Theme/ThemeManager.swift b/AppleNugs/Theme/ThemeManager.swift deleted file mode 100644 index 3d8d938..0000000 --- a/AppleNugs/Theme/ThemeManager.swift +++ /dev/null @@ -1,26 +0,0 @@ -import Observation -import SwiftUI - -/// Owns the selected theme and persists it to UserDefaults — the same idiom -/// PlayerService uses for volume. The derived `theme` value is what views read. -@MainActor -@Observable -final class ThemeManager { - private static let key = "selectedTheme" - - var selected: ThemeID { - didSet { - UserDefaults.standard.set(selected.rawValue, forKey: Self.key) - theme = Theme.make(selected) - } - } - - private(set) var theme: Theme - - init() { - let stored = UserDefaults.standard.string(forKey: Self.key) - let id = stored.flatMap(ThemeID.init(rawValue:)) ?? .tapeRoom - selected = id - theme = Theme.make(id) - } -} diff --git a/AppleNugs/Theme/Transport/JCardStrip.swift b/AppleNugs/Theme/Transport/JCardStrip.swift deleted file mode 100644 index 548f1a5..0000000 --- a/AppleNugs/Theme/Transport/JCardStrip.swift +++ /dev/null @@ -1,39 +0,0 @@ -import SwiftUI - -/// Shoebox's signature now-playing block: a cassette J-card spine — cover chip -/// with a cream keyline, a hairline divider, the title in liner-note serif and -/// the metadata typed out in tracked small caps. -struct JCardStrip: View { - @Environment(AppModel.self) private var app - @Environment(\.theme) private var theme - - private var player: PlayerService { app.player } - - var body: some View { - if let track = player.current { - HStack(spacing: 10) { - ArtChip(image: player.nowPlayingImage, - fallbackText: track.artist ?? track.title ?? "?", - size: 42) - Rectangle() - .fill(theme.palette.hairline) - .frame(width: 1, height: 32) - VStack(alignment: .leading, spacing: 3) { - Text(track.title ?? "Unknown track") - .font(theme.type.title(15)) - .lineLimit(1) - NowPlayingMetaText(track: track, casing: .upper) - .font(theme.type.numeric(9)) - .tracking(0.9) - .foregroundStyle(theme.palette.textSecondary) - .lineLimit(1) - } - } - } else { - Text(theme.copy.nowPlaying) - .font(theme.type.title(13)) - .italic() - .foregroundStyle(theme.palette.textIdle) - } - } -} diff --git a/AppleNugs/Theme/Transport/NowPlayingMetaLine.swift b/AppleNugs/Theme/Transport/NowPlayingMetaLine.swift index 637c81c..0dc58e5 100644 --- a/AppleNugs/Theme/Transport/NowPlayingMetaLine.swift +++ b/AppleNugs/Theme/Transport/NowPlayingMetaLine.swift @@ -2,14 +2,14 @@ import Foundation /// The "artist · show" now-playing subtitle, broken into linkable segments. /// -/// Sixteen surfaces render this line — four transport signatures, four Mac -/// miniplayers, three full-screen players, the iOS pill and panel, the -/// inspector, and the Home hero — and eight of them build it by joining the two +/// Many surfaces render this line — the transport bar, the Mac miniplayer and +/// Now Playing window, the full-screen player, the iOS pill and panel, the +/// inspector, and the Home heroes — and several build it by joining the two /// fields into one flat string. Making the artist and the show individually /// clickable therefore has to happen BELOW the string, not by splitting the -/// views: several of those surfaces are dimensionally tight (the cassette's -/// 25:16 ratio, the pill's text budget, the faceplate's fixed-width ticker), and -/// an HStack of buttons would not truncate the way one Text does. +/// views: several of those surfaces are dimensionally tight (the pill's text +/// budget above all), and an HStack of buttons would not truncate the way one +/// Text does. /// /// So this type answers "what are the runs, and which of them point somewhere", /// and `NowPlayingMetaText` renders them as one `Text(AttributedString)` with a @@ -30,7 +30,7 @@ enum NowPlayingMetaLine { enum Field { case artist, show } - /// Some signatures render the line in tracked small caps. + /// `.upper` upper-cases the fields (no caller does today; kept, tested). enum Casing { case asIs, upper } enum Mode { diff --git a/AppleNugs/Theme/Transport/StandardNowPlaying.swift b/AppleNugs/Theme/Transport/StandardNowPlaying.swift index 8481af6..6e8e178 100644 --- a/AppleNugs/Theme/Transport/StandardNowPlaying.swift +++ b/AppleNugs/Theme/Transport/StandardNowPlaying.swift @@ -1,7 +1,7 @@ import SwiftUI -/// The original now-playing block, token-styled. Used by Soundboard and as the -/// fallback for The Receiver until its faceplate ships. +/// The Mac transport bar's now-playing block: an art chip, the title, the +/// meta line and the queue position. struct StandardNowPlaying: View { @Environment(AppModel.self) private var app @Environment(\.theme) private var theme @@ -10,26 +10,39 @@ struct StandardNowPlaying: View { var body: some View { if let track = player.current { - VStack(alignment: .leading, spacing: 2) { - HStack(spacing: 6) { - Text("\(player.index + 1)/\(player.queue.count)") - .font(theme.type.numeric(10)) - .foregroundStyle(theme.palette.textSecondary) - Text(track.title ?? "Unknown track") - .font(theme.type.title(14)) - .lineLimit(1) - } - NowPlayingMetaText(track: track) - .font(.caption) - .foregroundStyle(theme.palette.textSecondary) - .lineLimit(1) - } + plainBlock(track) } else { Text(theme.copy.nowPlaying) .font(.callout) .foregroundStyle(theme.palette.textIdle) } } + + /// The block as drawn in the surfaces page's Mac + /// transport bar: a flat 42pt art chip (no bubble — that is the + /// miniplayer's), the title, the meta line, and the queue position said + /// plainly ("3 of 24") in lamp-off gray under them. + private func plainBlock(_ track: QueueTrack) -> some View { + HStack(spacing: 10) { + ArtChip(image: player.nowPlayingImage, + fallbackText: track.artist ?? track.title ?? "?", + size: 42) + .accessibilityHidden(true) + VStack(alignment: .leading, spacing: 1) { + Text(track.title ?? "Unknown track") + .font(theme.type.title(15)) + .lineLimit(1) + NowPlayingMetaText(track: track) + .font(theme.type.body(12)) + .foregroundStyle(theme.palette.textSecondary) + .lineLimit(1) + Text("\(player.index + 1) of \(player.queue.count)") + .font(theme.type.numeric(11)) + .foregroundStyle(theme.palette.textIdle) + .lineLimit(1) + } + } + } } /// Shared helper for the "artist · show" subtitle. diff --git a/AppleNugs/Theme/Transport/TapeLabelCard.swift b/AppleNugs/Theme/Transport/TapeLabelCard.swift deleted file mode 100644 index 95c9b27..0000000 --- a/AppleNugs/Theme/Transport/TapeLabelCard.swift +++ /dev/null @@ -1,50 +0,0 @@ -import SwiftUI - -/// Tape Room's signature now-playing block: a cover chip + title/meta, with a -/// thin amber "tape counter" under-rule that fills as the track plays. -struct TapeLabelCard: View { - @Environment(AppModel.self) private var app - @Environment(\.theme) private var theme - - private var player: PlayerService { app.player } - - var body: some View { - if let track = player.current { - HStack(spacing: 10) { - ArtChip(image: player.nowPlayingImage, - fallbackText: track.artist ?? track.title ?? "?", - size: 40) - VStack(alignment: .leading, spacing: 3) { - Text(track.title ?? "Unknown track") - .font(theme.type.title(14)) - .lineLimit(1) - NowPlayingMetaText(track: track) - .font(theme.type.numeric(10)) - .foregroundStyle(theme.palette.textSecondary) - .lineLimit(1) - underRule - } - } - } else { - Text(theme.copy.nowPlaying) - .font(.callout) - .foregroundStyle(theme.palette.textIdle) - } - } - - private var underRule: some View { - GeometryReader { geo in - ZStack(alignment: .leading) { - Capsule().fill(theme.palette.hairline) - Capsule().fill(theme.palette.accent) - .frame(width: geo.size.width * progress) - } - } - .frame(height: 2) - } - - private var progress: CGFloat { - guard player.duration > 0 else { return 0 } - return min(max(CGFloat(player.currentTime / player.duration), 0), 1) - } -} diff --git a/AppleNugs/Views/AlbumDetailView.swift b/AppleNugs/Views/AlbumDetailView.swift index 2e5a12d..a2c1369 100644 --- a/AppleNugs/Views/AlbumDetailView.swift +++ b/AppleNugs/Views/AlbumDetailView.swift @@ -29,7 +29,6 @@ struct AlbumDetailView: View { .frame(maxWidth: .infinity, alignment: .leading) } } - .reportsScrollForPillDuck() .background(theme.palette.base) .navigationTitle(album?.title ?? titleHint ?? "Album") .compactNavigationTitle() @@ -49,42 +48,66 @@ struct AlbumDetailView: View { // --- sections --------------------------------------------------------------- private func header(_ album: AlbumDetailModel) -> some View { - HStack(alignment: .top, spacing: 14) { + // `.show-ios` sets the text against the cover's bottom edge. + HStack(alignment: .bottom, spacing: 14) { if album.imageURL != nil { CoverArt(url: album.imageURL) .frame(width: 110) } - VStack(alignment: .leading, spacing: 4) { - Text(album.title).font(theme.type.hero(26)) - Text(album.artistName).font(theme.type.title(18)).foregroundStyle(theme.palette.textSecondary) - HStack(spacing: 10) { - if let date = album.dateText { - Text(date).font(theme.type.numeric(12)) - } - if let venue = album.venue { - Text(venue) - } - if let runtime = album.totalRunningTime { - Text("\(runtime) · \(album.tracks.count) tracks") - .font(theme.type.numeric(12)) - .foregroundStyle(theme.palette.textSecondary) - } + headerText(album) + } + } + + /// `.show-ios`: the meta stacks instead of sitting side by side + /// (where a city wrapped into a ragged column of its own). Title, then + /// the artist as a snow link, then "date · venue" on one truncating line, + /// then "runtime · N tracks", both in slush 13.5. A missing field drops + /// out rather than leaving a blank line. + private func headerText(_ album: AlbumDetailModel) -> some View { + let place = [album.dateText, album.venue].compactMap { $0 }.joined(separator: " · ") + return VStack(alignment: .leading, spacing: 4) { + Text(album.title) + .font(theme.type.hero(26)) + .foregroundStyle(theme.palette.textPrimary) + artistLink(album.artistName) + VStack(alignment: .leading, spacing: 2) { + if !place.isEmpty { + Text(place) + .lineLimit(1) + .truncationMode(.tail) + } + if let runtime = album.totalRunningTime { + Text("\(runtime) · \(album.tracks.count) tracks") + .lineLimit(1) } - .font(.callout) - .foregroundStyle(theme.palette.textSecondary) } + .font(theme.type.body(13.5)) + .foregroundStyle(theme.palette.textSecondary) } + .frame(maxWidth: .infinity, alignment: .leading) } + /// The artist, heavy 15 in snow, opening the artist page through the same + /// `x-applenugs-meta://` route the now-playing links use (the scene + /// root's `.nowPlayingMetaLinks` resolves the name; a miss is a toast). + /// A `.link` run in a `Text`, like `NowPlayingMetaText`, carrying its snow + /// explicitly so SwiftUI doesn't paint it in the link tint. + private func artistLink(_ name: String) -> some View { + var run = AttributedString(name) + run[AttributeScopes.FoundationAttributes.LinkAttribute.self] = + NowPlayingMetaLine.Target.artist(name: name).url + run[AttributeScopes.SwiftUIAttributes.ForegroundColorAttribute.self] = theme.palette.textPrimary + return Text(run) + .font(theme.type.title(15)) + .lineLimit(1) + } + + /// Play all is the one snow primary and the rest are frost rings — round + /// icon buttons on iPhone (`.actrow .ib`), frost-outlined labeled buttons + /// on the Mac. private func actions(_ album: AlbumDetailModel) -> some View { HStack(spacing: 8) { - Button { - app.player.play(queueTracks(album)) - } label: { - Label("Play all", systemImage: "play.fill") - } - .buttonStyle(.borderedProminent) - .disabled(album.tracks.isEmpty) + playAllButton(album) Button { if app.player.playNext(queueTracks(album)) { @@ -94,6 +117,7 @@ struct AlbumDetailView: View { Label("Play next", systemImage: "text.line.first.and.arrowtriangle.forward") } .disabled(album.tracks.isEmpty) + .yetiPageAction() Button { if app.player.enqueue(queueTracks(album)) { @@ -103,6 +127,10 @@ struct AlbumDetailView: View { Label("Queue", systemImage: "plus") } .disabled(album.tracks.isEmpty) + .yetiPageAction() + + // `.actrow .grow`: Save and Download sit at the trailing edge. + Spacer(minLength: 0) // Five labeled buttons overflow iPhone widths; the secondary // actions go icon-only there (labels stay for accessibility). @@ -122,6 +150,16 @@ struct AlbumDetailView: View { } } + private func playAllButton(_ album: AlbumDetailModel) -> some View { + Button { + app.player.play(queueTracks(album)) + } label: { + Label("Play all", systemImage: "play.fill") + } + .disabled(album.tracks.isEmpty) + .buttonStyle(.yetiPrimary) + } + /// Offline download state machine: idle → live progress → downloaded /// (tap to remove) / failed (tap to retry). Shared by both platforms. /// A separate View so the live-progress @Observable dependency @@ -144,10 +182,14 @@ struct AlbumDetailView: View { Label("Download", systemImage: "arrow.down.circle") } .disabled(album.tracks.isEmpty) + .yetiPageAction() case .downloading(let fraction): HStack(spacing: 6) { ProgressView(value: fraction) + // A download moves through time: foil, explicitly, + // against the snow root tint. + .tint(theme.palette.accent) .frame(width: 44) .accessibilityLabel("Downloading show") .accessibilityValue("\(Int(fraction * 100)) percent") @@ -167,7 +209,7 @@ struct AlbumDetailView: View { } label: { Label("Downloaded", systemImage: "arrow.down.circle.fill") } - .tint(theme.palette.accent) + .yetiDownloadedAction() .confirmationDialog("Remove this show's downloaded files?", isPresented: $confirmRemoveDownload, titleVisibility: .visible) { @@ -183,6 +225,7 @@ struct AlbumDetailView: View { } label: { Label("Download failed", systemImage: "exclamationmark.arrow.circlepath") } + .yetiPageAction() .alert("Download failed", isPresented: $downloadFailureShown) { Button("Retry") { app.downloads.download(downloadRequest()) } Button("OK", role: .cancel) {} @@ -215,7 +258,9 @@ struct AlbumDetailView: View { } label: { Label(fav ? "Saved" : "Save", systemImage: fav ? "star.fill" : "star") } - .tint(fav ? theme.palette.accent : nil) + // A saved show isn't time moving: the star is snow (the frost ring's + // glyph), never accent. + .yetiPageAction() .help(fav ? "Remove from Favorites" : "Save show to Favorites") } @@ -243,11 +288,11 @@ struct AlbumDetailView: View { ForEach(groups, id: \.key) { setNum, rows in if showHeaders { - let label = TrackEntry.setLabel(setNum) - Text(theme.caps.contains(.condensedHeaders) ? label.uppercased() : label) + Text(TrackEntry.setLabel(setNum)) .font(theme.type.section(15)) - .tracking(theme.caps.contains(.condensedHeaders) ? 1.4 : 0) - .foregroundStyle(theme.palette.textPrimary) + // `.setname`: set headers are the barker's heavy slush, + // sentence case. + .foregroundStyle(theme.palette.textSecondary) .padding(.top, 6) } VStack(spacing: 0) { @@ -319,8 +364,8 @@ private struct TrackRow: View { let enqueue: () -> Void let isCurrent: Bool + @Environment(AppModel.self) private var app @Environment(\.theme) private var theme - @Environment(\.artColor) private var artColor @State private var hovering = false /// Touch has no hover: on iOS the per-row actions are always visible @@ -333,18 +378,25 @@ private struct TrackRow: View { #endif } + /// The setlist rows (`.setlist li`, `.setlist.ios li`): 44pt on iPhone, + /// 36pt on the Mac, each ruled in frost. + private var rowHeight: CGFloat { + #if os(iOS) + 44 + #else + 36 + #endif + } + var body: some View { HStack(spacing: 8) { Button(action: play) { HStack(spacing: 8) { - Text("\(track.trackNum).") - .font(theme.type.numeric(12)) - .foregroundStyle(theme.palette.textSecondary) - .frame(width: 28, alignment: .trailing) + numberColumn Text(track.title) - .font(theme.type.body(13)) - .fontWeight(isCurrent ? .semibold : .regular) - .foregroundStyle(isCurrent ? theme.activeEmphasis(art: artColor) : theme.palette.textPrimary) + .font(theme.type.body(15)) + .fontWeight(isCurrent ? .heavy : .regular) + .foregroundStyle(isCurrent ? theme.palette.playState : theme.palette.textPrimary) Spacer(minLength: 8) if let duration = track.durationText { Text(duration) @@ -367,12 +419,28 @@ private struct TrackRow: View { .help("Add to queue") } .buttonStyle(.borderless) + // `.setlist.ios li .q`: lamp-off idle (the snow root tint would + // print them louder than the title). + .tint(theme.palette.textIdle) .opacity(rowActionsVisible ? 1 : 0) } .padding(.vertical, 4) .padding(.horizontal, 6) + .frame(minHeight: rowHeight) + .background { + if isCurrent { + // The playing row's faint sale wash (`.setlist li.playing`), + // the Mac setlist rule brought to iPhone too. + LinearGradient(stops: [.init(color: theme.palette.playState.opacity(0.07), location: 0), + .init(color: theme.palette.playState.opacity(0), location: 0.7)], + startPoint: .leading, endPoint: .trailing) + } + } .background(hovering ? theme.palette.raised : .clear, in: RoundedRectangle(cornerRadius: 5)) + .overlay(alignment: .top) { + Rectangle().fill(theme.palette.hairline).frame(height: 1) + } .onHover { hovering = $0 } .contextMenu { Button("Play", action: play) @@ -380,4 +448,22 @@ private struct TrackRow: View { Button("Add to Queue", action: enqueue) } } + + /// The 28pt number column. The playing row shows the 20pt `RowSticker` + /// in place of its number (the stage-c spec's row-sticker table), and + /// EVERY row's column is 20pt tall, so the sticker moving to another track + /// never resizes a row. `isPlaying` is read only on the playing row, so a + /// pause re-renders that one row, not the show page. + private var numberColumn: some View { + Group { + if isCurrent { + RowSticker(isPlaying: app.player.isPlaying, size: .s20) + } else { + Text("\(track.trackNum)") + .font(theme.type.numeric(12)) + .foregroundStyle(theme.palette.textIdle) + } + } + .frame(width: 28, height: RowSticker.Size.s20.diameter, alignment: .trailing) + } } diff --git a/AppleNugs/Views/ArtistDetailView.swift b/AppleNugs/Views/ArtistDetailView.swift index d03edda..75a377b 100644 --- a/AppleNugs/Views/ArtistDetailView.swift +++ b/AppleNugs/Views/ArtistDetailView.swift @@ -41,6 +41,9 @@ struct ArtistDetailView: View { .background(theme.palette.base) .navigationTitle(artist.name) .compactNavigationTitle() + // The header prints the name as a 34pt title right below the bar; the + // bar keeps only the back button, as the surfaces sheet draws it. + .hidesNavigationBarTitle() .overlay { if loading && containers.isEmpty { ProgressView() @@ -156,11 +159,18 @@ struct CoverArt: View { .aspectRatio(1, contentMode: .fit) .frame(maxWidth: .infinity) .clipShape(RoundedRectangle(cornerRadius: 6)) + // A cover sits flat on the shelf with a 1pt frost keyline (the bubble + // is only for what's playing) — the show page and `ShowCard`. + .overlay { + RoundedRectangle(cornerRadius: 6).strokeBorder(theme.palette.hairline, lineWidth: 1) + } } private var placeholder: some View { Rectangle() .fill(theme.palette.raised) - .overlay(Image(systemName: "music.note").foregroundStyle(theme.palette.accent.opacity(0.7))) + // A placeholder isn't time moving: slush. + .overlay(Image(systemName: "music.note") + .foregroundStyle(theme.palette.textSecondary)) } } diff --git a/AppleNugs/Views/ArtistLibrary/CrateHeader.swift b/AppleNugs/Views/ArtistLibrary/CrateHeader.swift index 5b9d19e..04b7738 100644 --- a/AppleNugs/Views/ArtistLibrary/CrateHeader.swift +++ b/AppleNugs/Views/ArtistLibrary/CrateHeader.swift @@ -1,8 +1,8 @@ import SwiftUI -/// Player-style header: a reused EQ/VU strip, an LCD-style summary panel, and -/// the Follow control. The LCD line truncates if it overflows (a scrolling -/// marquee is a deferred enhancement). +/// The artist page header: the artist's name as a large title, the counts under +/// it as plain type, and Follow as a frost-outlined button. No LCD pill and no +/// bars (the stage-c row-sticker table: this site gets nothing). struct CrateHeader: View { let artist: ArtistEntry let albumCount: Int @@ -12,48 +12,62 @@ struct CrateHeader: View { @Environment(AppModel.self) private var app @Environment(\.theme) private var theme + /// The surfaces sheet's artist page: `.large-title`, then `.crate-head` + /// (counts left, Follow right). The counts row is as tall as the 30pt + /// button whether or not any counts have arrived, so paging in the + /// catalog never moves the list below it. var body: some View { - HStack(spacing: 12) { - EqualizerBars(isPlaying: app.player.isPlaying) - lcd - followButton + VStack(alignment: .leading, spacing: 4) { + Text(artist.name) + .font(theme.type.hero(34)) + .foregroundStyle(theme.palette.textPrimary) + .lineLimit(2) + .minimumScaleFactor(0.7) + .accessibilityAddTraits(.isHeader) + HStack(spacing: 12) { + countsLine + .frame(maxWidth: .infinity, alignment: .leading) + followButton + } } } - private var lcd: some View { - Text(summary) - .font(theme.type.numeric(12)) - .foregroundStyle(theme.palette.accent) + /// "**340** shows · **12** videos · **5** albums" — snow figures in slush + /// type, in the sheet's order; a kind with nothing yet is left out. + private var countsLine: some View { + let parts = [(showCount, "show", "shows"), + (videoCount, "video", "videos"), + (albumCount, "album", "albums")] + .filter { $0.0 > 0 } + // Text interpolation, not `Text + Text` (deprecated), as in + // `ListeningLedger.figureText`. + var line = Text(verbatim: "") + for (i, part) in parts.enumerated() { + let figure = Text(verbatim: "\(part.0)") + .fontWeight(.heavy) + .foregroundStyle(theme.palette.textPrimary) + let unit = part.0 == 1 ? part.1 : part.2 + line = i == 0 ? Text("\(figure) \(unit)") : Text("\(line) · \(figure) \(unit)") + } + return line + .font(theme.type.body(14)) + .foregroundStyle(theme.palette.textSecondary) .lineLimit(1) .truncationMode(.tail) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal, 10) - .padding(.vertical, 6) - .background(theme.palette.base.opacity(0.6), in: RoundedRectangle(cornerRadius: 4)) - .overlay(RoundedRectangle(cornerRadius: 4).strokeBorder(theme.palette.hairline, lineWidth: 0.5)) - .accessibilityLabel(summary) - } - - private var summary: String { - let name = theme.caps.contains(.condensedHeaders) ? artist.name.uppercased() : artist.name - var parts: [String] = [] - if videoCount > 0 { parts.append("\(videoCount) videos") } - if showCount > 0 { parts.append("\(showCount) shows") } - if albumCount > 0 { parts.append("\(albumCount) albums") } - let tail = parts.joined(separator: " · ") - return tail.isEmpty ? name : "\(name) — \(tail)" } + /// `.btn.quiet.sm`: a frost ring, "Following" with a check; unfollowed it + /// reads "Follow" with a plus. private var followButton: some View { let fav = app.favorites.isArtistFavorited(artist.id) return Button { app.favorites.toggleArtist(id: artist.id, name: artist.name) } label: { - Label(fav ? "Following" : "Follow", systemImage: fav ? "star.fill" : "star") - .font(theme.type.body(12)) + Label(fav ? "Following" : "Follow", systemImage: fav ? "checkmark" : "plus") } - .buttonStyle(.bordered) - .tint(fav ? theme.palette.accent : theme.palette.textSecondary) + .buttonStyle(.yetiSecondary) + .controlSize(.small) + .fixedSize() .help(fav ? "Unfollow artist" : "Follow artist") } } diff --git a/AppleNugs/Views/ArtistLibrary/CrateList.swift b/AppleNugs/Views/ArtistLibrary/CrateList.swift index 1c4c19e..0ed9dcc 100644 --- a/AppleNugs/Views/ArtistLibrary/CrateList.swift +++ b/AppleNugs/Views/ArtistLibrary/CrateList.swift @@ -20,7 +20,7 @@ struct CrateList: View { /// Derived state, not computed in `body`: filtering + grouping + sorting a /// catalog hundreds deep is too heavy to redo on every body evaluation - /// (theme switches, favorites toggles, playback ticks). Rebuilt only when + /// (favorites toggles, playback ticks). Rebuilt only when /// the inputs actually change. The old outline re-grouped all 483 shows on /// every render. @State private var sections: [(month: Date?, items: [CrateItem])] = [] @@ -61,17 +61,21 @@ struct CrateList: View { // MARK: scope + /// Our own segmented control, and no counts in the labels — the header's + /// counts line under the name already carries them. + private var scopeControl: some View { + YetiSegmentedControl("Scope", selection: $scope, options: [ + .init(value: .show, title: "Shows"), + .init(value: .video, title: "Videos"), + .init(value: .album, title: "Albums"), + ]) + } + private var scopePicker: some View { - Picker("Scope", selection: $scope) { - Text("Shows \(shows.count)").tag(CrateKind.show) - Text("Videos \(videos.count)").tag(CrateKind.video) - Text("Albums \(albums.count)").tag(CrateKind.album) - } - .pickerStyle(.segmented) - .labelsHidden() - .padding(.horizontal, 16) - .padding(.top, 4) - .padding(.bottom, 8) + scopeControl + .padding(.horizontal, 16) + .padding(.top, 4) + .padding(.bottom, 8) } // MARK: filter @@ -81,10 +85,13 @@ struct CrateList: View { Image(systemName: "magnifyingglass") .font(.system(size: 12, weight: .medium)) .foregroundStyle(theme.palette.textSecondary) - TextField(placeholder, text: $filter) + TextField(placeholder, text: $filter, + prompt: Text(placeholder).foregroundStyle(theme.palette.textIdle)) .textFieldStyle(.plain) - .font(theme.type.body(13)) + .font(theme.type.body(15)) .foregroundStyle(theme.palette.textPrimary) + // The root tint is snow; the caret is focus, and focus is foil. + .tint(theme.palette.accent) .focused($filterFocused) .submitLabel(.done) if !filter.isEmpty { @@ -97,15 +104,13 @@ struct CrateList: View { .accessibilityLabel("Clear filter") } } - .padding(.horizontal, 10) + .padding(.horizontal, 12) .padding(.vertical, 7) + // The `.field`: a 40pt shelf field, radius 12, no keyline. + .frame(minHeight: 40) .background { - RoundedRectangle(cornerRadius: 8, style: .continuous) + RoundedRectangle(cornerRadius: 12, style: .continuous) .fill(theme.palette.raised) - .overlay { - RoundedRectangle(cornerRadius: 8, style: .continuous) - .strokeBorder(theme.palette.hairline, lineWidth: 1) - } } .padding(.horizontal, 16) .padding(.bottom, 8) @@ -152,7 +157,7 @@ struct CrateList: View { .themedListRow() } } header: { - Text(header(section.month)) + monthHeader(section.month) } } } @@ -161,15 +166,16 @@ struct CrateList: View { .listStyle(.inset) .scrollContentBackground(.hidden) .scrollDismissesKeyboard(.immediately) - .reportsScrollForPillDuck() } } - private func header(_ month: Date?) -> String { - let title = CrateSection.monthTitle(month, - calendar: CrateSection.catalogCalendar, - locale: .current) - return theme.caps.contains(.condensedHeaders) ? title.uppercased() : title + /// Month headers: the barker's heavy slush 15 (`.month`), sentence case. + private func monthHeader(_ month: Date?) -> some View { + Text(CrateSection.monthTitle(month, + calendar: CrateSection.catalogCalendar, + locale: .current)) + .font(theme.type.section(15)) + .foregroundStyle(theme.palette.textSecondary) } private var loadingFooter: some View { diff --git a/AppleNugs/Views/ArtistLibrary/CrateRow.swift b/AppleNugs/Views/ArtistLibrary/CrateRow.swift index 4b1cb29..70f2238 100644 --- a/AppleNugs/Views/ArtistLibrary/CrateRow.swift +++ b/AppleNugs/Views/ArtistLibrary/CrateRow.swift @@ -1,6 +1,6 @@ import SwiftUI -/// One dense library row: number · thumbnail · title · LIVE/4K badges · date. +/// One dense library row: number · thumbnail · title · Live/4K stickers · date. /// Tapping navigates via the item's route; the context menu mirrors the /// existing per-kind favorites behavior (albums aren't favoritable). struct CrateRow: View { @@ -15,15 +15,18 @@ struct CrateRow: View { HStack(spacing: 9) { Text("\(index)") .font(theme.type.numeric(11)) - .foregroundStyle(theme.palette.textSecondary) + // `.crate li .ix` is lamp-off idle. + .foregroundStyle(theme.palette.textIdle) .frame(width: 22, alignment: .trailing) CrateThumb(url: item.imageURL, kind: item.kind) Text(item.title) .font(theme.type.body(13)) .foregroundStyle(theme.palette.textPrimary) .lineLimit(1) - if item.isLive { badge("LIVE") } - if item.has4K { badge("4K") } + // Live is an offer (a label sticker), 4K a fact (a plain tag); + // never an accent fill. + if item.isLive { LabelSticker("Live") } + if item.has4K { PlainTag("4K") } Spacer(minLength: 8) if let dateText = item.dateText { Text(dateText) @@ -40,15 +43,6 @@ struct CrateRow: View { .contextMenu { favoriteButton } } - private func badge(_ text: String) -> some View { - Text(text) - .font(theme.type.numeric(10)) - .foregroundStyle(theme.palette.base) - .padding(.horizontal, 4) - .padding(.vertical, 1) - .background(theme.palette.accent, in: RoundedRectangle(cornerRadius: 3)) - } - @ViewBuilder private var favoriteButton: some View { switch item.kind { case .show: diff --git a/AppleNugs/Views/ArtistLibrary/CrateThumb.swift b/AppleNugs/Views/ArtistLibrary/CrateThumb.swift index 9531b27..449ead9 100644 --- a/AppleNugs/Views/ArtistLibrary/CrateThumb.swift +++ b/AppleNugs/Views/ArtistLibrary/CrateThumb.swift @@ -35,7 +35,8 @@ struct CrateThumb: View { .overlay( Image(systemName: kind == .video ? "play.fill" : "music.note") .font(.system(size: 9)) - .foregroundStyle(theme.palette.accent.opacity(0.7)) + // A placeholder isn't time moving: slush. + .foregroundStyle(theme.palette.textSecondary) ) } } diff --git a/AppleNugs/Views/ArtistListView.swift b/AppleNugs/Views/ArtistListView.swift index f7b507a..6a079ff 100644 --- a/AppleNugs/Views/ArtistListView.swift +++ b/AppleNugs/Views/ArtistListView.swift @@ -14,7 +14,7 @@ struct ArtistListView: View { /// A–Z sections, derived state: filtering + grouping + locale-aware /// sorting over a catalog hundreds deep is too heavy to redo on every - /// body evaluation (favorites toggles, theme switches, toasts) — it + /// body evaluation (favorites toggles, toasts) — it /// rebuilds only when the inputs actually change (load, keystroke). @State private var sections: [(letter: String, artists: [ArtistEntry])] = [] @@ -59,12 +59,19 @@ struct ArtistListView: View { Section { ForEach(section.artists) { artist in NavigationLink(value: Route.artist(artist)) { + // The `.alist` rows of the first sheet's "Pill + // over an artist list": names only (Tim's + // ruling 2: no show counts), heavy 17 on frost + // rules, under heavy slush letter headers. Text(artist.name) - .font(theme.type.body(14)) + .font(theme.type.title(17)) .foregroundStyle(theme.palette.textPrimary) - .padding(.vertical, 1) + .lineLimit(1) + .padding(.vertical, 4) } - .listRowSeparator(.hidden) + // Each row is ruled in frost (`.alist li`). + .listRowSeparator(.visible) + .listRowSeparatorTint(theme.palette.hairline) .themedListRow() .contextMenu { let fav = app.favorites.isArtistFavorited(artist.id) @@ -75,16 +82,16 @@ struct ArtistListView: View { } } } header: { + // `.letter`: heavy slush, untracked. A header isn't + // time moving, so it isn't foil. Text(section.letter) - .font(theme.type.section(12)) - .tracking(theme.caps.contains(.condensedHeaders) ? 1.6 : 0.5) - .foregroundStyle(theme.palette.accent) + .font(theme.type.section(17)) + .foregroundStyle(theme.palette.textSecondary) } } } .listStyle(.inset) .scrollContentBackground(.hidden) - .reportsScrollForPillDuck() } .background(theme.palette.base) .navigationTitle("Artists") @@ -109,7 +116,8 @@ struct ArtistListView: View { systemImage: "exclamationmark.triangle", description: Text(error)) } else if sections.isEmpty && !artists.isEmpty { - ContentUnavailableView.search(text: filter) + ContentUnavailableView(theme.copy.searchNoResults(filter), + systemImage: "magnifyingglass") } } .task { await load() } @@ -121,12 +129,15 @@ struct ArtistListView: View { private var filterField: some View { HStack(spacing: 8) { Image(systemName: "magnifyingglass") - .font(.system(size: 12, weight: .medium)) + .font(.system(size: 15, weight: .medium)) .foregroundStyle(theme.palette.textSecondary) - TextField("Filter artists", text: $filter) + TextField("Filter artists", text: $filter, + prompt: Text("Filter artists").foregroundStyle(theme.palette.textIdle)) .textFieldStyle(.plain) - .font(theme.type.body(13)) + .font(theme.type.body(16)) .foregroundStyle(theme.palette.textPrimary) + // The root tint is snow; the caret is focus, and focus is foil. + .tint(theme.palette.accent) .focused($filterFocused) .submitLabel(.done) if !filter.isEmpty { @@ -138,14 +149,17 @@ struct ArtistListView: View { .buttonStyle(.plain) } } - .padding(.horizontal, 10) + .padding(.horizontal, 12) .padding(.vertical, 7) + // `.search`: a 38pt shelf field, radius 12, no keyline at rest (the + // foil focus ring stays). + .frame(minHeight: 38) .background { - RoundedRectangle(cornerRadius: 9, style: .continuous) + RoundedRectangle(cornerRadius: 12, style: .continuous) .fill(theme.palette.raised) .overlay { - RoundedRectangle(cornerRadius: 9, style: .continuous) - .strokeBorder(filterFocused ? theme.palette.accent.opacity(0.7) : theme.palette.hairline, + RoundedRectangle(cornerRadius: 12, style: .continuous) + .strokeBorder(filterFocused ? theme.palette.accent.opacity(0.7) : .clear, lineWidth: 1) } } diff --git a/AppleNugs/Views/ClearQueueConfirmation.swift b/AppleNugs/Views/ClearQueueConfirmation.swift new file mode 100644 index 0000000..6711b96 --- /dev/null +++ b/AppleNugs/Views/ClearQueueConfirmation.swift @@ -0,0 +1,28 @@ +import SwiftUI + +/// The one question every clear-queue path asks (Tim's ruling P2): the +/// now-playing ⋯ menu, the dashboard's "Clear", and Playback ▸ Clear Queue on +/// the Mac. The words live here so the SwiftUI dialog below and the Mac menu's +/// AppKit alert (`ClearQueueAlert`) can't drift apart. The message says what +/// `PlayerService.clear()` does: it stops playback and empties the queue. +enum ClearQueueCopy { + static let title = "Clear the queue?" + static let message = "Playback stops and the whole queue empties." + static let confirm = "Clear Queue" + static let cancel = "Cancel" +} + +extension View { + /// The in-window form of the question, for surfaces that own a view to + /// present from (the dashboard and the now-playing ⋯ menu). + func clearQueueConfirmation(isPresented: Binding, + onConfirm: @escaping () -> Void) -> some View { + confirmationDialog(ClearQueueCopy.title, isPresented: isPresented, + titleVisibility: .visible) { + Button(ClearQueueCopy.confirm, role: .destructive, action: onConfirm) + Button(ClearQueueCopy.cancel, role: .cancel) {} + } message: { + Text(ClearQueueCopy.message) + } + } +} diff --git a/AppleNugs/Views/ConnectionFailedView.swift b/AppleNugs/Views/ConnectionFailedView.swift new file mode 100644 index 0000000..3bb294e --- /dev/null +++ b/AppleNugs/Views/ConnectionFailedView.swift @@ -0,0 +1,79 @@ +import SwiftUI + +/// The "Couldn't reach nugs.net" screen, shared by both scene roots. The roots +/// keep the offline Downloads sheet; this view only raises it. +/// +/// Plain values in, closures out (no `AppModel`), so the offscreen harness can +/// render it. +/// +/// Two gates, deliberately different: +/// - `hasDownloads` shows "Listen offline" — today's gate, unchanged. Tightening +/// it to the 30-day window would hide the user's downloads entirely once +/// verification lapses; the expired case is already explained in the sheet. +/// - `canPlayOffline` (downloads exist AND `OfflineAccessPolicy.allowsOfflinePlay`) +/// shows Yeti's reassurance line, so it never promises what won't happen. +struct ConnectionFailedView: View { + @Environment(\.theme) private var theme + + let message: String + let hasDownloads: Bool + let canPlayOffline: Bool + let onRetry: () -> Void + let onListenOffline: () -> Void + let onSignOut: () -> Void + + /// The surfaces sheet's connection figure: the small empty pack, a + /// sentence a person can act on, the raw error last in quiet monospace, + /// one snow primary. The Mac centers the same column in the window. + var body: some View { + VStack(spacing: 0) { + // `.card.pack.sm { width: 156px }` + BlisterPack(emptyStyle: .small) + .frame(width: 156) + .accessibilityHidden(true) + Text("Couldn't reach nugs.net") + .font(theme.type.title(24)) + .foregroundStyle(theme.palette.textPrimary) + .multilineTextAlignment(.center) + .padding(.top, 22) + if canPlayOffline { + Text("Your downloads still play while you're offline.") + .font(theme.type.body(15)) + .foregroundStyle(theme.palette.textSecondary) + .frame(maxWidth: 290) + .padding(.top, 6) + } + Text(message) + .font(.system(size: 12.5, design: .monospaced)) + .foregroundStyle(theme.palette.textIdle) + .frame(maxWidth: 290) + .padding(.top, 10) + VStack(spacing: 10) { + Button(action: onRetry) { + Text("Try again").frame(maxWidth: .infinity) + } + .buttonStyle(.yetiPrimary) + if hasDownloads { + Button(action: onListenOffline) { + Text("Listen offline").frame(maxWidth: .infinity) + } + .buttonStyle(.yetiSecondary) + } + Button(action: onSignOut) { + Text("Sign out") + .font(.system(size: 17, weight: .heavy, design: .rounded)) + .foregroundStyle(theme.palette.textSecondary) + .frame(maxWidth: .infinity, minHeight: 40) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + } + .controlSize(.large) + .frame(maxWidth: 300) + .padding(.top, 24) + } + .multilineTextAlignment(.center) + .padding(.horizontal, 28) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } +} diff --git a/AppleNugs/Views/DashboardPanel.swift b/AppleNugs/Views/DashboardPanel.swift index 42d44f5..3fadec2 100644 --- a/AppleNugs/Views/DashboardPanel.swift +++ b/AppleNugs/Views/DashboardPanel.swift @@ -5,8 +5,8 @@ import SwiftUI /// player's resolved pick + AVFoundation decoder instead of header parsing. /// /// Rendered as a `ScrollView`+`VStack` rather than a `List`: the inspector -/// content updates every frame during playback (elapsed time, buffer-ahead, -/// the equalizer animation), and a `List` is `NSTableView`-backed. Live size +/// content updates every frame during playback (elapsed time, buffer-ahead), +/// and a `List` is `NSTableView`-backed. Live size /// churn inside the inspector's split child re-enters the `NSTableView` /// delegate, which on macOS 26 aborts with `_postWindowNeedsUpdateConstraints` /// when it collides with a window resize / inspector toggle. A `ScrollView` @@ -16,7 +16,6 @@ struct DashboardPanel: View { @Environment(AppModel.self) private var app @Environment(UIState.self) private var ui @Environment(\.theme) private var theme - @Environment(\.artColor) private var artColor /// False when the panel sits BESIDE the full-screen player (iPhone Duo's /// `NowPlayingArrangement`), where the "Now" section would only repeat the @@ -24,6 +23,9 @@ struct DashboardPanel: View { /// Signal section's constant-row-set rule is untouched. var showsNowPlaying: Bool = true + /// The "Clear" button asks first, like the ⋯ menu (ruling P2). + @State private var confirmingClear = false + private var player: PlayerService { app.player } var body: some View { @@ -38,11 +40,7 @@ struct DashboardPanel: View { // hidden while this column shows. Keep the error alone. // Conditional for the same reason as the Mac branch below: // a real failure is rare, not per-tick churn. - Label(error, systemImage: "exclamationmark.triangle") - .font(.caption) - .foregroundStyle(.red) - .lineLimit(2) - .fixedSize(horizontal: false, vertical: true) + PlaybackErrorLine(message: error) } qualitySection queueSection @@ -54,14 +52,12 @@ struct DashboardPanel: View { .background(theme.palette.base) } - /// Section headers in the theme's display face; condensed themes track them - /// out as letterpress small caps. + /// Section headers are the design's barker headers: a real section header + /// in snow ("Here it comes"), not a small gray label. private func sectionHeader(_ text: String) -> some View { - let condensed = theme.caps.contains(.condensedHeaders) - return Text(condensed ? text.uppercased() : text) - .font(theme.type.section(12)) - .tracking(condensed ? 1.6 : 0) - .foregroundStyle(theme.palette.textSecondary) + Text(text) + .font(theme.type.section(17)) + .foregroundStyle(theme.palette.textPrimary) } // --- now playing --------------------------------------------------------- @@ -69,11 +65,11 @@ struct DashboardPanel: View { @ViewBuilder private var nowPlayingSection: some View { VStack(alignment: .leading, spacing: 6) { - sectionHeader(theme.copy.dashHeaders.now) + sectionHeader(theme.copy.dashNow) #if os(iOS) legacyNowPlayingBlock #else - // The Mac inspector gets the per-theme miniplayer. iOS keeps the + // The Mac inspector gets the miniplayer. iOS keeps the // text block above: it reaches this panel as a sheet from the // full-screen players, which already carry their own transport. DashboardMiniPlayer() @@ -85,11 +81,7 @@ struct DashboardPanel: View { // error). If it ever does bother us, the fix is to fold the error // into the Signal section as an extra row, not to reserve a gap. if let error = player.playbackError { - Label(error, systemImage: "exclamationmark.triangle") - .font(.caption) - .foregroundStyle(.red) - .lineLimit(2) - .fixedSize(horizontal: false, vertical: true) + PlaybackErrorLine(message: error) } #endif } @@ -102,7 +94,7 @@ struct DashboardPanel: View { if let track = player.current { VStack(alignment: .leading, spacing: 3) { Text(track.title ?? "Unknown track") - .font(theme.type.title(15)) + .font(theme.type.title(20)) .lineLimit(1) .truncationMode(.tail) NowPlayingMetaText(track: track, fields: [.artist]) @@ -121,13 +113,8 @@ struct DashboardPanel: View { } .frame(maxWidth: .infinity, alignment: .leading) .padding(8) - .background(Color.clear.artWash(theme.washStyle, color: artColor)) if let error = player.playbackError { - Label(error, systemImage: "exclamationmark.triangle") - .font(.caption) - .foregroundStyle(.red) - .lineLimit(2) - .fixedSize(horizontal: false, vertical: true) + PlaybackErrorLine(message: error) } } else { Text(theme.copy.dashboardIdle) @@ -159,7 +146,15 @@ struct DashboardPanel: View { private var qualitySection: some View { if player.current != nil { VStack(alignment: .leading, spacing: 6) { - sectionHeader(theme.copy.dashHeaders.quality) + // The price sticker rides the quality header. This whole + // section is already gated on `player.current`, and the + // sticker holds its frame when a known lossy pick hides it, + // so the header never changes height. + HStack { + sectionHeader(theme.copy.dashQuality) + Spacer(minLength: 8) + PriceSticker(player: player, size: Self.headerStickerSize) + } VStack(spacing: 5) { ForEach(SignalReadout.rows(format: player.nowPick?.format, platformId: player.nowPick?.platformId, @@ -174,19 +169,30 @@ struct DashboardPanel: View { } } + /// The design draws the Mac inspector's sticker at 44pt and the iOS + /// sheet's at 38pt (the nearest size the sticker comes in is 40). + #if os(macOS) + private static let headerStickerSize: PriceSticker.Size = .s44 + #else + private static let headerStickerSize: PriceSticker.Size = .s40 + #endif + // --- queue ------------------------------------------------------------------- @ViewBuilder private var queueSection: some View { VStack(alignment: .leading, spacing: 6) { HStack { - sectionHeader(theme.copy.dashHeaders.upNext) + sectionHeader(theme.copy.dashUpNext) Spacer() if !player.queue.isEmpty { - Button("Clear") { player.clear() } + Button("Clear") { confirmingClear = true } .buttonStyle(.plain) .font(.caption) .foregroundStyle(theme.palette.textSecondary) + .clearQueueConfirmation(isPresented: $confirmingClear) { + player.clear() + } } } if player.queue.isEmpty { @@ -203,6 +209,24 @@ struct DashboardPanel: View { } } + /// The row's title. The playing row prints heavy in sale yellow + /// (`playState`); the rest keep the inherited text color. + @ViewBuilder + private func queueTitle(_ track: QueueTrack, isCurrent: Bool) -> some View { + let title = Text(track.title ?? "Unknown track") + .font(.caption) + .lineLimit(1) + .truncationMode(.tail) + if isCurrent { + title + .fontWeight(.heavy) + .foregroundStyle(theme.palette.playState) + } else { + title + .fontWeight(.regular) + } + } + private func queueRow(_ i: Int, _ track: QueueTrack) -> some View { HStack(spacing: 6) { Button { @@ -210,26 +234,16 @@ struct DashboardPanel: View { } label: { HStack(spacing: 6) { if i == player.index { - if theme.caps.contains(.equalizerRows) { - EqualizerBars(isPlaying: player.isPlaying) - .frame(width: 16) - } else { - Image(systemName: player.isPlaying ? "speaker.wave.2.fill" : "pause.fill") - .font(.caption2) - .foregroundStyle(theme.activeEmphasis(art: artColor)) - .frame(width: 16) - } + // The static row sticker marks the playing slot. + RowSticker(isPlaying: player.isPlaying, size: .s16) + .frame(width: 16) } else { Text(String(i + 1)) .font(theme.type.numeric(10)) .foregroundStyle(theme.palette.textSecondary) .frame(width: 16) } - Text(track.title ?? "Unknown track") - .font(.caption) - .lineLimit(1) - .truncationMode(.tail) - .fontWeight(i == player.index ? .semibold : .regular) + queueTitle(track, isCurrent: i == player.index) Spacer(minLength: 0) } .contentShape(Rectangle()) @@ -256,6 +270,49 @@ struct DashboardPanel: View { // MARK: - rows +/// A playback error, as the dashboard shows it: said in snow with a slush mark +/// on a shelf block (errors are said, not alarmed). Reads no player state. +private struct PlaybackErrorLine: View { + @Environment(\.theme) private var theme + + let message: String + + /// The offline-expiry error's "why" line (`OfflineAccessPolicy.detail`). + /// Part of this conditional error block — never a Signal + /// row, so the Signal section's constant row set is untouched. + private var detail: String? { OfflineAccessPolicy.detail(forError: message) } + + var body: some View { + HStack(alignment: .firstTextBaseline, spacing: 8) { + Image(systemName: "exclamationmark.triangle") + .foregroundStyle(theme.palette.textSecondary) + .accessibilityHidden(true) + VStack(alignment: .leading, spacing: 2) { + Text(message) + .fontWeight(.semibold) + .foregroundStyle(theme.palette.textPrimary) + .lineLimit(2) + .fixedSize(horizontal: false, vertical: true) + if let detail { + Text(detail) + .foregroundStyle(theme.palette.textSecondary) + .fixedSize(horizontal: false, vertical: true) + } + } + } + .font(.caption) + .padding(.horizontal, 10) + .padding(.vertical, 8) + .frame(maxWidth: .infinity, alignment: .leading) + .background(theme.palette.raised, in: RoundedRectangle(cornerRadius: 10, style: .continuous)) + .overlay { + RoundedRectangle(cornerRadius: 10, style: .continuous) + .strokeBorder(theme.palette.hairline, lineWidth: 1) + } + .accessibilityElement(children: .combine) + } +} + /// A label/value row. The label keeps its width; the value truncates so the row /// can never demand more width than the inspector column. /// @@ -311,7 +368,7 @@ private struct ElapsedTimeLine: View { // Always rendered. `duration` is zeroed on every track change, so // hiding the line while it's unknown made the block above the Signal // section shrink and grow on each track — the same defect one section - // up. `--:--` is the clock placeholder used by MiniPlayerClock. + // up. `--:--` is the clock placeholder used by MiniPlayerScrubTrack. let known = app.player.duration > 0 Text(known ? "\(TransportBar.format(seconds: app.player.currentTime)) / \(TransportBar.format(seconds: app.player.duration))" diff --git a/AppleNugs/Views/DownloadsView.swift b/AppleNugs/Views/DownloadsView.swift index 320bf04..626df6e 100644 --- a/AppleNugs/Views/DownloadsView.swift +++ b/AppleNugs/Views/DownloadsView.swift @@ -12,11 +12,9 @@ struct DownloadsView: View { var body: some View { Group { if app.downloads.manifest.shows.isEmpty { - ContentUnavailableView { - Label("Nothing downloaded", systemImage: "arrow.down.circle") - } description: { - Text("Download a show from its page to keep it for offline listening.") - } + // The empty pack, nothing boxed up yet. + EmptyPackMessage(title: theme.copy.downloadsEmptyTitle, + message: Self.emptyMessage) } else { VStack(spacing: 0) { List { @@ -27,19 +25,17 @@ struct DownloadsView: View { } .listStyle(.inset) .scrollContentBackground(.hidden) - // The footer below the list must clear the iPhone Duo - // overlay pill too, so the VStack is padded instead. - .reportsScrollForPillDuck(insetsContent: false) storageFooter } - .clearsPillOverlay() } } .frame(maxWidth: .infinity, maxHeight: .infinity) .background(theme.palette.base) } + private static let emptyMessage = "Download a show from its page to keep it for offline listening." + /// "N shows · X GB" — the manifest is the single source of truth, so the /// line always matches what the list renders. private var storageFooter: some View { @@ -76,7 +72,8 @@ struct DownloadsView: View { Image(systemName: "play.circle") .font(.system(size: 22)) - .foregroundStyle(theme.palette.accent) + // A play glyph isn't time moving: snow. + .foregroundStyle(theme.palette.textPrimary) } .contentShape(Rectangle()) } diff --git a/AppleNugs/Views/FavoritesView.swift b/AppleNugs/Views/FavoritesView.swift index d63e57a..c162c0c 100644 --- a/AppleNugs/Views/FavoritesView.swift +++ b/AppleNugs/Views/FavoritesView.swift @@ -23,16 +23,13 @@ struct FavoritesView: View { .frame(maxWidth: .infinity, alignment: .leading) } } - .reportsScrollForPillDuck() .background(theme.palette.base) .navigationTitle("Favorites") } private func sectionTitle(_ text: String) -> some View { - let condensed = theme.caps.contains(.condensedHeaders) - return Text(condensed ? text.uppercased() : text) + Text(text) .font(theme.type.section(17)) - .tracking(condensed ? 1.4 : 0) .foregroundStyle(theme.palette.textPrimary) } @@ -56,6 +53,11 @@ struct FavoritesView: View { RoundedRectangle(cornerRadius: 9, style: .continuous) .fill(theme.palette.raised) } + // Chips on the shelf with a frost keyline, as on Home. + .overlay { + RoundedRectangle(cornerRadius: 9, style: .continuous) + .strokeBorder(theme.palette.hairline, lineWidth: 1) + } .contentShape(RoundedRectangle(cornerRadius: 9, style: .continuous)) } .buttonStyle(.plain) @@ -123,17 +125,9 @@ struct FavoritesView: View { eventStart: nil, has4K: false) } + /// The empty pack. private var emptyState: some View { - VStack(spacing: 8) { - Image(systemName: "star") - .font(.system(size: 34)) - .foregroundStyle(theme.palette.accent) - Text("Nothing saved yet") - .font(theme.type.hero(22)) - .foregroundStyle(theme.palette.textPrimary) - Text("Star an artist, show, or video to keep it here.") - .font(theme.type.body(13)) - .foregroundStyle(theme.palette.textSecondary) - } + EmptyPackMessage(title: "Nothing saved yet", + message: "Star an artist, show, or video to keep it here.") } } diff --git a/AppleNugs/Views/HomeResumeHero.swift b/AppleNugs/Views/HomeResumeHero.swift new file mode 100644 index 0000000..f3fc352 --- /dev/null +++ b/AppleNugs/Views/HomeResumeHero.swift @@ -0,0 +1,213 @@ +import SwiftUI + +// Home's "what's playing" heroes, plus the progress rule every Home +// hero shares. Shared code: `HomeView` mounts the iPhone hero on iOS and the +// flat resume card on macOS, but both views take plain values (no +// `PlayerService`), so either compiles anywhere and an offscreen render can +// build them as-is. + +extension EnvironmentValues { + /// Opens the full-screen now-playing cover. Set once by `IOSRootView` on + /// its `TabView`, wired to the same `nowPlayingPresented` state the pill's + /// tap sets — so the Home hero reuses the one presentation instead of + /// adding a second. Nil (a no-op) everywhere else, macOS included. + @Entry var openNowPlaying: OpenNowPlayingAction? = nil +} + +/// The `openNowPlaying` environment action, `OpenURLAction`-style: call it. +/// +/// Equatable and always equal, deliberately: there is exactly one owner of the +/// cover and the action always does the same thing (presents it), so a fresh +/// closure on each `IOSRootView` update is not a new value — without this, +/// every update would invalidate every reader of the key. +struct OpenNowPlayingAction: Equatable, Sendable { + private let open: @MainActor @Sendable () -> Void + + init(_ open: @escaping @MainActor @Sendable () -> Void) { + self.open = open + } + + @MainActor func callAsFunction() { open() } + + static func == (_: Self, _: Self) -> Bool { true } +} + +// MARK: - Progress rule + +/// The hero's thin progress rule: a hairline track with the elapsed part in +/// the accent (foil — set explicitly, since the root tint is snow). +/// +/// A LEAF view: the ~4Hz playback-time dependency registers here, not on the +/// whole editorial landing page or the hero around it (CLAUDE.md, the +/// leaf-view rule). Always 3pt tall, whatever the time reads. +struct ResumeProgressRule: View { + @Environment(AppModel.self) private var app + @Environment(\.theme) private var theme + + /// 360 on the Mac card (`.resume .prog`), unbounded in the iPhone hero's + /// name panel. + let maxWidth: CGFloat + + var body: some View { + GeometryReader { geo in + ZStack(alignment: .leading) { + Capsule().fill(theme.palette.hairline) + Capsule().fill(theme.palette.accent) + .frame(width: geo.size.width * fraction) + } + } + .frame(height: 3) + .frame(maxWidth: maxWidth, alignment: .leading) + .accessibilityHidden(true) + } + + private var fraction: CGFloat { + let player = app.player + guard player.duration > 0 else { return 0 } + return min(max(CGFloat(player.currentTime / player.duration), 0), 1) + } +} + +// MARK: - iPhone hero + +/// The iPhone Home hero (Tim's ruling 1: the Mac's resume +/// card, ported, on the peg): the cover in the bubble on card stock with the +/// price sticker on its corner; the title, meta and a foil progress rule +/// printed on the card; and a 54pt play sticker beside them. Translated from +/// `yeti-decisions.html` §1 option A (`.hero-card`). No foil sweep — that +/// lives only on the now-playing card and the Mac miniplayer. +/// +/// Two targets: the sticker toggles playback; anywhere else on the card opens +/// the full-screen player. The sticker is a real `Button` nested inside the +/// card's tap gesture — the pill's verified pattern: the nested button wins +/// hit-testing, so a sticker tap never also opens the player. The meta line's +/// artist and show are real links too (`tappableText` on, as on the pill and +/// the Mac hero, by Tim's call). +struct YetiHomeHero: View { + @Environment(\.theme) private var theme + + let track: QueueTrack + let image: PlatformImage? + let isPlaying: Bool + let isBuffering: Bool + /// The corner sticker's label; pass `PriceStickerLabel(player:)`. + let price: PriceStickerLabel? + let onToggle: () -> Void + let onOpen: () -> Void + + /// `.hero-card .bubble { width: 186px }`. + static let bubbleSide: CGFloat = 186 + + var body: some View { + BlisterPack(style: .hero, + price: price, + // The sheet's full 62pt sticker; the auto size would pick + // 44 for a bubble this size. + priceSize: .s62, + maxBubbleSide: Self.bubbleSide) { + PackArt(image: image, monogram: track.artist ?? track.title ?? "?") + } below: { + // `.hero-card .name`: text column + sticker, bottoms aligned. + HStack(alignment: .bottom, spacing: 12) { + VStack(alignment: .leading, spacing: 0) { + Text(track.title ?? "Unknown track") + .font(theme.type.hero(28)) + .foregroundStyle(theme.palette.textPrimary) + .lineLimit(2) + .fixedSize(horizontal: false, vertical: true) + NowPlayingMetaText(track: track) + .font(theme.type.body(15)) + .foregroundStyle(theme.palette.textSecondary) + .lineLimit(1) + .padding(.top, 6) + ResumeProgressRule(maxWidth: .infinity) + .padding(.top, 10) + } + .frame(maxWidth: .infinity, alignment: .leading) + + HapticButton(.transportToggle, action: onToggle) { + PlaySticker(isPlaying: isPlaying, isBuffering: isBuffering, size: .s54) + } + .buttonStyle(.plain) + .accessibilityLabel(isPlaying ? "Pause" : "Play") + .accessibilityValue(isBuffering ? "Buffering" : "") + } + } + .contentShape(RoundedRectangle(cornerRadius: BlisterCard.cornerRadius, style: .continuous)) + .onTapGesture(perform: onOpen) + // The pill's pattern: a container, so the play sticker and the meta + // links stay their own elements, that itself says what's playing and + // carries the open action. The action, label and hint all come AFTER + // `.accessibilityElement(children: .contain)` — applied before it they + // land on the pre-container element and are dropped. + .accessibilityElement(children: .contain) + .accessibilityAction(named: "Open now playing", onOpen) + .accessibilityLabel(accessibilityLabelText) + .accessibilityHint("Opens full-screen now playing") + } + + private var accessibilityLabelText: String { + let title = track.title ?? "Unknown track" + guard let artist = track.artist else { return "Now playing: \(title)" } + return "Now playing: \(title), \(artist)" + } +} + +// MARK: - Mac resume card + +/// The macOS Home resume card (Tim's ruling 4): card +/// stock with a FLAT 96pt cover — no bubble (that stays special to the +/// miniplayer and the Now Playing window, so an open dashboard never shows +/// two bubbles of one cover) and no hang slot (`.resume::before { display: +/// none }`) — no "CONTINUE LISTENING" eyebrow, a foil progress rule and a +/// 54pt play sticker. From `yeti-surfaces.html` "Mac Home" (`.card.resume`). +/// +/// The whole card toggles playback. +struct YetiResumeCard: View { + @Environment(\.theme) private var theme + + let track: QueueTrack + let image: PlatformImage? + let isPlaying: Bool + let isBuffering: Bool + let onToggle: () -> Void + + var body: some View { + let shape = RoundedRectangle(cornerRadius: BlisterCard.cornerRadius, style: .continuous) + Button(action: onToggle) { + HStack(spacing: 20) { + ArtChip(image: image, + fallbackText: track.artist ?? track.title ?? "?", + size: 96) + + VStack(alignment: .leading, spacing: 4) { + Text(track.title ?? "Unknown track") + .font(theme.type.hero(26)) + .foregroundStyle(theme.palette.textPrimary) + .lineLimit(1) + NowPlayingMetaText(track: track) + .font(theme.type.body(14)) + .foregroundStyle(theme.palette.textSecondary) + .lineLimit(1) + ResumeProgressRule(maxWidth: 360) + .padding(.top, 8) + } + Spacer(minLength: 8) + PlaySticker(isPlaying: isPlaying, isBuffering: isBuffering, size: .s54) + } + .padding(.vertical, 20) + .padding(.horizontal, 22) + .background { + shape + .fill(theme.palette.cardStock) + .overlay { shape.strokeBorder(theme.palette.hairline, lineWidth: 1) } + .stickerLift(BlisterCard.lift, in: shape) + } + .contentShape(shape) + // The whole card is the button, so the card takes the outline. + .foilFocusOutline(shape) + } + .buttonStyle(.plain) + .macFocusEffectDisabled() + } +} diff --git a/AppleNugs/Views/HomeView.swift b/AppleNugs/Views/HomeView.swift index 419af3b..9ceacaa 100644 --- a/AppleNugs/Views/HomeView.swift +++ b/AppleNugs/Views/HomeView.swift @@ -4,18 +4,14 @@ import SwiftUI /// a time-aware greeting, favorites, and a taste of the crate to invite /// digging. /// -/// On macOS it also carries a glowing "continue listening" hero and two entry -/// tiles. Both are desktop-only by design — see `resumeCard` and `entryRow`. +/// Both platforms carry a "continue listening" hero while a track is loaded: +/// on iPhone the blister pack (Tim's ruling 1), on the Mac the flat card-stock +/// `YetiResumeCard` (ruling 4); both live in `HomeResumeHero.swift`. The Mac +/// also gets two entry tiles (`entryRow`), desktop-only by design. struct HomeView: View { @Environment(AppModel.self) private var app @Environment(UIState.self) private var ui @Environment(\.theme) private var theme - #if os(macOS) - /// Only the resume hero reads the art color. Left unguarded, this would - /// re-render Home on every track change on iPhone, where the hero no - /// longer exists. - @Environment(\.artColor) private var artColor - #endif @State private var sample: [ArtistEntry] = [] @State private var artistCount = 0 @@ -25,9 +21,11 @@ struct HomeView: View { /// the crate" is a worse lie than a spinner nobody sees. @State private var loadingSample = true - #if os(macOS) private var player: PlayerService { app.player } - #endif + + /// Whether a hero renders at all. This body reads `current` and nothing + /// else of the player; the hero host reads the rest. + private var showsHero: Bool { player.current != nil } /// The rest of the app pads to 16–20; 36 is a wide-window luxury. #if os(iOS) @@ -40,9 +38,7 @@ struct HomeView: View { ScrollView { VStack(alignment: .leading, spacing: 30) { greeting.reveal(appeared, 0) - #if os(macOS) - if player.current != nil { resumeCard.reveal(appeared, 1) } - #endif + if showsHero { YetiHeroHost().reveal(appeared, 1) } if !app.favorites.isEmpty { favoritesStrip.reveal(appeared, 2) } #if os(macOS) entryRow.reveal(appeared, 3) @@ -58,7 +54,6 @@ struct HomeView: View { .frame(maxWidth: 880, alignment: .leading) .frame(maxWidth: .infinity, alignment: .leading) } - .reportsScrollForPillDuck() .background(theme.palette.base) .navigationTitle("Home") .compactNavigationTitle() @@ -68,125 +63,45 @@ struct HomeView: View { // --- greeting ----------------------------------------------------------- + /// No eyebrow: the greeting stands alone. private var greeting: some View { VStack(alignment: .leading, spacing: 8) { - Text("YETI") - .font(theme.type.numeric(11)) - .tracking(3) - .foregroundStyle(theme.palette.accent) Text(greetingText) - .font(theme.type.hero(42)) + .font(theme.type.hero(greetingSize)) .foregroundStyle(theme.palette.textPrimary) - Text("Pick up where you left off — or dig through the crate.") - .font(theme.type.title(16)) + Text(theme.copy.homeLede) + .font(theme.type.body(16)) .foregroundStyle(theme.palette.textSecondary) } } + /// 42 on the Mac; the iPhone sets the greeting at the large-title size + /// (`.large-title { font-size: 34px }`) to leave the hero room above the + /// fold. + private var greetingSize: CGFloat { + #if os(iOS) + 34 + #else + 42 + #endif + } + + /// Said plainly — "Good evening", no period — but "Late set." is a + /// sentence, and keeps its stop. private var greetingText: String { switch Calendar.current.component(.hour, from: Date()) { - case 5..<12: return "Good morning." - case 12..<17: return "Good afternoon." - case 17..<22: return "Good evening." + case 5..<12: return "Good morning" + case 12..<17: return "Good afternoon" + case 17..<22: return "Good evening" default: return "Late set." } } - // --- continue listening (the hero) -------------------------------------- - - // macOS only. On iPhone the mini-player docks above the tab bar whenever a - // track is loaded — which, because the queue is restored at launch, is - // exactly whenever this hero would render. It showed the same art, title, - // meta and progress twice on one screen and pushed the crate below the - // fold. Resume lives in the dock there; Home gets the space back. - #if os(macOS) - private var resumeArtSize: CGFloat { 96 } - private var resumeTitleSize: CGFloat { 24 } - private var resumeGlyphSize: CGFloat { 48 } - - @ViewBuilder - private var resumeCard: some View { - if let track = player.current { - Button { - player.togglePlayPause() - } label: { - HStack(spacing: 18) { - ArtChip(image: player.nowPlayingImage, - fallbackText: track.artist ?? track.title ?? "?", - size: resumeArtSize) - .shadow(color: (artColor ?? theme.palette.accent).opacity(0.45), radius: 22, y: 6) - - VStack(alignment: .leading, spacing: 5) { - Text("CONTINUE LISTENING") - .font(theme.type.numeric(10)) - .tracking(1.8) - .foregroundStyle(theme.palette.textSecondary) - Text(track.title ?? "Unknown track") - .font(theme.type.hero(resumeTitleSize)) - .foregroundStyle(theme.palette.textPrimary) - .lineLimit(1) - NowPlayingMetaText(track: track) - .font(theme.type.title(14)) - .foregroundStyle(theme.palette.textSecondary) - .lineLimit(1) - ResumeProgressRule().padding(.top, 6) - } - Spacer(minLength: 8) - Image(systemName: player.isPlaying ? "pause.circle.fill" : "play.circle.fill") - .font(.system(size: resumeGlyphSize)) - .foregroundStyle(theme.effectiveAccent(art: artColor)) - .symbolRenderingMode(.hierarchical) - } - .padding(20) - .background { - RoundedRectangle(cornerRadius: 16, style: .continuous) - .fill(theme.palette.raised) - .overlay { - RoundedRectangle(cornerRadius: 16, style: .continuous) - .fill(.clear) - .artWash(theme.washStyle, color: artColor) - } - .overlay { - RoundedRectangle(cornerRadius: 16, style: .continuous) - .strokeBorder(theme.palette.hairline, lineWidth: 1) - } - } - .contentShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) - } - .buttonStyle(.plain) - } - } - - /// A leaf view: the 4Hz currentTime dependency registers here, not on - /// the whole editorial landing page. - private struct ResumeProgressRule: View { - @Environment(AppModel.self) private var app - @Environment(\.theme) private var theme - @Environment(\.artColor) private var artColor - - var body: some View { - GeometryReader { geo in - ZStack(alignment: .leading) { - Capsule().fill(theme.palette.hairline) - Capsule().fill(theme.effectiveAccent(art: artColor)) - .frame(width: geo.size.width * fraction) - } - } - .frame(height: 3) - .frame(maxWidth: 320, alignment: .leading) - } - - private var fraction: CGFloat { - let player = app.player - guard player.duration > 0 else { return 0 } - return min(max(CGFloat(player.currentTime / player.duration), 0), 1) - } - } - // --- entry points ------------------------------------------------------- - // macOS only, for the same reason as the hero: on iPhone these two tiles - // re-open Artists and Search, which the tab bar already puts one tap away. + // macOS only: on iPhone these two tiles re-open Artists and Search, which + // the tab bar already puts one tap away. + #if os(macOS) private var entryRow: some View { HStack(spacing: 14) { EntryTile(icon: "music.mic", @@ -205,15 +120,15 @@ struct HomeView: View { // --- from the crate ----------------------------------------------------- - /// On iPhone this is the only section that always renders — no resume hero, - /// no entry tiles, and favorites may well be empty. So it has to account for + /// On iPhone this is the only section that always renders — no entry + /// tiles, the hero only while a track is loaded, and favorites may well be + /// empty. So it has to account for /// itself when it's loading, empty, or offline, or a new user's Home is a /// greeting alone on a blank screen, promising a crate that never arrives. private var crate: some View { VStack(alignment: .leading, spacing: 14) { - Text(theme.caps.contains(.condensedHeaders) ? "FROM THE CRATE" : "From the crate") - .font(theme.type.section(15)) - .tracking(theme.caps.contains(.condensedHeaders) ? 1.6 : 0) + Text("From the crate") + .font(theme.type.section(sectionSize)) .foregroundStyle(theme.palette.textPrimary) if !sample.isEmpty { @@ -275,19 +190,21 @@ struct HomeView: View { } } + /// Section headers: `.sec h3`'s heavy 22. + private var sectionSize: CGFloat { 22 } + // --- favorites strip ---------------------------------------------------- private var favoritesStrip: some View { VStack(alignment: .leading, spacing: 14) { HStack { - Text(theme.caps.contains(.condensedHeaders) ? "FAVORITES" : "Favorites") - .font(theme.type.section(15)) - .tracking(theme.caps.contains(.condensedHeaders) ? 1.6 : 0) + Text("Favorites") + .font(theme.type.section(sectionSize)) .foregroundStyle(theme.palette.textPrimary) Spacer() - Button("See all ›") { ui.sidebarSelection = .favorites } + Button("See all") { ui.sidebarSelection = .favorites } .buttonStyle(.plain) - .font(theme.type.body(12)) + .font(theme.type.body(14)) .foregroundStyle(theme.palette.textSecondary) } @@ -298,7 +215,7 @@ struct HomeView: View { NavigationLink(value: Route.artist(ArtistEntry(id: fav.id, name: fav.name))) { HStack { Text(fav.name) - .font(theme.type.body(13)) + .font(theme.type.body(13.5).weight(.bold)) .foregroundStyle(theme.palette.textPrimary) .lineLimit(1) Spacer(minLength: 0) @@ -308,6 +225,11 @@ struct HomeView: View { .background { RoundedRectangle(cornerRadius: 9, style: .continuous) .fill(theme.palette.raised) + // `.chips span`: shelf with a frost keyline. + .overlay { + RoundedRectangle(cornerRadius: 9, style: .continuous) + .strokeBorder(theme.palette.hairline, lineWidth: 1) + } } .contentShape(RoundedRectangle(cornerRadius: 9, style: .continuous)) } @@ -346,6 +268,38 @@ struct HomeView: View { } } +// MARK: - Yeti hero host + +/// Reads the player for the Home hero and hands the hero plain values, so +/// `isPlaying`, the cover, the pick and the specs subscribe this small view — +/// not the greeting, favorites strip, ledger and crate around it. (Playback +/// time is read one level further down still, in `ResumeProgressRule`.) +private struct YetiHeroHost: View { + @Environment(AppModel.self) private var app + @Environment(\.openNowPlaying) private var openNowPlaying + + var body: some View { + let player = app.player + if let track = player.current { + #if os(iOS) + YetiHomeHero(track: track, + image: player.nowPlayingImage, + isPlaying: player.isPlaying, + isBuffering: player.isBuffering, + price: PriceStickerLabel(player: player), + onToggle: { player.togglePlayPause() }, + onOpen: { openNowPlaying?() }) + #else + YetiResumeCard(track: track, + image: player.nowPlayingImage, + isPlaying: player.isPlaying, + isBuffering: player.isBuffering, + onToggle: { player.togglePlayPause() }) + #endif + } + } +} + // MARK: - Entry tile #if os(macOS) diff --git a/AppleNugs/Views/ListeningLedger.swift b/AppleNugs/Views/ListeningLedger.swift index 4c5d8ba..2033f7d 100644 --- a/AppleNugs/Views/ListeningLedger.swift +++ b/AppleNugs/Views/ListeningLedger.swift @@ -18,7 +18,13 @@ import SwiftUI /// Every string it renders comes from `LedgerSummary`, so the formatting is /// pinned by tests rather than by this body. Do not format here. /// -/// Shared across macOS and iOS — nothing platform-specific. +/// It is "the nice list": a sentence-case section header, one totals line +/// ("112 hours listened · 9 artists"), and rows of a checked box, the name +/// (which truncates first) and plays / hours in two right-aligned columns; the +/// split numbers come from `LedgerSummary`. +/// +/// Shared across macOS and iOS; the nice list's columns are a little wider on +/// the Mac (the sheet's `.ledger-wide`). struct ListeningLedger: View { @Environment(AppModel.self) private var app @Environment(\.theme) private var theme @@ -36,25 +42,10 @@ struct ListeningLedger: View { let rows = LedgerSummary.top(tallies) let totals = LedgerSummary.totals(tallies) - VStack(alignment: .leading, spacing: 12) { - Text("LISTENING") - .font(theme.type.numeric(10)) - .tracking(1.8) - .foregroundStyle(theme.palette.textSecondary) - - VStack(spacing: 0) { - totalRow(label: "Hours", value: totals.hoursText) - hairline - totalRow(label: "Artists", value: totals.artistCountText) - ForEach(rows) { row in - hairline - leaderRow(row) - } + niceList(rows: rows, totals: totals) + .task(id: rows.map(\.key).joined(separator: "|")) { + await resolveNames(rows) } - } - .task(id: rows.map(\.key).joined(separator: "|")) { - await resolveNames(rows) - } } // --- rows --------------------------------------------------------------- @@ -66,52 +57,97 @@ struct ListeningLedger: View { .frame(height: 1) } - private func totalRow(label: String, value: String) -> some View { - HStack(alignment: .firstTextBaseline, spacing: 12) { - Text(label) - .font(theme.type.body(14)) - .foregroundStyle(theme.palette.textSecondary) - Spacer(minLength: 8) - Text(value) - .font(theme.type.numeric(14)) - .foregroundStyle(theme.palette.textPrimary) - } - .padding(.vertical, 9) - } - - /// Tappable only when the stored display name resolved against the catalog. - /// An unresolved name renders as plain text — never a dead tap target. + /// Wraps a row in a `NavigationLink` only when its name resolved. @ViewBuilder - private func leaderRow(_ row: LedgerSummary.Row) -> some View { + private func linked(_ row: LedgerSummary.Row, + @ViewBuilder content: () -> Content) -> some View { if let entry = resolved[row.key] { NavigationLink(value: Route.artist(entry)) { - leaderRowBody(row) + content() } .buttonStyle(.plain) } else { - leaderRowBody(row) + content() } } - private func leaderRowBody(_ row: LedgerSummary.Row) -> some View { - HStack(alignment: .firstTextBaseline, spacing: 12) { - Text("\(row.rank)") - .font(theme.type.numeric(12)) - .foregroundStyle(theme.palette.textIdle) - .frame(width: 16, alignment: .leading) + // --- the nice list ------------------------------------------------------- + + /// `.nice li { grid-template-columns: 22px 1fr 62px 40px }`, and the Mac's + /// `.nice.ledger-wide` (72/48). Minimum widths, so the columns line up at + /// the default text size and grow rather than clip at larger ones. + #if os(macOS) + private static let playsColumn: CGFloat = 72 + private static let hoursColumn: CGFloat = 48 + private static let rowPadding: CGFloat = 9 + #else + private static let playsColumn: CGFloat = 62 + private static let hoursColumn: CGFloat = 40 + private static let rowPadding: CGFloat = 11 + #endif + + private func niceList(rows: [LedgerSummary.Row], totals: LedgerSummary.Totals) -> some View { + VStack(alignment: .leading, spacing: 0) { + // `.sec h3`: heavy 22, sentence case. + Text(theme.copy.ledgerTitle) + .font(theme.type.section(22)) + .foregroundStyle(theme.palette.textPrimary) + .accessibilityAddTraits(.isHeader) + .padding(.bottom, 6) + + // `.totals`: two figures, 22pt apart. + HStack(alignment: .firstTextBaseline, spacing: 22) { + figureText(totals.hours, valueSize: 17) + figureText(totals.artists, valueSize: 17) + } + .font(theme.type.body(14)) + .foregroundStyle(theme.palette.textSecondary) + .padding(.bottom, 6) + + VStack(spacing: 0) { + ForEach(rows) { row in + hairline + linked(row) { niceRow(row) } + } + if !rows.isEmpty { hairline } + } + } + } + + private func niceRow(_ row: LedgerSummary.Row) -> some View { + HStack(spacing: 12) { + CheckedBox() Text(row.displayName) - .font(theme.type.body(14)) + .font(theme.type.title(16)) .foregroundStyle(theme.palette.textPrimary) .lineLimit(1) - Spacer(minLength: 8) - Text("\(row.playsText) · \(row.hoursText)") - .font(theme.type.numeric(12)) - .foregroundStyle(theme.palette.textSecondary) - .lineLimit(1) - .layoutPriority(1) + .frame(maxWidth: .infinity, alignment: .leading) + // The numbers outrank the name, so a long name truncates first. + Group { + figureText(row.plays, valueSize: 14) + .frame(minWidth: Self.playsColumn, alignment: .trailing) + figureText(row.hours, valueSize: 14) + .frame(minWidth: Self.hoursColumn, alignment: .trailing) + } + .font(theme.type.body(14)) + .foregroundStyle(theme.palette.textSecondary) + .lineLimit(1) + .fixedSize() + .layoutPriority(1) } - .padding(.vertical, 9) + .padding(.vertical, Self.rowPadding) .contentShape(Rectangle()) + .accessibilityElement(children: .combine) + } + + /// "**41** plays": the value in snow at weight 600 (800 and a larger size + /// on the totals line), the unit in the surrounding slush. + private func figureText(_ figure: LedgerSummary.Figure, valueSize: CGFloat) -> Text { + let weight: Font.Weight = valueSize > 14 ? .heavy : .semibold + let value = Text(figure.value) + .font(theme.type.body(valueSize).weight(weight)) + .foregroundStyle(theme.palette.textPrimary) + return Text("\(value) \(figure.unit)") } // --- resolution --------------------------------------------------------- @@ -138,3 +174,21 @@ extension ListeningLedger { } } } + +/// `.box`: a 22pt rounded square outlined in slush, holding a check in snow. +/// Every row on the nice list is checked — being on it is the point. +private struct CheckedBox: View { + @Environment(\.theme) private var theme + + var body: some View { + RoundedRectangle(cornerRadius: 7, style: .continuous) + .strokeBorder(theme.palette.textSecondary, lineWidth: 1.5) + .frame(width: 22, height: 22) + .overlay { + Image(systemName: "checkmark") + .font(.system(size: 11, weight: .heavy)) + .foregroundStyle(theme.palette.textPrimary) + } + .accessibilityHidden(true) + } +} diff --git a/AppleNugs/Views/LoginView.swift b/AppleNugs/Views/LoginView.swift index 6bf5757..48583cc 100644 --- a/AppleNugs/Views/LoginView.swift +++ b/AppleNugs/Views/LoginView.swift @@ -6,49 +6,95 @@ struct LoginView: View { @Environment(AppModel.self) private var app var body: some View { - VStack(spacing: 18) { - VStack(spacing: 6) { - Image(systemName: "waveform.circle.fill") - .font(.system(size: 44)) - .foregroundStyle(.tint) + LoginContent(isLoggingIn: app.isLoggingIn, error: app.loginError) { + Task { await app.loginWithBrowser() } + } + } +} + +/// The sign-in screen itself, from plain values (no `AppModel`), so the +/// offscreen harness can render it. +struct LoginContent: View { + @Environment(\.theme) private var theme + + let isLoggingIn: Bool + let error: String? + let onSignIn: () -> Void + + /// The surfaces sheet's sign-in figure: the EMPTY pack with the "Yeti" + /// nameplate printed on its card (the app before the show arrives), one + /// snow primary, a failure said in snow — never red. The Mac centers the + /// same column in the window. + var body: some View { + VStack(spacing: 0) { + // `.card.pack { width: 210px }`, `.plate-sm { font-weight: 900; + // font-size: 30px; letter-spacing: -.02em }` + BlisterPack(emptyStyle: .regular) { Text("Yeti") - .font(.largeTitle.weight(.semibold)) - Text("An unofficial player for nugs.net") - .foregroundStyle(.secondary) + .font(.system(size: 30, weight: .black, design: .rounded)) + .tracking(-0.6) + .foregroundStyle(theme.palette.textPrimary) + .lineLimit(1) } + .frame(width: 210) + .accessibilityElement(children: .combine) - if let error = app.loginError { + Text("An unofficial player for nugs.net") + .font(theme.type.body(15)) + .foregroundStyle(theme.palette.textSecondary) + .padding(.top, 22) + + if let error { Text(error) - .font(.callout) - .foregroundStyle(.red) - .frame(maxWidth: 360) - .multilineTextAlignment(.center) + .font(theme.type.body(15).weight(.semibold)) + .foregroundStyle(theme.palette.textPrimary) + .frame(maxWidth: 300) + .padding(.top, 10) } Button { - Task { await app.loginWithBrowser() } + // The button stays at full strength while the browser sheet is + // up (it reads "Opening nugs.net…"), so it can't be `.disabled` + // — that would dim it to half. Guard the second tap instead. + guard !isLoggingIn else { return } + onSignIn() } label: { - if app.isLoggingIn { - ProgressView().controlSize(.small).frame(minWidth: 160) - } else { - Label("Sign in with nugs.net", systemImage: "globe") - .frame(minWidth: 160) + Group { + if isLoggingIn { + HStack(spacing: 10) { + ProgressView() + .controlSize(.small) + .tint(theme.palette.base) + // macOS draws its spinner by appearance, not + // tint: a light appearance gives night ink. + .environment(\.colorScheme, .light) + Text("Opening nugs.net…") + } + } else { + Label("Sign in with nugs.net", systemImage: "globe") + } } + .frame(maxWidth: .infinity) } - .buttonStyle(.borderedProminent) - .disabled(app.isLoggingIn) + .buttonStyle(.yetiPrimary) + .controlSize(.large) + .frame(maxWidth: 300) + .padding(.top, 24) + // Not `.disabled` (see the guard above), so say the state instead. + .accessibilityValue(isLoggingIn ? "Opening nugs.net" : "") .accessibilityIdentifier("login.signIn") - VStack(spacing: 4) { + VStack(spacing: 6) { Text("You sign in on nugs.net’s own page; this app never sees your password.") Text("Unofficial. Not affiliated with nugs.net or Apple.") } - .font(.caption) - .foregroundStyle(.secondary) - .frame(maxWidth: 380) - .multilineTextAlignment(.center) + .font(theme.type.body(12.5)) + .foregroundStyle(theme.palette.textIdle) + .frame(maxWidth: 300) + .padding(.top, 14) } - .padding() + .multilineTextAlignment(.center) + .padding(.horizontal, 28) .frame(maxWidth: .infinity, maxHeight: .infinity) } } diff --git a/AppleNugs/Views/NowPlaying/ClickWheelScreen.swift b/AppleNugs/Views/NowPlaying/ClickWheelScreen.swift deleted file mode 100644 index 69860c6..0000000 --- a/AppleNugs/Views/NowPlaying/ClickWheelScreen.swift +++ /dev/null @@ -1,358 +0,0 @@ -import SwiftUI - -/// Click Wheel's now-playing: a monochrome pocket-player homage. Large art -/// card, a compact track card, a thin white progress rule, and a circular -/// control pad — heart up top, prev/next on the ring, mute below, play in the -/// center — ringed by four satellite buttons (queue, AirPlay, ±seek). -struct ClickWheelScreen: View { - @Environment(AppModel.self) private var app - @Environment(\.theme) private var theme - @Environment(\.dismiss) private var dismiss - @Environment(UIState.self) private var ui - @Environment(\.nowPlayingQueueBeside) private var queueBeside - - @State private var scrubbing = false - @State private var scrubValue: Double = 0 - @State private var dashboardShown = false - /// Volume to restore when the mute button toggles back on. - @State private var unmutedVolume: Float = 1 - /// Width offered to the control pad; 0 until the first measurement. - @State private var padWidth: CGFloat = 0 - - private var player: PlayerService { app.player } - - var body: some View { - VStack(spacing: 18) { - header - - Spacer(minLength: 0) - - artwork - .padding(.horizontal, 44) - - trackCard - .padding(.horizontal, 24) - - seekBlock - .padding(.horizontal, 28) - - Spacer(minLength: 0) - - controlPad - .padding(.bottom, 8) - } - .padding(.vertical, 16) - .nowPlayingReadableWidth() - .frame(maxWidth: .infinity, maxHeight: .infinity) - .background { theme.palette.base.ignoresSafeArea() } - .dismissesOnNavigation($dashboardShown, ui: ui) - .sheet(isPresented: $dashboardShown) { - DashboardPanel() - #if os(iOS) - .presentationDetents([.medium, .large]) - #endif - .presentationBackground(theme.palette.base) - } - } - - private var header: some View { - HStack { - Button { - dismiss() - } label: { - Image(systemName: "chevron.down") - .font(.system(size: 16, weight: .semibold)) - .foregroundStyle(theme.palette.textPrimary) - .frame(width: 40, height: 40) - .background(Circle().fill(theme.palette.raised)) - } - .buttonStyle(.plain) - .accessibilityLabel("Close now playing") - - Spacer() - - Text("Now Playing") - .font(theme.type.title(15)) - .foregroundStyle(theme.palette.textPrimary) - - Spacer() - - // Balance the chevron so the title stays centered. - Color.clear.frame(width: 40, height: 40) - } - .padding(.horizontal, 16) - } - - private var artwork: some View { - ZStack { - if let image = player.nowPlayingImage { - Image(platformImage: image) - .resizable() - .aspectRatio(contentMode: .fit) - } else { - // The monogram scales with the box it is given, capped at its - // old fixed 260pt — the same flexibility the real-art Image - // above already has (and the fix StandardNowPlayingScreen got). - // A rigid 260×260 tile can't shrink: on iPhone Duo's ~669pt- - // tall landscape inner display it pushed the header off the - // top and the wheel's ±15/30 satellites off the bottom. The - // ideal size keeps 260 for unconstrained (nil) proposals. - GeometryReader { geo in - MonogramTile( - text: player.current?.artist ?? player.current?.title ?? "?", - size: min(geo.size.width, geo.size.height)) - .frame(width: geo.size.width, height: geo.size.height) - } - .frame(idealWidth: 260, maxWidth: 260, idealHeight: 260, maxHeight: 260) - } - } - .frame(maxWidth: .infinity) - .aspectRatio(1, contentMode: .fit) - .clipShape(RoundedRectangle(cornerRadius: 10, style: .continuous)) - .shadow(color: .black.opacity(0.5), radius: 24, y: 10) - } - - /// The compact "artist / bold title" card from the reference, with the - /// favorites star standing in for its Follow pill. - private var trackCard: some View { - HStack(spacing: 10) { - ArtChip(image: player.nowPlayingImage, - fallbackText: player.current?.artist ?? "?", - size: 34) - - VStack(alignment: .leading, spacing: 1) { - NowPlayingMetaText(track: player.current, fields: [.artist], idle: "—") - .font(theme.type.body(12)) - .foregroundStyle(theme.palette.textSecondary) - .lineLimit(1) - Text(player.current?.title ?? "Nothing playing") - .font(theme.type.title(15)) - .foregroundStyle(theme.palette.textPrimary) - .lineLimit(1) - } - .frame(maxWidth: .infinity, alignment: .leading) - - savePill - } - .padding(10) - .background { - RoundedRectangle(cornerRadius: 12, style: .continuous) - .fill(theme.palette.raised) - } - } - - private var savePill: some View { - let saved = NowPlayingFavorite.isSaved(player.current, favorites: app.favorites) - return Button { - NowPlayingFavorite.toggle(player.current, favorites: app.favorites) - } label: { - Text(saved ? "Saved" : "Save") - .font(theme.type.body(12).weight(.semibold)) - .foregroundStyle(saved ? theme.palette.base : theme.palette.textPrimary) - .padding(.horizontal, 12) - .padding(.vertical, 6) - .background { - Capsule().fill(saved ? theme.palette.textPrimary : theme.palette.hairline) - } - } - .buttonStyle(.plain) - .disabled(player.current?.showId == nil) - .accessibilityLabel("Save show to Favorites") - .accessibilityAddTraits(saved ? .isSelected : []) - } - - // --- thin progress rule + times ----------------------------------------- - - private var seekBlock: some View { - VStack(spacing: 4) { - Slider( - value: Binding( - // Duration-less streams would pin the thumb hard right - // (sliderMax falls back to 1), reading as "finished". - get: { scrubbing ? scrubValue - : (player.duration > 0 ? min(player.currentTime, sliderMax) : 0) }, - set: { scrubValue = $0 }), - in: 0...sliderMax - ) { editing in - if editing { scrubValue = player.currentTime } else { player.seek(to: scrubValue) } - scrubbing = editing - } - .controlSize(.mini) - .tint(theme.palette.textPrimary) - .disabled(player.duration <= 0) - .accessibilityLabel("Playback position") - .accessibilityValue(TransportBar.format(seconds: scrubbing ? scrubValue : player.currentTime)) - - HStack { - Text(TransportBar.format(seconds: scrubbing ? scrubValue : player.currentTime)) - Spacer() - Text(remainingText) - } - .font(theme.type.numeric(11)) - .foregroundStyle(theme.palette.textSecondary) - } - } - - private var sliderMax: Double { max(player.duration, 1) } - - private var remainingText: String { - guard player.duration > 0 else { return "--:--" } - let position = scrubbing ? scrubValue : player.currentTime - return "-" + TransportBar.format(seconds: max(player.duration - position, 0)) - } - - // --- the wheel ------------------------------------------------------------ - - private var controlPad: some View { - ZStack { - // Satellites sit on the diagonals outside the wheel. - VStack { - HStack { - // The queue already sits beside the player on iPhone - // Duo's inner display. An empty 52pt slot, not a raised - // disc (that would read as a dead button), and never - // removed — the satellites are fixed geometry. - if queueBeside { - Color.clear - .frame(width: 52, height: 52) - .accessibilityHidden(true) - } else { - satellite("ellipsis", label: "Queue and stream details") { dashboardShown = true } - } - Spacer() - RoutePicker(tint: theme.palette.textPrimary) - .frame(width: 52, height: 52) - .background(Circle().fill(theme.palette.raised)) - } - Spacer() - HStack { - satellite("gobackward.15", label: "Back 15 seconds") { player.seek(by: -15) } - Spacer() - satellite("goforward.30", label: "Forward 30 seconds") { player.seek(by: 30) } - } - } - .frame(width: Self.padSize.width, height: Self.padSize.height) - - wheel - } - .frame(width: Self.padSize.width, height: Self.padSize.height) - // The pad's proportions are the theme, so it never reflows: in a - // column narrower than 330pt (an iPhone Duo split column, Split View) - // it shrinks as a whole. scaleEffect only changes the drawing, so the - // frame after it is what actually gives the layout back. - .scaleEffect(padScale) - .frame(width: Self.padSize.width * padScale, height: Self.padSize.height * padScale) - // Measured on the full-width container, whose width doesn't depend on - // the scale — so there's no measure → scale → re-measure loop. - // `minWidth: 0` is load-bearing: a flexible frame with only a max - // reports max(child, proposal), so in a column narrower than 330pt the - // unscaled first pass reported 330, the scale stayed 1, and the pad - // widened its column (seen beside the queue on the Duo, where it - // pushed the queue column half off the screen). With a 0 minimum the - // container reports the proposal whatever the pad does. - .frame(minWidth: 0, maxWidth: .infinity) - .onGeometryChange(for: CGFloat.self) { $0.size.width } action: { padWidth = $0 } - } - - /// The satellite cluster's natural footprint around the 240pt wheel. - private static let padSize = CGSize(width: 330, height: 260) - - /// 1 until measured (`padWidth == 0` is unconstrained), and never above 1. - private var padScale: CGFloat { - NowPlayingFit.scale(available: CGSize(width: padWidth, height: .infinity), - natural: Self.padSize) - } - - /// A pad outside the ring. Seek and queue live here, one wheel-width from - /// the prev/next glyphs that tick — so they tick the same way. - private func satellite(_ system: String, label: String, action: @escaping () -> Void) -> some View { - HapticButton(.transportStep, action: action) { - Image(systemName: system) - .font(.system(size: 17, weight: .semibold)) - .foregroundStyle(theme.palette.textPrimary) - .frame(width: 52, height: 52) - .background(Circle().fill(theme.palette.raised)) - } - .buttonStyle(.plain) - .accessibilityLabel(label) - } - - private var wheel: some View { - let saved = NowPlayingFavorite.isSaved(player.current, favorites: app.favorites) - return ZStack { - Circle() - .fill(theme.palette.raised) - .overlay { - Circle().strokeBorder(theme.palette.hairline, lineWidth: 1) - } - .shadow(color: .black.opacity(0.45), radius: 18, y: 8) - - // Ring glyphs (N/S/E/W). - VStack { - wheelButton(saved ? "heart.fill" : "heart", - label: "Save show to Favorites") { - NowPlayingFavorite.toggle(player.current, favorites: app.favorites) - } - .disabled(player.current?.showId == nil) - Spacer() - wheelButton(player.volume == 0 ? "speaker.slash.fill" : "speaker.wave.2.fill", - label: player.volume == 0 ? "Unmute" : "Mute") { - toggleMute() - } - } - .padding(.vertical, 20) - - HStack { - wheelButton("backward.fill", label: "Previous track") { player.previous() } - .disabled(!player.hasPrevious) - Spacer() - wheelButton("forward.fill", label: "Next track") { player.next() } - .disabled(!player.hasNext) - } - .padding(.horizontal, 20) - - // Center play/pause puck. - HapticButton(.transportToggle) { - player.togglePlayPause() - } label: { - ZStack { - Circle().fill(theme.palette.base) - if player.isBuffering { - ProgressView() - } else { - Image(systemName: player.isPlaying ? "pause.fill" : "play.fill") - .font(.system(size: 26, weight: .bold)) - .foregroundStyle(theme.palette.textPrimary) - } - } - .frame(width: 88, height: 88) - } - .buttonStyle(.plain) - .disabled(player.current == nil) - .accessibilityLabel(player.isPlaying ? "Pause" : "Play") - } - .frame(width: 240, height: 240) - } - - /// A glyph on the ring. Ticks like the original scroll wheel. - private func wheelButton(_ system: String, label: String, action: @escaping () -> Void) -> some View { - HapticButton(.transportStep, action: action) { - Image(systemName: system) - .font(.system(size: 20, weight: .semibold)) - .foregroundStyle(theme.palette.textPrimary) - .frame(width: 52, height: 44) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .accessibilityLabel(label) - } - - private func toggleMute() { - if player.volume == 0 { - player.volume = unmutedVolume > 0 ? unmutedVolume : 1 - } else { - unmutedVolume = player.volume - player.volume = 0 - } - } -} diff --git a/AppleNugs/Views/NowPlaying/NowPlayingFit.swift b/AppleNugs/Views/NowPlaying/NowPlayingFit.swift index cafbb62..094fcb9 100644 --- a/AppleNugs/Views/NowPlaying/NowPlayingFit.swift +++ b/AppleNugs/Views/NowPlaying/NowPlayingFit.swift @@ -1,8 +1,8 @@ import Foundation -/// Uniform scale-to-fit for now-playing parts whose proportions ARE the theme -/// (Click Wheel's satellite cluster around the wheel): they shrink as a whole -/// rather than reflow. Pure Foundation so the host-free tests can cover it. +/// Uniform scale-to-fit for rigid now-playing parts (the transport row, via +/// `nowPlayingFitWidth`): they shrink as a whole rather than reflow. Pure +/// Foundation so the host-free tests can cover it. enum NowPlayingFit { /// The largest scale in `0...1` at which `natural` fits inside `available`. diff --git a/AppleNugs/Views/NowPlaying/NowPlayingPackCard.swift b/AppleNugs/Views/NowPlaying/NowPlayingPackCard.swift new file mode 100644 index 0000000..0aa0756 --- /dev/null +++ b/AppleNugs/Views/NowPlaying/NowPlayingPackCard.swift @@ -0,0 +1,151 @@ +import SwiftUI + +/// The now-playing card (the blister pack): the cover sealed in the bubble on +/// card stock, the price sticker on the bubble's corner, and the title printed +/// on the card at nameplate size with the meta line under it. Translated from +/// the design sheet's `.np .card` / `.name h2.plate` / `.name .meta`. +/// +/// Hosted by `NowPlayingScreen` in place of its artwork + title block — +/// iPhone full screen, the Duo's player column, the Mac Now Playing window. +/// Takes plain values (no `PlayerService`), so it has no playback-time input at +/// all and an offscreen render can build it as-is. +/// +/// **Art yields height** (CLAUDE.md, iPhone Duo layout rules): the pack is a +/// flexible child of the player's stack, and inside it `BlisterPack` gives the +/// nameplate and meta their natural height first, so on a short canvas the +/// bubble shrinks and the text keeps its lines. +struct NowPlayingPackCard: View { + @Environment(\.theme) private var theme + + let track: QueueTrack? + let image: PlatformImage? + let isPlaying: Bool + /// The corner sticker's label; `nil` draws none. Pass + /// `PriceStickerLabel(player:)` (it gates on `player.current`). + let price: PriceStickerLabel? + + var body: some View { + // ONE pack for both "nothing loaded" and "playing": branching to the + // empty pack on `track == nil` would change the title's view identity + // and reset the foil sweep's trigger just as the first track arrives. + BlisterPack(price: price, maxBubbleSide: NowPlayingCardMetrics.maxBubble) { + PackArt(image: image, monogram: track?.artist ?? track?.title ?? "?") + } below: { + VStack(alignment: .leading, spacing: NowPlayingCardMetrics.metaGap) { + NowPlayingNameplate(text: track?.title ?? "Nothing playing", + identity: track?.trackId, + isPlaying: isPlaying) + if let track { + NowPlayingMetaText(track: track) + .font(theme.type.body(NowPlayingCardMetrics.metaSize)) + .foregroundStyle(theme.palette.textSecondary) + .lineLimit(2) + .multilineTextAlignment(.leading) + } + } + } + } +} + +/// The card title: heavy rounded at 44pt, scaling down to a 34pt floor before +/// it wraps a third line (`.name h2.plate`: "34 to 44pt, scales down before it +/// wraps a third line"). Only a title that still needs more than two lines AT +/// the floor gets a third (and truncates past it), so a long segue ("Arcadia > +/// Hot Tea > Madhuvan") stays readable in the Duo's narrow upright column. +/// +/// Which mode applies is decided by two hidden probes set at the floor size +/// and measured at the FULL available width (not the title's own, which +/// depends on the mode — that would loop): the title with no line limit, and +/// with two. Taller unlimited = it needs a third line at 34pt. The probes sit +/// in a background, so they never add height; the visible `Text` keeps one +/// identity across both modes, so the foil sweep's state survives a flip. +/// +/// The foil sweep sits on the `Text` itself, before any frame, so the band +/// spans the glyphs; its mask redraws this same text at the same proposal, so +/// it scales with it. +struct NowPlayingNameplate: View { + @Environment(\.theme) private var theme + + let text: String + /// The catalog id (`QueueTrack.trackId`), not the per-entry queue id. + let identity: String? + let isPlaying: Bool + + @State private var heightAtFloor = (unlimited: CGFloat(0), twoLines: CGFloat(0)) + + private var needsThirdLine: Bool { + heightAtFloor.unlimited > heightAtFloor.twoLines + 0.5 + } + + var body: some View { + let m = NowPlayingCardMetrics.self + let third = needsThirdLine + Text(text) + .font(theme.type.hero(third ? m.plateFloor : m.plateSize)) + .tracking(m.plateTracking) + .foregroundStyle(theme.palette.textPrimary) + .lineLimit(third ? 3 : 2) + .minimumScaleFactor(third ? 1 : m.plateFloor / m.plateSize) + .multilineTextAlignment(.leading) + .foilSweep(identity: identity, isPlaying: isPlaying) + .frame(maxWidth: .infinity, alignment: .leading) + .background(alignment: .topLeading) { + ZStack(alignment: .topLeading) { + probe(lineLimit: nil) + .onGeometryChange(for: CGFloat.self) { $0.size.height } action: { + heightAtFloor.unlimited = $0 + } + probe(lineLimit: 2) + .onGeometryChange(for: CGFloat.self) { $0.size.height } action: { + heightAtFloor.twoLines = $0 + } + } + .hidden() + .accessibilityHidden(true) + } + } + + private func probe(lineLimit: Int?) -> some View { + Text(text) + .font(theme.type.hero(NowPlayingCardMetrics.plateFloor)) + .tracking(NowPlayingCardMetrics.plateTracking) + .lineLimit(lineLimit) + .fixedSize(horizontal: false, vertical: true) + } +} + +/// Every number the blister-pack now-playing layout uses, in one place so the +/// height budget can be checked offscreen with the exact same constants. +/// +/// Budget at the Duo's flat inner-display column (433×635pt) with a two-line +/// title and a two-line meta: the chrome around the bubble (header, card +/// padding and hang slot, nameplate, meta, scrub, transport, volume, bottom +/// row, these gaps) is fixed, and the bubble takes what is left. Today's 22pt +/// stack spacing alone would cost ~150pt across seven gaps and leave the bubble +/// almost nothing, so the card layout uses the mockup's tighter margins. +enum NowPlayingCardMetrics { + /// The player stack's vertical padding (today 18). + static let verticalPadding: CGFloat = 12 + /// `.np .card { margin: 4px 20px 0 }` + static let cardTop: CGFloat = 4 + static let cardInset: CGFloat = 20 + /// `.scrub { margin-top: 22px }`, trimmed. + static let scrubTop: CGFloat = 18 + /// `.transport { margin-top: 12px }`, trimmed. + static let transportTop: CGFloat = 8 + static let volumeTop: CGFloat = 8 + /// `.np-bottom { margin-top: 14px }`, trimmed (the row's 44pt targets + /// already carry air). + static let bottomTop: CGFloat = 4 + /// The bubble's cap: about the sheet's 298pt, so a tall canvas (the Mac + /// window stretched, the upright Duo with the player alone) doesn't blow + /// the cover up past the pack's proportions. + static let maxBubble: CGFloat = 320 + /// `.name h2.plate { font-size: 44px; letter-spacing: -.02em }` + static let plateSize: CGFloat = 44 + static let plateFloor: CGFloat = 34 + static let plateTracking: CGFloat = -0.88 + /// `.name .meta { margin: 6px 0 0; font-size: 15px }` + static let metaGap: CGFloat = 6 + static let metaSize: CGFloat = 15 +} diff --git a/AppleNugs/Views/NowPlaying/NowPlayingScreen.swift b/AppleNugs/Views/NowPlaying/NowPlayingScreen.swift index 4c27b62..1705026 100644 --- a/AppleNugs/Views/NowPlaying/NowPlayingScreen.swift +++ b/AppleNugs/Views/NowPlaying/NowPlayingScreen.swift @@ -7,32 +7,11 @@ enum NowPlayingWindow { static let id = "now-playing" } -/// Full-screen now-playing, presented from a tap on the compact transport bar. -/// Themes with the faceplate transport (The Receiver) get the TouchFaceplate; -/// everything else gets the standard layout below, tinted by theme tokens. +/// Full-screen now-playing: the iOS full-screen cover (inside +/// `NowPlayingArrangement`) and the macOS Now Playing window. struct NowPlayingScreen: View { - @Environment(\.theme) private var theme - - var body: some View { - Group { - switch theme.transport { - case .faceplate: - TouchFaceplate() - case .clickWheel: - ClickWheelScreen() - case .standard, .tapeLabel, .jCard: - StandardNowPlayingScreen() - } - } - } -} - -// MARK: - Standard layout - -struct StandardNowPlayingScreen: View { @Environment(AppModel.self) private var app @Environment(\.theme) private var theme - @Environment(\.artColor) private var artColor @Environment(\.dismiss) private var dismiss @Environment(UIState.self) private var ui @Environment(\.nowPlayingQueueBeside) private var queueBeside @@ -44,58 +23,29 @@ struct StandardNowPlayingScreen: View { private var player: PlayerService { app.player } + /// The card layout: the blister pack carries the art, title and meta, and + /// the stack uses the mockup's tight per-row margins + /// (`NowPlayingCardMetrics`) rather than a uniform spacing, so the whole + /// player fits the Duo's 433×635pt column. var body: some View { - VStack(spacing: 22) { + VStack(spacing: 0) { header Spacer(minLength: 0) - artwork - .padding(.horizontal, 36) - - // Tape Room: the label card's "tape counter" under-rule rides - // directly beneath the reel (in addition to the scrubber below). - if theme.transport == .tapeLabel { - tapeCounter - .padding(.horizontal, 72) - } - - VStack(spacing: 5) { - HStack(spacing: 8) { - if theme.caps.contains(.equalizerRows) { - EqualizerBars(isPlaying: player.isPlaying) - } - Text(player.current?.title ?? "Nothing playing") - .font(theme.type.hero(22)) - .foregroundStyle(theme.palette.textPrimary) - .lineLimit(2) - .multilineTextAlignment(.center) - } - if let track = player.current { - // Shoebox liner-note treatment: typed-out tracked caps, - // as on its J-card spine. - if theme.transport == .jCard { - NowPlayingMetaText(track: track, casing: .upper) - .font(theme.type.numeric(11)) - .tracking(0.9) - .foregroundStyle(theme.palette.textSecondary) - .lineLimit(2) - .multilineTextAlignment(.center) - } else { - NowPlayingMetaText(track: track) - .font(theme.type.title(14)) - .foregroundStyle(theme.palette.textSecondary) - .lineLimit(2) - .multilineTextAlignment(.center) - } - } - } - .padding(.horizontal, 24) + NowPlayingPackCard( + track: player.current, + image: player.nowPlayingImage, + isPlaying: player.isPlaying, + price: PriceStickerLabel(player: player)) + .padding(.horizontal, NowPlayingCardMetrics.cardInset) + .padding(.top, NowPlayingCardMetrics.cardTop) Spacer(minLength: 0) seekBlock .padding(.horizontal, 24) + .padding(.top, NowPlayingCardMetrics.scrubTop) // Rigid (five fixed glyphs, a 68pt play button): in a column // narrower than the row plus the seek block's 24pt insets — the @@ -105,24 +55,21 @@ struct StandardNowPlayingScreen: View { // and the Mac window at its default 400pt; ~0.82 at a 300pt column. transportRow .nowPlayingFitWidth(margin: 24) + .padding(.top, NowPlayingCardMetrics.transportTop) volumeRow .padding(.horizontal, 32) + .padding(.top, NowPlayingCardMetrics.volumeTop) bottomRow .padding(.horizontal, 28) + .padding(.top, NowPlayingCardMetrics.bottomTop) } - .padding(.vertical, 18) + .padding(.vertical, NowPlayingCardMetrics.verticalPadding) .nowPlayingReadableWidth() .frame(maxWidth: .infinity, maxHeight: .infinity) .background { theme.palette.base - .overlay { ArtWashBackground(style: theme.washStyle, color: artColor ?? .clear) } - .overlay { - if theme.textureOpacity > 0 { - PaperGrain().opacity(theme.textureOpacity).allowsHitTesting(false) - } - } .ignoresSafeArea() } .dismissesOnNavigation($dashboardShown, ui: ui) @@ -133,10 +80,7 @@ struct StandardNowPlayingScreen: View { #endif .presentationBackground(theme.palette.base) } - .confirmationDialog("Clear the queue?", isPresented: $confirmClear, titleVisibility: .visible) { - Button("Clear Queue", role: .destructive) { app.player.clear() } - Button("Cancel", role: .cancel) {} - } + .clearQueueConfirmation(isPresented: $confirmClear) { app.player.clear() } } private var header: some View { @@ -154,57 +98,34 @@ struct StandardNowPlayingScreen: View { Spacer() - // Queue position while loaded; the theme's flavor copy only for - // the empty state (its keyboard hints don't apply on iOS anyway). + // Queue position while loaded; the flavor copy only for the empty + // state. Sentence case, untracked ("Track 3 of 24", ruling 3). Text(player.current != nil - ? "TRACK \(player.index + 1) OF \(player.queue.count)" - : theme.copy.nowPlaying.uppercased()) - .font(theme.type.numeric(11)) - .tracking(1.6) + ? "Track \(player.index + 1) of \(player.queue.count)" + : theme.copy.nowPlaying) + .font(theme.type.body(14)) .foregroundStyle(theme.palette.textSecondary) .lineLimit(1) Spacer() - star - .frame(width: 44, height: 44) - } - .padding(.horizontal, 12) - } - - private var artwork: some View { - ZStack { - if let image = player.nowPlayingImage { - Image(platformImage: image) - .resizable() - .aspectRatio(contentMode: .fit) - } else { - // The monogram scales with the box it is given, capped at its - // old fixed 280pt — the same flexibility the real-art Image - // above already has. A rigid 280×280 tile can't shrink, which - // overflowed the stack: on iPhone Duo's ~669pt-tall landscape - // inner display it pushed the header and bottom row off - // screen, and even on a portrait iPhone the stack overflowed - // its 18pt vertical padding by ~15pt at each end (truncating - // the meta line to one row). The ideal size keeps 280 for - // unconstrained (nil) proposals. - GeometryReader { geo in - MonogramTile( - text: player.current?.artist ?? player.current?.title ?? "?", - size: min(geo.size.width, geo.size.height)) - .frame(width: geo.size.width, height: geo.size.height) - } - .frame(idealWidth: 280, maxWidth: 280, idealHeight: 280, maxHeight: 280) + // The card layout moves the star to the bottom row (AirPlay / star + // / queue, as drawn); the ⋯ queue menu takes its slot here so the + // bottom row holds exactly three. + // The ZStack keeps the 44pt slot when `moreMenu` is empty (no + // queue) — a frame around an empty `if` reserves nothing, and the + // centered header line would drift. The plain menu style drops the + // macOS pull-down bezel and chevron, which spill past the slot. + ZStack { + Color.clear + moreMenu + .menuStyle(.button) + .buttonStyle(.plain) + .menuIndicator(.hidden) } + .frame(width: 44, height: 44) } - .frame(maxWidth: .infinity) - .aspectRatio(1, contentMode: .fit) - .clipShape(RoundedRectangle(cornerRadius: 18, style: .continuous)) - .overlay { - RoundedRectangle(cornerRadius: 18, style: .continuous) - .strokeBorder(theme.palette.textPrimary.opacity(0.12), lineWidth: 1) - } - .shadow(color: (artColor ?? theme.palette.accent).opacity(0.35), radius: 28, y: 10) + .padding(.horizontal, 12) } private var seekBlock: some View { @@ -224,7 +145,7 @@ struct StandardNowPlayingScreen: View { if editing { scrubValue = player.currentTime } else { player.seek(to: scrubValue) } scrubbing = editing } - .tint(theme.effectiveAccent(art: artColor)) + .tint(theme.palette.accent) .disabled(player.duration <= 0) .accessibilityLabel("Playback position") .accessibilityValue(TransportBar.format(seconds: scrubbing ? scrubValue : player.currentTime)) @@ -239,23 +160,6 @@ struct StandardNowPlayingScreen: View { } } - private var tapeCounter: some View { - GeometryReader { geo in - ZStack(alignment: .leading) { - Capsule().fill(theme.palette.hairline) - Capsule().fill(theme.palette.accent) - .frame(width: geo.size.width * counterProgress) - } - } - .frame(height: 2) - .accessibilityHidden(true) - } - - private var counterProgress: CGFloat { - guard player.duration > 0 else { return 0 } - return min(max(CGFloat(player.currentTime / player.duration), 0), 1) - } - private var sliderMax: Double { max(player.duration, 1) } private var remainingText: String { @@ -279,14 +183,11 @@ struct StandardNowPlayingScreen: View { .accessibilityLabel("Back 15 seconds") HapticButton(.transportToggle) { player.togglePlayPause() } label: { - if player.isBuffering { - ProgressView() - .frame(width: 68, height: 68) - } else { - Image(systemName: player.isPlaying ? "pause.circle.fill" : "play.circle.fill") - .font(.system(size: 68)) - .symbolRenderingMode(.hierarchical) - } + // The sticker keeps the spinner inside its own disc, so the + // control never changes size while buffering. + PlaySticker(isPlaying: player.isPlaying, + isBuffering: player.isBuffering, + size: .s64) } .disabled(player.current == nil) .accessibilityLabel(player.isPlaying ? "Pause" : "Play") @@ -312,63 +213,74 @@ struct StandardNowPlayingScreen: View { return HStack(spacing: 12) { Image(systemName: "speaker.fill").font(.system(size: 12)) Slider(value: $player.volume, in: 0...1) - .tint(theme.effectiveAccent(art: artColor)) + // Volume isn't time, so it isn't foil. + .tint(theme.palette.textSecondary) .accessibilityLabel("Volume") Image(systemName: "speaker.wave.3.fill").font(.system(size: 12)) } .foregroundStyle(theme.palette.textSecondary) } + /// The bottom row: AirPlay / star / queue, spread like the sheet's + /// `.np-bottom`. No format badge — the price sticker on the bubble's + /// corner carries it. private var bottomRow: some View { HStack { - if let pick = player.nowPick { - Text(pick.format.badge) - .font(theme.type.numeric(10).weight(.semibold)) - .foregroundStyle(theme.palette.textSecondary) - .padding(.horizontal, 6) - .padding(.vertical, 2) - .background(theme.palette.hairline, in: RoundedRectangle(cornerRadius: 4)) - } + RoutePicker(tint: theme.palette.textSecondary) + .frame(width: 44, height: 44) Spacer() - RoutePicker(tint: theme.effectiveAccent(art: artColor)) + star .frame(width: 44, height: 44) - // The queue already sits beside the player on iPhone Duo's inner - // display; keep its 44pt slot so the row doesn't shift. - if queueBeside { - Color.clear + Spacer() + + queueButton + } + } + + /// The queue slot: a button, or — with the queue already beside the + /// player on iPhone Duo's inner display — a same-size placeholder so the + /// row doesn't shift. + @ViewBuilder + private var queueButton: some View { + if queueBeside { + Color.clear + .frame(width: 44, height: 44) + .accessibilityHidden(true) + } else { + Button { + dashboardShown = true + } label: { + Image(systemName: "list.bullet") + .font(.system(size: 17)) + .foregroundStyle(theme.palette.textSecondary) .frame(width: 44, height: 44) - .accessibilityHidden(true) - } else { - Button { - dashboardShown = true - } label: { - Image(systemName: "list.bullet") - .font(.system(size: 17)) - .foregroundStyle(theme.palette.textSecondary) - .frame(width: 44, height: 44) - } - .buttonStyle(.plain) - .accessibilityLabel("Queue and stream details") } + .buttonStyle(.plain) + .accessibilityLabel("Queue and stream details") + } + } - if !player.queue.isEmpty { - Menu { - Button(role: .destructive) { - confirmClear = true - } label: { - Label("Clear Queue", systemImage: "trash") - } + /// "More queue actions" (Clear Queue, confirmed). Present while there is a + /// queue; nothing otherwise (the header's ZStack keeps the 44pt slot). + @ViewBuilder + private var moreMenu: some View { + if !player.queue.isEmpty { + Menu { + Button(role: .destructive) { + confirmClear = true } label: { - Image(systemName: "ellipsis.circle") - .font(.system(size: 17)) - .foregroundStyle(theme.palette.textSecondary) - .frame(width: 44, height: 44) + Label("Clear Queue", systemImage: "trash") } - .accessibilityLabel("More queue actions") + } label: { + Image(systemName: "ellipsis.circle") + .font(.system(size: 17)) + .foregroundStyle(theme.palette.textSecondary) + .frame(width: 44, height: 44) } + .accessibilityLabel("More queue actions") } } @@ -379,7 +291,9 @@ struct StandardNowPlayingScreen: View { } label: { Image(systemName: saved ? "star.fill" : "star") .font(.system(size: 17)) - .foregroundStyle(saved ? theme.palette.accent : theme.palette.textSecondary) + // A saved show isn't time moving: the filled star is snow, + // not an accent fill. + .foregroundStyle(saved ? theme.palette.textPrimary : theme.palette.textSecondary) } .buttonStyle(.plain) .disabled(player.current?.showId == nil) diff --git a/AppleNugs/Views/NowPlaying/TouchFaceplate.swift b/AppleNugs/Views/NowPlaying/TouchFaceplate.swift deleted file mode 100644 index 39459f9..0000000 --- a/AppleNugs/Views/NowPlaying/TouchFaceplate.swift +++ /dev/null @@ -1,317 +0,0 @@ -import SwiftUI - -/// The Receiver's now-playing as a touch-first faceplate: the desktop bar's -/// design language (brushed chassis, knurled transport, LED volume ladder, -/// L/R VU meter, tuner seek, seven-segment-ish readouts) re-proportioned for -/// a phone in the hand — 44pt+ targets, a 76pt play knob, full-width meters. -struct TouchFaceplate: View { - @Environment(AppModel.self) private var app - @Environment(\.theme) private var theme - @Environment(\.dismiss) private var dismiss - @Environment(UIState.self) private var ui - @Environment(\.nowPlayingQueueBeside) private var queueBeside - - @State private var scrubbing = false - @State private var scrubValue: Double = 0 - @State private var dashboardShown = false - - private var player: PlayerService { app.player } - - var body: some View { - VStack(spacing: 24) { - header - - Spacer(minLength: 0) - - chassis - - Spacer(minLength: 0) - - seekBlock - .padding(.horizontal, 24) - - // 54+46+76+46+54 plus 4×22 spacing = 364pt of rigid knurled - // buttons: wider than the ~300pt player column on iPhone Duo's - // upright inner display, so it shrinks as a unit there. The 12pt - // margin (the header's inset) keeps the scale at 1 on every iPhone, - // the 390pt ones included (390 − 24 ≥ 364). - controls - .nowPlayingFitWidth(margin: 12) - - volumeLadder - - qualityReadout - - bottomRow - .padding(.horizontal, 28) - } - .padding(.vertical, 18) - .nowPlayingReadableWidth() - .frame(maxWidth: .infinity, maxHeight: .infinity) - .background { - LinearGradient( - colors: [Color(hex: 0x1C1A17), Color(hex: 0x14120F)], - startPoint: .top, endPoint: .bottom) - .ignoresSafeArea() - } - .dismissesOnNavigation($dashboardShown, ui: ui) - .sheet(isPresented: $dashboardShown) { - DashboardPanel() - #if os(iOS) - .presentationDetents([.medium, .large]) - #endif - .presentationBackground(theme.palette.base) - } - } - - private var header: some View { - HStack { - Button { - dismiss() - } label: { - Image(systemName: "chevron.down") - .font(.system(size: 17, weight: .semibold)) - .foregroundStyle(theme.palette.textSecondary) - .frame(width: 44, height: 44) - } - .buttonStyle(.plain) - .accessibilityLabel("Close now playing") - - Spacer() - - // The channel block already reads out queue position; the header - // carries the tuner flavor only while idle. - Text(player.current != nil ? "RECEIVING" : theme.copy.nowPlaying.uppercased()) - .font(theme.type.numeric(11)) - .tracking(1.6) - .foregroundStyle(theme.palette.textSecondary) - .lineLimit(1) - - Spacer() - - star - .frame(width: 44, height: 44) - } - .padding(.horizontal, 12) - } - - // --- chassis: channel readout + big VU ----------------------------------- - - private var chassis: some View { - VStack(alignment: .leading, spacing: 18) { - VStack(alignment: .leading, spacing: 6) { - HStack(spacing: 8) { - Text(channelText) - .font(theme.type.numeric(15).weight(.heavy)) - .foregroundStyle(theme.palette.accent) - Text(player.current?.title ?? "—") - .font(theme.type.title(18)) - .foregroundStyle(theme.palette.textPrimary) - .lineLimit(1) - } - NowPlayingMetaText(track: player.current, casing: .upper, - idle: theme.copy.nowPlaying.uppercased()) - .font(theme.type.numeric(10)) - .tracking(0.6) - .foregroundStyle(theme.palette.textSecondary) - .lineLimit(2) - } - - VUMeter(isPlaying: player.isPlaying) - .scaleEffect(1.9, anchor: .leading) - .frame(maxWidth: .infinity, minHeight: 66, alignment: .leading) - .accessibilityHidden(true) - } - .padding(18) - .frame(maxWidth: .infinity, alignment: .leading) - .background { - RoundedRectangle(cornerRadius: 14, style: .continuous) - .fill(LinearGradient( - colors: [Color(hex: 0x231F1B), Color(hex: 0x161310)], - startPoint: .top, endPoint: .bottom)) - .overlay { - RoundedRectangle(cornerRadius: 14, style: .continuous) - .strokeBorder(theme.palette.hairline, lineWidth: 1) - } - .overlay(alignment: .top) { - RoundedRectangle(cornerRadius: 14, style: .continuous) - .fill(.white.opacity(0.05)) - .frame(height: 1) - .padding(.horizontal, 8) - } - } - .padding(.horizontal, 20) - } - - private var channelText: String { - guard player.current != nil else { return "—" } - return "\(player.index + 1)/\(player.queue.count)" - } - - // --- tuner seek ----------------------------------------------------------- - - private var seekBlock: some View { - HStack(spacing: 10) { - Text(TransportBar.format(seconds: scrubbing ? scrubValue : player.currentTime)) - .font(theme.type.numeric(13)) - .foregroundStyle(theme.palette.accent) - .frame(width: 52, alignment: .trailing) - - Slider( - value: Binding( - // Duration-less streams would pin the thumb hard right - // (sliderMax falls back to 1), reading as "finished". - get: { scrubbing ? scrubValue - : (player.duration > 0 ? min(player.currentTime, sliderMax) : 0) }, - set: { scrubValue = $0 }), - in: 0...sliderMax - ) { editing in - if editing { scrubValue = player.currentTime } else { player.seek(to: scrubValue) } - scrubbing = editing - } - .tint(theme.palette.accent) - .disabled(player.duration <= 0) - .accessibilityLabel("Playback position") - .accessibilityValue(TransportBar.format(seconds: scrubbing ? scrubValue : player.currentTime)) - - Text(remainingText) - .font(theme.type.numeric(13)) - .foregroundStyle(theme.palette.textSecondary) - .frame(width: 58, alignment: .leading) - } - } - - private var sliderMax: Double { max(player.duration, 1) } - - private var remainingText: String { - guard player.duration > 0 else { return "--:--" } - let position = scrubbing ? scrubValue : player.currentTime - return "-" + TransportBar.format(seconds: max(player.duration - position, 0)) - } - - // --- knurled transport ---------------------------------------------------- - - private var controls: some View { - HStack(spacing: 22) { - KnurledButton(system: "backward.fill", size: 54, glow: false) { player.previous() } - .disabled(!player.hasPrevious) - .accessibilityLabel("Previous track") - KnurledButton(system: "gobackward.15", size: 46, glow: false) { player.seek(by: -15) } - .disabled(player.current == nil) - .accessibilityLabel("Back 15 seconds") - KnurledButton( - system: player.isPlaying ? "pause.fill" : "play.fill", - size: 76, glow: player.isPlaying) { player.togglePlayPause() } - .disabled(player.current == nil) - .accessibilityLabel(player.isPlaying ? "Pause" : "Play") - KnurledButton(system: "goforward.30", size: 46, glow: false) { player.seek(by: 30) } - .disabled(player.current == nil) - .accessibilityLabel("Forward 30 seconds") - KnurledButton(system: "forward.fill", size: 54, glow: false) { player.next() } - .disabled(!player.hasNext) - .accessibilityLabel("Next track") - } - } - - // --- LED volume ladder ------------------------------------------------------ - - private var volumeLadder: some View { - @Bindable var player = app.player - let segs = 14 - let lit = Int((Double(player.volume) * Double(segs)).rounded()) - return GeometryReader { geo in - HStack(alignment: .bottom, spacing: 3) { - ForEach(0.. 0 { parts.append(String(format: "%.1fkHz", specs.sampleRate / 1000)) } - parts.append(specs.channels == 2 ? "STEREO" : "\(specs.channels)CH") - } - return parts.joined(separator: " · ") - } - - private var bottomRow: some View { - HStack { - RoutePicker(tint: theme.palette.accent) - .frame(width: 44, height: 44) - - Spacer() - - // The queue already sits beside the player on iPhone Duo's inner - // display; keep its 44pt slot so the row doesn't shift. - if queueBeside { - Color.clear - .frame(width: 44, height: 44) - .accessibilityHidden(true) - } else { - Button { - dashboardShown = true - } label: { - Image(systemName: "list.bullet") - .font(.system(size: 17)) - .foregroundStyle(theme.palette.textSecondary) - .frame(width: 44, height: 44) - } - .buttonStyle(.plain) - .accessibilityLabel("Queue and stream details") - } - } - } - - private var star: some View { - let saved = NowPlayingFavorite.isSaved(player.current, favorites: app.favorites) - return Button { - NowPlayingFavorite.toggle(player.current, favorites: app.favorites) - } label: { - Image(systemName: saved ? "star.fill" : "star") - .font(.system(size: 17, weight: .bold)) - .foregroundStyle(saved ? theme.palette.accent : theme.palette.textSecondary) - } - .buttonStyle(.plain) - .disabled(player.current?.showId == nil) - .accessibilityLabel("Save show to Favorites") - .accessibilityAddTraits(saved ? .isSelected : []) - } -} diff --git a/AppleNugs/Views/SearchView.swift b/AppleNugs/Views/SearchView.swift index 5375431..e1e7fc2 100644 --- a/AppleNugs/Views/SearchView.swift +++ b/AppleNugs/Views/SearchView.swift @@ -16,16 +16,17 @@ struct SearchView: View { var body: some View { VStack(spacing: 0) { - TextField("Search shows, artists, songs", text: $query) - .textFieldStyle(.roundedBorder) - .focused($fieldFocused) - .submitLabel(.search) - .onSubmit { Task { await run() } } + searchField .padding([.horizontal, .top], 16) .padding(.bottom, 10) resultsBody } + // Night under the whole tab (the results list paints its own base; + // the empty/initial states otherwise sat on the system black). + .background { + theme.palette.base.ignoresSafeArea() + } .navigationTitle("Search") .compactNavigationTitle() .onAppear { @@ -49,6 +50,30 @@ struct SearchView: View { } } + /// The surfaces sheet's `.field` — one 40pt shelf field, a slush + /// magnifier, and a foil caret (focus is foil; the root tint is snow). + private var searchField: some View { + HStack(spacing: 8) { + Image(systemName: "magnifyingglass") + .font(.system(size: 15, weight: .medium)) + .foregroundStyle(theme.palette.textSecondary) + TextField("Search shows, artists, songs", text: $query, + prompt: Text("Search shows, artists, songs") + .foregroundStyle(theme.palette.textIdle)) + .focused($fieldFocused) + .submitLabel(.search) + .onSubmit { Task { await run() } } + .textFieldStyle(.plain) + .font(theme.type.body(16)) + .foregroundStyle(theme.palette.textPrimary) + .tint(theme.palette.accent) + } + .padding(.horizontal, 12) + .frame(minHeight: 40) + .background(theme.palette.raised, + in: RoundedRectangle(cornerRadius: 12, style: .continuous)) + } + @ViewBuilder private var resultsBody: some View { if searching { @@ -62,7 +87,8 @@ struct SearchView: View { description: Text(error)) } else if let results, let searchedQuery { if results.isEmpty { - ContentUnavailableView.search(text: searchedQuery) + ContentUnavailableView(theme.copy.searchNoResults(searchedQuery), + systemImage: "magnifyingglass") } else { resultsList(results) } @@ -107,18 +133,25 @@ struct SearchView: View { } } .scrollContentBackground(.hidden) - .reportsScrollForPillDuck() .background(theme.palette.base) } + /// `.minisec`: the barker's heavy slush 15, sentence case. The headers + /// after Artists come from the API (`scHeader`), so an all-caps one is set + /// in sentence case here. private func sectionHeader(_ text: String) -> some View { - let condensed = theme.caps.contains(.condensedHeaders) - return Text(condensed ? text.uppercased() : text) - .font(theme.type.section(12)) - .tracking(condensed ? 1.5 : 0) + Text(Self.sentenceCased(text)) + .font(theme.type.section(15)) .foregroundStyle(theme.palette.textSecondary) } + /// "SHOWS" → "Shows"; anything already in mixed case is left alone. + private static func sentenceCased(_ text: String) -> String { + guard text == text.uppercased(), text != text.lowercased(), + let first = text.first else { return text } + return String(first).uppercased() + text.dropFirst().lowercased() + } + @ViewBuilder private func row(_ item: SearchModel.Item) -> some View { switch item.kind { @@ -158,7 +191,7 @@ struct SearchView: View { app.player.play(single) } label: { HStack(spacing: 6) { - Image(systemName: "play.fill").font(.caption) + songPlayMark Text(item.name) if let artist = item.artistName { Text("— \(artist)").foregroundStyle(theme.palette.textSecondary) @@ -188,6 +221,20 @@ struct SearchView: View { } } + /// A song's play mark: an OUTLINED disc in slush, not a sticker — nothing + /// is playing yet (the sheet's `.rs` with no stock). + private var songPlayMark: some View { + ZStack { + Circle().strokeBorder(theme.palette.textSecondary, lineWidth: 1.5) + Image(systemName: "play.fill") + .font(.system(size: 9, weight: .bold)) + .foregroundStyle(theme.palette.textSecondary) + .offset(x: 1) + } + .frame(width: 22, height: 22) + .padding(.trailing, 4) + } + private func run() async { let q = query.trimmingCharacters(in: .whitespaces) guard !q.isEmpty else { return } diff --git a/AppleNugs/Views/ToastCapsule.swift b/AppleNugs/Views/ToastCapsule.swift new file mode 100644 index 0000000..5b8deb8 --- /dev/null +++ b/AppleNugs/Views/ToastCapsule.swift @@ -0,0 +1,38 @@ +import SwiftUI + +/// The toast's capsule, shared by both scene roots (`RootView`, `IOSRootView`); +/// each root keeps its own placement, transition and hit testing. +/// +/// The surfaces sheet's `.toast`: glass with a frost hairline, heavy rounded +/// type, and a mark that says how it went — a snow check, or a slush warning +/// mark for a failure. +/// +/// Plain values and `\.theme` only, so the offscreen harness can render it. +struct ToastCapsule: View { + @Environment(\.theme) private var theme + + let text: String + let kind: UIState.Toast.Kind + + var body: some View { + HStack(spacing: 8) { + Image(systemName: kind == .failure ? "exclamationmark.circle" : "checkmark") + .font(.system(size: 15, weight: .bold)) + .foregroundStyle(kind == .failure + ? theme.palette.textSecondary : theme.palette.textPrimary) + .accessibilityHidden(true) + Text(text) + .font(theme.type.title(14)) + .foregroundStyle(theme.palette.textPrimary) + .lineLimit(1) + } + // `.toast { height: 38px; padding: 0 16px 0 12px }` + .padding(.leading, 12) + .padding(.trailing, 16) + .frame(height: 38) + .background(.regularMaterial, in: Capsule()) + .overlay { Capsule().strokeBorder(theme.palette.hairline, lineWidth: 1) } + // `0 12px 22px -10px rgba(0,0,0,.75)` + .shadow(color: .black.opacity(0.55), radius: 11, y: 8) + } +} diff --git a/AppleNugs/Views/TransportBar.swift b/AppleNugs/Views/TransportBar.swift index 6f0d45b..535e072 100644 --- a/AppleNugs/Views/TransportBar.swift +++ b/AppleNugs/Views/TransportBar.swift @@ -1,11 +1,10 @@ import SwiftUI /// Bottom transport bar: now-playing block, prev/play/next, seek, volume, -/// and the current format badge. Mirrors the web Transport component. +/// and the price sticker. Mirrors the web Transport component. struct TransportBar: View { @Environment(AppModel.self) private var app @Environment(\.theme) private var theme - @Environment(\.artColor) private var artColor #if os(macOS) @Environment(\.openWindow) private var openWindow @@ -20,11 +19,7 @@ struct TransportBar: View { var body: some View { #if os(macOS) - if theme.transport == .faceplate { - FaceplateTransport() - } else { - standardBar - } + standardBar #else // iOS uses NowPlayingPill in the tab accessory; nothing docks here. EmptyView() @@ -43,7 +38,8 @@ struct TransportBar: View { volumeBlock - RoutePicker(tint: theme.palette.accent) + // AirPlay isn't time moving: slush, not foil. + RoutePicker(tint: theme.palette.textSecondary) .frame(width: 26, height: 26) .help("AirPlay") @@ -59,15 +55,11 @@ struct TransportBar: View { .help("Open the Now Playing window") .accessibilityLabel("Open the Now Playing window") - if let pick = player.nowPick { - Text(pick.format.badge) - .font(theme.type.numeric(10).weight(.semibold)) - .foregroundStyle(badgeColor(for: pick.format)) - .padding(.horizontal, 6) - .padding(.vertical, 2) - .background(theme.palette.hairline, in: RoundedRectangle(cornerRadius: 4)) - .help(pick.format.qualityLabel) - } + // The price sticker replaces the format badge. Presence follows + // `player.current` (inside the sticker's own init), never + // `nowPick`, and it holds its 36pt frame when hidden, so the bar + // never reflows at a track boundary. + PriceSticker(player: player, size: .s36) } .padding(.horizontal, 16) .padding(.vertical, 10) @@ -77,21 +69,6 @@ struct TransportBar: View { private var barBackground: some View { theme.palette.raised - .overlay { ArtWashBackground(style: theme.washStyle, color: artColor ?? .clear) } - .overlay { - if theme.textureOpacity > 0 { - PaperGrain().opacity(theme.textureOpacity).allowsHitTesting(false) - } - } - } - - /// Lossless formats get the theme's dedicated lossless tint when it has one; - /// everything else rides the accent. - private func badgeColor(for format: AudioFormat) -> Color { - if format.isLossless, let badge = theme.palette.losslessBadge { - return badge - } - return theme.palette.textSecondary } private var nowPlayingStar: some View { @@ -101,7 +78,9 @@ struct TransportBar: View { } label: { Image(systemName: saved ? "star.fill" : "star") .font(.system(size: 13)) - .foregroundStyle(saved ? theme.palette.accent : theme.palette.textSecondary) + // A saved show isn't time moving: the filled star is snow, + // not an accent fill. + .foregroundStyle(saved ? theme.palette.textPrimary : theme.palette.textSecondary) } .buttonStyle(.plain) .disabled(player.current?.showId == nil) @@ -112,19 +91,8 @@ struct TransportBar: View { // --- blocks ------------------------------------------------------------------ - @ViewBuilder private var nowPlayingBlock: some View { - switch theme.transport { - case .tapeLabel: - TapeLabelCard() - case .jCard: - JCardStrip() - case .standard, .faceplate, .clickWheel: - // Faceplate has its own whole-bar treatment on the Mac; Click - // Wheel's circular pad is an iOS full-screen idea — on the Mac - // bar both fall back to the standard block in their own tokens. - StandardNowPlaying() - } + StandardNowPlaying() } private var controls: some View { @@ -147,23 +115,7 @@ struct TransportBar: View { .help("Back 15s (←)") .accessibilityLabel("Back 15 seconds") - Button { - player.togglePlayPause() - } label: { - if player.isBuffering { - ProgressView() - .controlSize(.small) - .frame(width: 24) - } else { - Image(systemName: player.isPlaying ? "pause.fill" : "play.fill") - .font(.title3) - .frame(width: 24) - } - } - .disabled(player.current == nil) - .help("Play / pause (space)") - .accessibilityLabel(player.isPlaying ? "Pause" : "Play") - .accessibilityValue(player.isBuffering ? "Buffering" : "") + playPauseButton Button { player.seek(by: 30) @@ -186,6 +138,27 @@ struct TransportBar: View { .buttonStyle(.borderless) } + /// The 34pt play sticker (its own disc, glyph and spinner, one size in + /// every state). + private var playPauseButton: some View { + Button { + player.togglePlayPause() + } label: { + PlaySticker(isPlaying: player.isPlaying, + isBuffering: player.isBuffering, + size: .s34) + .foilFocusOutline(Circle()) + } + // `.borderless` (the cluster's style) would decorate the sticker. + .buttonStyle(.plain) + // The sticker draws its own foil focus outline; no system ring too. + .macFocusEffectDisabled() + .disabled(player.current == nil) + .help("Play / pause (space)") + .accessibilityLabel(player.isPlaying ? "Pause" : "Play") + .accessibilityValue(player.isBuffering ? "Buffering" : "") + } + private var seekBlock: some View { HStack(spacing: 8) { Text(Self.format(seconds: scrubbing ? scrubValue : player.currentTime)) @@ -210,6 +183,8 @@ struct TransportBar: View { } .disabled(player.duration <= 0) .controlSize(.small) + // The root tint is snow; time moving is foil, so say it here. + .tint(theme.palette.accent) .accessibilityLabel("Playback position") .accessibilityValue(Self.format(seconds: scrubbing ? scrubValue : player.currentTime)) @@ -241,6 +216,8 @@ struct TransportBar: View { Slider(value: $player.volume, in: 0...1) .controlSize(.mini) .frame(width: 90) + // Volume isn't time, so it's slush, not foil. + .tint(theme.palette.textSecondary) .accessibilityLabel("Volume") } } @@ -252,8 +229,8 @@ struct TransportBar: View { } } -/// Shared now-playing → FavShow bridge used by both the standard transport and -/// the faceplate. A track is favoritable only when it carries a `showId` +/// Shared now-playing → FavShow bridge used by the transport bar and the +/// now-playing screens. A track is favoritable only when it carries a `showId` /// (i.e. it was queued from a show, not a single-track search hit). @MainActor enum NowPlayingFavorite { diff --git a/AppleNugs/Views/VideoDetailView.swift b/AppleNugs/Views/VideoDetailView.swift index f5c1c41..b0e69b2 100644 --- a/AppleNugs/Views/VideoDetailView.swift +++ b/AppleNugs/Views/VideoDetailView.swift @@ -5,6 +5,12 @@ import SwiftUI /// tappable chapter list, a quality menu, a resume affordance, and a save star. /// Clones the AlbumDetailView load/overlay idiom; playback is owned by the /// shared VideoPlayerService (one AVPlayer, audio/video arbiter). +/// +/// AVKit's controls are untouched; we own only the Live sticker over them. +/// Play/Pause and the screen's other lone primaries (Watch on…, Buy on +/// nugs.net) are snow, everything else is a frost ring, benefit links are snow +/// with a dotted underline, the PPV panel is card stock, and errors are said +/// in snow and slush, never colored. struct VideoDetailView: View { let videoId: String var titleHint: String? @@ -32,7 +38,7 @@ struct VideoDetailView: View { Button { openURL(linkOut) } label: { Label(watchButtonTitle, systemImage: "arrow.up.right.square") } - .buttonStyle(.borderedProminent) + .buttonStyle(.yetiPrimary) } if let detail { header(detail) @@ -51,7 +57,6 @@ struct VideoDetailView: View { .padding(20) .frame(maxWidth: .infinity, alignment: .leading) } - .reportsScrollForPillDuck() .background(theme.palette.base) .navigationTitle(detail?.title ?? titleHint ?? "Video") .compactNavigationTitle() @@ -59,10 +64,7 @@ struct VideoDetailView: View { if shouldAttemptPlay && linkOut == nil && detail == nil && error == nil { ProgressView() } else if let error { - ContentUnavailableView( - "Couldn't load this video", - systemImage: "exclamationmark.triangle", - description: Text(error)) + VideoErrorMessage(title: "Couldn't load this video", reason: error) } } .task(id: videoId) { await load() } @@ -84,11 +86,9 @@ struct VideoDetailView: View { .overlay(alignment: .topLeading) { liveBadge } .overlay { if let loadError = app.video.loadError { - ContentUnavailableView( - "Can't play this video", - systemImage: "play.slash", - description: Text(loadError)) - .background(.black.opacity(0.6)) + VideoErrorMessage(title: "Can't play this video", + reason: loadError, + overPlayer: true) } } } @@ -106,7 +106,7 @@ struct VideoDetailView: View { } placeholder: { Image(systemName: "waveform") .font(.system(size: 40)) - .foregroundStyle(theme.palette.accent) + .foregroundStyle(theme.palette.textSecondary) } .padding(24) } @@ -137,50 +137,22 @@ struct VideoDetailView: View { /// PPV: honest buy-out. No player, no stream-resolve. Opens nugs's own /// watch/buy page (which gates on login, then offers purchase). - @ViewBuilder private var buyPanel: some View { - VStack(alignment: .leading, spacing: 10) { - RoundedRectangle(cornerRadius: 8) - .fill(theme.palette.raised) - .aspectRatio(16.0/9.0, contentMode: .fit) - .overlay { - VStack(spacing: 6) { - Image(systemName: "lock.circle") - .font(.system(size: 34)) - .foregroundStyle(theme.palette.accent) - Text("Pay-per-view").font(theme.type.title(15)) - .foregroundStyle(theme.palette.textPrimary) - Text("Not included in your subscription") - .font(theme.type.body(12)) - .foregroundStyle(theme.palette.textSecondary) - } - } - Button { - if let url = nugsWatchURL(access: .ppv, skuId: webcast?.sku ?? 0) { openURL(url) } - } label: { - Label("Buy on nugs.net", systemImage: "arrow.up.right.square") - } - .buttonStyle(.borderedProminent) + private var buyPanel: some View { + PayPerViewPanel { + if let url = nugsWatchURL(access: .ppv, skuId: webcast?.sku ?? 0) { openURL(url) } } } /// Benefit/donation framing that ships with some free webcasts. Rendered /// as attributed HTML so links (e.g. donation pages) stay tappable; falls /// back to plain text if the markdown parse fails. + /// + /// Each link run is snow with a dotted underline, never the accent. The runs keep their `.link`, so a tap still goes through + /// `openURL`, where the root `.nowPlayingMetaLinks` interceptor hands any + /// foreign URL back to the system: the donation page opens externally. @ViewBuilder private func benefitNotes(_ html: String) -> some View { let md = htmlToMarkdownish(html) - Group { - if let attributed = try? AttributedString( - markdown: md, - options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace)) { - Text(attributed) - } else { - Text(md) - } - } - .font(theme.type.body(13)) - .foregroundStyle(theme.palette.textSecondary) - .tint(theme.palette.accent) - .textSelection(.enabled) + BenefitNotesText(markdown: md) } /// Minimal HTML→text for short, first-party nugs blurbs: keep http/https/ @@ -222,16 +194,7 @@ struct VideoDetailView: View { @ViewBuilder private var liveBadge: some View { - if app.video.isLive { - Text("LIVE") - .font(theme.type.numeric(10).weight(.bold)) - .tracking(1.2) - .foregroundStyle(.white) - .padding(.horizontal, 7) - .padding(.vertical, 3) - .background(theme.palette.accent, in: RoundedRectangle(cornerRadius: 4)) - .padding(10) - } + if app.video.isLive { VideoLiveBadge() } } // --- sections --------------------------------------------------------------- @@ -255,8 +218,13 @@ struct VideoDetailView: View { } private func actions(_ detail: VideoDetail) -> some View { - HStack(spacing: 8) { - Button { + let fav = app.favorites.isVideoFavorited(videoId) + return VideoActionRow( + isPlaying: app.video.isPlaying, + isLive: detail.isLive, + atLiveEdge: app.video.atLiveEdge, + saved: fav, + playPause: { // If this video is already loaded, toggle play/pause; only do a // cold start (re-resolve + buffer) when it isn't the current item. if app.video.current?.id == detail.id { @@ -264,38 +232,15 @@ struct VideoDetailView: View { } else { Task { await app.video.play(detail) } } - } label: { - Label(app.video.isPlaying ? "Pause" : "Play", - systemImage: app.video.isPlaying ? "pause.fill" : "play.fill") - } - .buttonStyle(.borderedProminent) - - if detail.isLive { - Button { - app.video.seekToLiveEdge() - } label: { - Label("Go Live", systemImage: "dot.radiowaves.left.and.right") - } - .disabled(app.video.atLiveEdge) - } - - saveButton(detail) - } - } - - private func saveButton(_ detail: VideoDetail) -> some View { - let fav = app.favorites.isVideoFavorited(videoId) - return Button { - app.favorites.toggleVideo( - FavVideo(id: videoId, videoSku: detail.videoSku, - title: detail.title, artistName: detail.artistName, - dateText: detail.dateText, isLive: detail.isLive, - imageURL: detail.imageURL?.absoluteString, savedAt: Date())) - } label: { - Label(fav ? "Saved" : "Save", systemImage: fav ? "star.fill" : "star") - } - .tint(fav ? theme.palette.accent : nil) - .help(fav ? "Remove from Favorites" : "Save video to Favorites") + }, + goLive: { app.video.seekToLiveEdge() }, + toggleSave: { + app.favorites.toggleVideo( + FavVideo(id: videoId, videoSku: detail.videoSku, + title: detail.title, artistName: detail.artistName, + dateText: detail.dateText, isLive: detail.isLive, + imageURL: detail.imageURL?.absoluteString, savedAt: Date())) + }) } // --- quality ---------------------------------------------------------------- @@ -346,8 +291,8 @@ struct VideoDetailView: View { systemImage: "arrow.clockwise") .font(theme.type.body(12)) } - .buttonStyle(.bordered) - .tint(theme.palette.accent) + .buttonStyle(.yetiSecondary) + .controlSize(.small) } } @@ -374,11 +319,10 @@ struct VideoDetailView: View { @ViewBuilder private func chapterList(_ detail: VideoDetail) -> some View { if !detail.chapters.isEmpty { - let condensed = theme.caps.contains(.condensedHeaders) - Text(condensed ? "CHAPTERS" : "Chapters") + // `.minisec`: heavy 15 in slush. + Text("Chapters") .font(theme.type.section(15)) - .tracking(condensed ? 1.4 : 0) - .foregroundStyle(theme.palette.textPrimary) + .foregroundStyle(theme.palette.textSecondary) .padding(.top, 6) VStack(spacing: 0) { ForEach(detail.chapters) { chapter in @@ -444,8 +388,13 @@ struct VideoDetailView: View { } } +// MARK: - Parts (plain values in, so they render without a player) + /// One row in the chapter list: title, start time, hover highlight, seek on tap. -private struct ChapterRow: View { +/// No play glyph (`.rows li.nolead`); the current chapter takes the +/// playing-row sale yellow; every title is heavy 15 (so being current never +/// changes a row's weight) and rows are ruled in frost. +struct ChapterRow: View { let chapter: VideoChapter let isCurrent: Bool let seek: () -> Void @@ -456,27 +405,198 @@ private struct ChapterRow: View { var body: some View { Button(action: seek) { HStack(spacing: 8) { - Image(systemName: isCurrent ? "play.fill" : "play") - .font(.system(size: 10)) - .foregroundStyle(isCurrent ? theme.palette.accent : theme.palette.textSecondary) - .frame(width: 16, alignment: .center) Text(chapter.title) - .font(theme.type.body(13)) - .fontWeight(isCurrent ? .semibold : .regular) - .foregroundStyle(isCurrent ? theme.palette.accent : theme.palette.textPrimary) + .font(theme.type.title(15)) + .foregroundStyle(isCurrent ? theme.palette.playState : theme.palette.textPrimary) .lineLimit(1) Spacer(minLength: 8) Text(TransportBar.format(seconds: chapter.startSeconds)) - .font(theme.type.numeric(12)) + .font(theme.type.numeric(13.5)) .foregroundStyle(theme.palette.textSecondary) } .contentShape(Rectangle()) } .buttonStyle(.plain) - .padding(.vertical, 5) + .padding(.vertical, 10) .padding(.horizontal, 6) .background(hovering ? theme.palette.raised : .clear, in: RoundedRectangle(cornerRadius: 5)) + .overlay(alignment: .top) { + Rectangle().fill(theme.palette.hairline).frame(height: 1) + } .onHover { hovering = $0 } } } + +/// The badge over the player when the stream is live: the `Live` label +/// sticker. +struct VideoLiveBadge: View { + var body: some View { + LabelSticker("Live") + .padding(10) + } +} + +/// Play/Pause, Go Live (live streams only) and Save. Play/Pause is the snow +/// primary, Go Live a frost ring that says "Go Live" while it can act and the +/// copy's `liveEdgeDisabled` while it has nothing to do, and Save the iPhone's +/// 36pt ring pushed to the trailing edge (a labeled frost button on the Mac). +struct VideoActionRow: View { + let isPlaying: Bool + let isLive: Bool + let atLiveEdge: Bool + let saved: Bool + let playPause: () -> Void + let goLive: () -> Void + let toggleSave: () -> Void + + @Environment(\.theme) private var theme + + var body: some View { + HStack(spacing: 8) { + Button(action: playPause) { + Label(isPlaying ? "Pause" : "Play", + systemImage: isPlaying ? "pause.fill" : "play.fill") + } + .buttonStyle(.yetiPrimary) + + if isLive { + Button(action: goLive) { + Label(atLiveEdge ? theme.copy.liveEdgeDisabled : "Go Live", + systemImage: "dot.radiowaves.left.and.right") + } + .disabled(atLiveEdge) + .buttonStyle(.yetiSecondary) + } + + Spacer(minLength: 0) + + saveButton + } + } + + private var saveButton: some View { + Button(action: toggleSave) { + Label(saved ? "Saved" : "Save", systemImage: saved ? "star.fill" : "star") + } + // Saving isn't time moving: never the accent. + .yetiPageAction() + .help(saved ? "Remove from Favorites" : "Save video to Favorites") + } +} + +/// PPV's locked panel and its buy button. The panel is card stock with a frost keyline and a slush lock, and "Buy on nugs.net" +/// is the full-width snow primary, its outbound arrow saying it leaves the app. +struct PayPerViewPanel: View { + let buy: () -> Void + + @Environment(\.theme) private var theme + + /// `.ppv`: card stock, 14pt corners, a 1pt frost keyline. + var body: some View { + let shape = RoundedRectangle(cornerRadius: 14, style: .continuous) + return VStack(alignment: .leading, spacing: 16) { + shape + .fill(theme.palette.cardStock) + .overlay { shape.strokeBorder(theme.palette.hairline, lineWidth: 1) } + .aspectRatio(16.0/9.0, contentMode: .fit) + .overlay { + VStack(spacing: 6) { + Image(systemName: "lock") + .font(.system(size: 28, weight: .semibold)) + .foregroundStyle(theme.palette.textSecondary) + .accessibilityHidden(true) + Text("Pay-per-view") + .font(theme.type.title(19)) + .foregroundStyle(theme.palette.textPrimary) + Text("Not part of your subscription") + .font(theme.type.body(14)) + .foregroundStyle(theme.palette.textSecondary) + } + .multilineTextAlignment(.center) + .accessibilityElement(children: .combine) + } + Button(action: buy) { + Label("Buy on nugs.net", systemImage: "arrow.up.right.square") + .frame(maxWidth: .infinity) + } + .buttonStyle(.yetiPrimary) + .controlSize(.large) + } + } +} + +/// A load or playback error, said, not colored: a slush mark, a heavy snow +/// title and the slush reason, with no triangle; over the player it sits on +/// the sheet's near-black. +struct VideoErrorMessage: View { + let title: String + let reason: String + var overPlayer = false + + @Environment(\.theme) private var theme + + var body: some View { + VStack(spacing: 8) { + Image(systemName: "exclamationmark.circle") + .font(.system(size: 28, weight: .semibold)) + .foregroundStyle(theme.palette.textSecondary) + .accessibilityHidden(true) + Text(title) + .font(theme.type.title(17)) + .foregroundStyle(theme.palette.textPrimary) + Text(reason) + .font(theme.type.body(13.5)) + .foregroundStyle(theme.palette.textSecondary) + .lineLimit(3) + } + .multilineTextAlignment(.center) + .padding(16) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background { if overPlayer { theme.palette.videoErrorBackdrop } } + .accessibilityElement(children: .combine) + } +} + +/// Benefit/donation notes as attributed markdown. Every link run carries snow +/// and a dotted underline explicitly (SwiftUI honors a run's own color over +/// the link tint), on slush body text. Attributes are set with the +/// `AttributeScopes` key subscript, not the dynamic-member form, which warns +/// under strict concurrency. +struct BenefitNotesText: View { + let markdown: String + + @Environment(\.theme) private var theme + + var body: some View { + Group { + if let attributed = styled() { + Text(attributed) + } else { + Text(markdown) + } + } + .font(theme.type.body(14)) + .foregroundStyle(theme.palette.textSecondary) + .tint(theme.palette.textPrimary) + .textSelection(.enabled) + } + + /// The parsed markdown (nil if the parse fails), with the link styling + /// applied to every run that carries a link. + private func styled() -> AttributedString? { + guard var attributed = try? AttributedString( + markdown: markdown, + options: .init(interpretedSyntax: .inlineOnlyPreservingWhitespace)) else { return nil } + let linkRanges = attributed.runs + .filter { $0[AttributeScopes.FoundationAttributes.LinkAttribute.self] != nil } + .map(\.range) + for range in linkRanges { + attributed[range][AttributeScopes.SwiftUIAttributes.ForegroundColorAttribute.self] = + theme.palette.textPrimary + attributed[range][AttributeScopes.SwiftUIAttributes.UnderlineStyleAttribute.self] = + Text.LineStyle(pattern: .dot, color: theme.palette.textPrimary) + } + return attributed + } +} diff --git a/AppleNugs/Views/VideosView.swift b/AppleNugs/Views/VideosView.swift index 05d9b21..ea2e802 100644 --- a/AppleNugs/Views/VideosView.swift +++ b/AppleNugs/Views/VideosView.swift @@ -46,7 +46,6 @@ struct VideosView: View { .frame(maxWidth: .infinity, alignment: .leading) } } - .reportsScrollForPillDuck() .background(theme.palette.base) .navigationTitle("Videos") .compactNavigationTitle() @@ -55,11 +54,11 @@ struct VideosView: View { // --- section title (shared idiom) -------------------------------------- + /// Section names come from the copy (the barker voice), set as `.sec h3`, + /// heavy 22, like Home's headers. private func sectionTitle(_ text: String) -> some View { - let condensed = theme.caps.contains(.condensedHeaders) - return Text(condensed ? text.uppercased() : text) - .font(theme.type.section(17)) - .tracking(condensed ? 1.4 : 0) + Text(text) + .font(theme.type.section(22)) .foregroundStyle(theme.palette.textPrimary) } @@ -69,7 +68,7 @@ struct VideosView: View { private var continueWatchingSection: some View { VStack(alignment: .leading, spacing: 12) { - sectionTitle("Continue Watching") + sectionTitle(theme.copy.videosResume) ScrollView(.horizontal, showsIndicators: false) { LazyHStack(alignment: .top, spacing: 16) { ForEach(continueWatching) { progress in @@ -93,6 +92,8 @@ struct VideosView: View { } } + /// `.prog`: a frost track with the accent fill. The accent is foil, and + /// that is right here: resume progress moves through time. private func resumeBar(_ progress: VideoProgress) -> some View { GeometryReader { geo in ZStack(alignment: .leading) { @@ -116,7 +117,7 @@ struct VideosView: View { // first render. private var liveSection: some View { VStack(alignment: .leading, spacing: 12) { - sectionTitle("Live & Upcoming") + sectionTitle(theme.copy.videosLive) ScrollView(.horizontal, showsIndicators: false) { LazyHStack(alignment: .top, spacing: 16) { ForEach(webcasts) { video in @@ -131,9 +132,11 @@ struct VideosView: View { private var recentExclusivesSection: some View { VStack(alignment: .leading, spacing: 12) { - sectionTitle("Recent Exclusives") - Text("Recently streamed — replays and free links.") - .font(theme.type.body(13)) + sectionTitle(theme.copy.videosExclusives) + // The header already says "Just streamed", so the line drops the + // "Recently streamed" lead-in (as the surfaces sheet draws it). + Text("Replays and free links.") + .font(theme.type.body(14)) .foregroundStyle(theme.palette.textSecondary) .padding(.top, -6) ScrollView(.horizontal, showsIndicators: false) { @@ -210,19 +213,11 @@ struct VideosView: View { // --- empty / error states ---------------------------------------------- + /// The empty pack (`EmptyPackMessage`), the app before anything has + /// arrived. private var emptyState: some View { - VStack(spacing: 8) { - Image(systemName: "play.rectangle") - .font(.system(size: 34)) - .foregroundStyle(theme.palette.accent) - Text("No videos right now") - .font(theme.type.hero(22)) - .foregroundStyle(theme.palette.textPrimary) - Text("Recently added concerts and webcasts will show up here.") - .font(theme.type.body(13)) - .foregroundStyle(theme.palette.textSecondary) - .multilineTextAlignment(.center) - } + EmptyPackMessage(title: "No videos right now", + message: "Recently added concerts and webcasts will show up here.") } private func errorState(_ message: String) -> some View { diff --git a/AppleNugs/Views/ViewCompat.swift b/AppleNugs/Views/ViewCompat.swift index 9bba196..f8d6b22 100644 --- a/AppleNugs/Views/ViewCompat.swift +++ b/AppleNugs/Views/ViewCompat.swift @@ -5,6 +5,20 @@ import SwiftUI // `#if` wrapped around it. extension View { + /// On iOS, removes the navigation bar's visible title while keeping + /// `navigationTitle` itself — the next screen's back button and its + /// long-press history still read it. For a screen whose header already + /// prints the same name in large type (the artist page), so the bar shows + /// only the back button. Unchanged on macOS, where the title is the + /// window's. + func hidesNavigationBarTitle() -> some View { + #if os(iOS) + return toolbar(removing: .title) + #else + return self + #endif + } + /// Inline navigation title on iOS; unchanged on macOS. /// /// A large title costs ~52pt of a phone and, on the tab roots, prints the @@ -19,29 +33,4 @@ extension View { return self #endif } - - /// Reports this scroll view's motion to the now-playing pill's duck - /// (`PillDuckScrollReporter`, iOS), and gives its content a bottom margin - /// that clears the pill. Apply it to the ScrollView/List itself on every - /// vertically scrolling tab root. Inert unless the pill is mounted as the - /// iPhone Duo overlay; a no-op on macOS. Pass `insetsContent: false` where - /// `clearsPillOverlay()` already pads an enclosing view. - func reportsScrollForPillDuck(insetsContent: Bool = true) -> some View { - #if os(iOS) - return modifier(PillDuckScrollReporter(insetsContent: insetsContent)) - #else - return self - #endif - } - - /// Extra bottom safe area clearing the iPhone Duo overlay pill, for a - /// screen whose bottom edge isn't just a scroll view (`PillOverlayClearance`, - /// iOS). Inert unless the overlay is mounted; a no-op on macOS. - func clearsPillOverlay() -> some View { - #if os(iOS) - return modifier(PillOverlayClearance()) - #else - return self - #endif - } } diff --git a/AppleNugs/iOS/AppleNugsiOSApp.swift b/AppleNugs/iOS/AppleNugsiOSApp.swift index 54bba1a..0577d50 100644 --- a/AppleNugs/iOS/AppleNugsiOSApp.swift +++ b/AppleNugs/iOS/AppleNugsiOSApp.swift @@ -5,13 +5,12 @@ import SwiftUI struct AppleNugsiOSApp: App { @State private var app = AppModel() @State private var ui: UIState - @State private var themes = ThemeManager() @Environment(\.scenePhase) private var scenePhase // Portrait-locked app, rotatable fullscreen video (see OrientationGate). @UIApplicationDelegateAdaptor(OrientationGate.self) private var orientationGate init() { - // Launch-arg tab/theme selection for layout screenshots under -UITEST + // Launch-arg tab selection for layout screenshots under -UITEST // (simctl cannot tap; XCUITest on iOS is future work). let ui = UIState() #if DEBUG @@ -25,13 +24,6 @@ struct AppleNugsiOSApp: App { default: break } } - let themes = ThemeManager() - if let i = ProcessInfo.processInfo.arguments.firstIndex(of: "-UITestTheme"), - ProcessInfo.processInfo.arguments.indices.contains(i + 1), - let id = ThemeID(rawValue: ProcessInfo.processInfo.arguments[i + 1]) { - themes.selected = id - } - _themes = State(initialValue: themes) #endif _ui = State(initialValue: ui) // Same CDN-artwork cache sizing as the Mac entry point: AsyncImage @@ -50,8 +42,6 @@ struct AppleNugsiOSApp: App { IOSRootView() .environment(app) .environment(ui) - .environment(themes) - .environment(\.theme, themes.theme) } .onChange(of: scenePhase) { _, phase in switch phase { diff --git a/AppleNugs/iOS/IOSRootView.swift b/AppleNugs/iOS/IOSRootView.swift index 4f75432..5763bbd 100644 --- a/AppleNugs/iOS/IOSRootView.swift +++ b/AppleNugs/iOS/IOSRootView.swift @@ -9,16 +9,11 @@ import UIKit /// because the accessory belongs to the TabView: it rides above pushed detail /// screens for free. The old inset decorated each stack's *root* view, so the /// bar vanished the moment anything was pushed. -/// -/// The one exception is a vertical tab bar (iPhone Duo): there the pill is -/// mounted as a TabView overlay that ducks on scroll — see `NowPlayingAccessory`. struct IOSRootView: View { @Environment(AppModel.self) private var app @Environment(UIState.self) private var ui - @Environment(ThemeManager.self) private var themes @Environment(\.theme) private var theme - @State private var artProvider = ArtColorProvider() // Starts presented under -UITestShowNowPlaying so layout screenshots can // reach the full-screen player without a tap (simctl cannot tap). @State private var nowPlayingPresented = @@ -27,9 +22,6 @@ struct IOSRootView: View { @State private var offlineShown = false /// The pill's expanded panel. Owned here, not by the pill — see the sheet. @State private var panelShown = false - /// The pill's duck on iPhone Duo (see PillDuck.swift). Written by the - /// scroll roots, read only by `PillDuckOverlay` — never by this body. - @State private var pillDuck = PillDuck() #if DEBUG /// The DEBUG Network Probe sheet (account menu). Starts presented under /// -UITestShowNetworkProbe so it can be screenshotted without a tap. @@ -40,19 +32,6 @@ struct IOSRootView: View { /// tallies, so the wipe is unrecoverable — it does not happen on one tap. @State private var confirmingStatsReset = false - /// The accent the chrome should use right now: the live art color when the - /// active theme is art-driven, else nil (so static themes are untouched). - private var activeArtColor: Color? { - theme.consumesArtColor ? artProvider.color : nil - } - - /// Refires the extractor when the track changes, when its art finishes - /// loading, or when the theme's appetite for art changes. - private var artTaskID: String { - let track = app.player.current?.id.uuidString ?? "none" - return "\(track)|\(app.player.nowPlayingImage != nil)|\(theme.consumesArtColor)" - } - var body: some View { Group { switch app.sessionState { @@ -68,18 +47,10 @@ struct IOSRootView: View { connectionFailedView(message) } } - .themed(theme, art: activeArtColor) - .environment(\.artColor, activeArtColor) - .environment(pillDuck) + .themed(theme) // One scene, so one install point; the cover, the panel and the // dashboard sheet nested inside them all inherit it. .nowPlayingMetaLinks(app: app, ui: ui) - .task(id: artTaskID) { - artProvider.update( - image: app.player.nowPlayingImage, - key: app.player.current?.artworkPath, - enabled: theme.consumesArtColor) - } .task { await app.bootstrap() } // Deep links (applenugs://show/…). Parse here; AppModel acts now if the // session is up, or stashes for the drain below. The now-playing cover @@ -106,19 +77,15 @@ struct IOSRootView: View { } private func connectionFailedView(_ message: String) -> some View { - ContentUnavailableView { - Label("Couldn't reach nugs.net", systemImage: "wifi.exclamationmark") - } description: { - Text(message) - } actions: { - Button("Retry") { Task { await app.retryBootstrap() } } - .buttonStyle(.borderedProminent) - // The whole point of downloads is being reachable right here. - if !app.downloads.manifest.shows.isEmpty { - Button("Listen Offline") { offlineShown = true } - } - Button("Sign Out") { app.logout() } - } + let hasDownloads = !app.downloads.manifest.shows.isEmpty + return ConnectionFailedView( + message: message, + hasDownloads: hasDownloads, + canPlayOffline: hasDownloads && OfflineAccessPolicy.allowsOfflinePlay( + lastVerifiedAt: OfflineVerification.lastVerifiedAt(), now: Date()), + onRetry: { Task { await app.retryBootstrap() } }, + onListenOffline: { offlineShown = true }, + onSignOut: { app.logout() }) .frame(maxWidth: .infinity, maxHeight: .infinity) .sheet(isPresented: $offlineShown) { NavigationStack { @@ -185,6 +152,11 @@ struct IOSRootView: View { app: app, onTap: { nowPlayingPresented = true }, onExpand: { panelShown = true })) + // The Home hero (Yeti) opens the same cover the pill's tap does — + // one presentation, one owner. + .environment(\.openNowPlaying, OpenNowPlayingAction { [presented = $nowPlayingPresented] in + presented.wrappedValue = true + }) .dismissesOnNavigation($nowPlayingPresented, ui: ui) .fullScreenCover(isPresented: $nowPlayingPresented) { // On iPhone Duo's inner display the player sits beside its queue; @@ -256,6 +228,13 @@ struct IOSRootView: View { ToolbarItem(placement: .topBarTrailing) { accountMenu } } } + // System API, not custom chrome: the default (`.automatic`) drew no + // bottom edge effect under the accessory where the tab bar is vertical + // (iPhone Duo, folded), so content ran crisp through and below the + // pill. `.soft` is what horizontal-bar iPhones already get; they render + // unchanged. On the NavigationStack, not its root, so it reaches every + // pushed screen (artist, show, video) as well. + .scrollEdgeEffectStyle(.soft, for: .bottom) .overlay(alignment: .bottom) { toastOverlay } } @@ -264,17 +243,6 @@ struct IOSRootView: View { if case .loggedIn(let plan) = app.sessionState, let plan { Text(plan) } - Picker("Theme", selection: Binding( - get: { themes.selected }, - set: { newValue in - withAnimation(.easeInOut(duration: 0.35)) { themes.selected = newValue } - } - )) { - ForEach(ThemeID.allCases) { id in - Text(id.displayName).tag(id) - } - } - .pickerStyle(.menu) #if DEBUG Divider() Button("Network Probe (Debug)") { probeShown = true } @@ -284,7 +252,7 @@ struct IOSRootView: View { confirmingStatsReset = true } Divider() - Button("Log Out") { app.logout() } + Button("Sign Out") { app.logout() } // iOS has no About panel, so the disclaimer the Mac carries in // Credits.rtf lives here: non-interactive rows, like the plan above. // Two short lines rather than one long one — menu rows truncate. @@ -315,12 +283,7 @@ struct IOSRootView: View { @ViewBuilder private var toastOverlay: some View { if let toast = ui.toast { - Text(toast) - .font(.callout.weight(.medium)) - .padding(.horizontal, 14) - .padding(.vertical, 8) - .background(.regularMaterial, in: Capsule()) - .shadow(radius: 4) + ToastCapsule(text: toast.text, kind: toast.kind) // This overlay is attached to the NavigationStack itself, one // level above `content()` (see `tabStack` above) — but the // NavigationStack is still a Tab's content, and it's *being* @@ -334,100 +297,36 @@ struct IOSRootView: View { // same overlay by construction, so the same safe area and // padding apply there too, but that specific case wasn't // exercised with a live tap (see the report for why). - // Where the pill is hand-mounted over a vertical tab bar - // instead (iPhone Duo) nothing excludes it from the safe area, - // so `ToastPillClearance` adds the pill's rise above it. - .modifier(ToastPillClearance()) + .padding(.bottom, 8) .transition(.opacity) .allowsHitTesting(false) } } } -/// Hosts `NowPlayingPill` on the `TabView`: in the system bottom accessory -/// everywhere the tab bar is horizontal, and as our own ducking overlay where -/// it is laid out vertically (iPhone Duo — see PillDuck.swift). -/// -/// `\.toolbarVerticalEdge` is iOS 27.1+, so reading it needs a type of its own -/// behind `#available`; below 27.1 there is no vertical tab bar to detect. -private struct NowPlayingAccessory: ViewModifier { - let app: AppModel - let onTap: () -> Void - let onExpand: () -> Void - - func body(content: Content) -> some View { - if #available(iOS 27.1, *) { - content.modifier(VerticalBarReader(app: app, onTap: onTap, onExpand: onExpand)) - } else { - content.modifier(PillMount(verticalBar: false, app: app, - onTap: onTap, onExpand: onExpand)) - } - } -} - -/// Reads the tab bar's orientation at the TabView (verified to report there, -/// in tab content, and in the pill): `.trailing` on iPhone Duo wherever the bar -/// runs down the side, nil everywhere else — including the Duo's inner display -/// whenever its bar is horizontal. -@available(iOS 27.1, *) -private struct VerticalBarReader: ViewModifier { - @Environment(\.toolbarVerticalEdge) private var verticalEdge - let app: AppModel - let onTap: () -> Void - let onExpand: () -> Void - - func body(content: Content) -> some View { - content.modifier(PillMount(verticalBar: verticalEdge != nil, app: app, - onTap: onTap, onExpand: onExpand)) - } -} - -/// One modifier chain for both mounts, so the gate flipping (a fold, or -/// playback starting) never changes the TabView's identity. +/// Hosts `NowPlayingPill` in the `TabView`'s bottom accessory — on every +/// iPhone, including iPhone Duo where the tab bar runs down the side. Tim's +/// call (2026-11-07): follow the system, not a custom overlay. The system's +/// scroll edge effect handles content under the bars, and `PillLayout` sheds +/// controls by measured width for the Duo's narrow (~290pt, still +/// `.expanded`) accessory. Don't fork the mount on `\.toolbarVerticalEdge` +/// again — see CLAUDE.md. /// /// `tabViewBottomAccessory(isEnabled:content:)` is the overload that hides the /// accessory entirely when nothing is playing; it is iOS 26.1+, which is why /// the deployment target is 26.1 rather than 26.0. The content-only overload /// (26.0) has no way to reclaim the slot, so an idle player would leave a 48pt /// gap above the tab bar. -/// -/// With a vertical bar the accessory is switched OFF and the pill is mounted -/// in the overlay instead. Toggling the accessory can't duck it: it pops (or -/// crossfades in place) and reflows the content's bottom safe area each time. -private struct PillMount: ViewModifier { - let verticalBar: Bool +private struct NowPlayingAccessory: ViewModifier { let app: AppModel let onTap: () -> Void let onExpand: () -> Void func body(content: Content) -> some View { - let playing = app.player.current != nil - let overlay = verticalBar && playing content - .tabViewBottomAccessory(isEnabled: playing && !verticalBar) { + .tabViewBottomAccessory(isEnabled: app.player.current != nil) { NowPlayingPill(onTap: onTap, onExpand: onExpand) } .tabBarMinimizeBehavior(.onScrollDown) - // Centered in the content column: the TabView's trailing safe area - // already excludes the vertical bar. - .overlay(alignment: .bottom) { - if overlay { - PillDuckOverlay(onTap: onTap, onExpand: onExpand) - } - } - .environment(\.pillOverlayActive, overlay) - } -} - -/// The toast's 8pt bottom gap, plus — while the pill is the hand-mounted -/// overlay (iPhone Duo, vertical tab bar) — the pill's rise above the safe -/// area, so the toast sits 8pt above the pill as it does above the accessory. -/// Deliberately not duck-aware: a toast that hopped down whenever the pill -/// ducked would move under the reader. Exactly 8 on every other device. -private struct ToastPillClearance: ViewModifier { - @Environment(\.pillOverlayActive) private var overlayActive - - func body(content: Content) -> some View { - content.padding(.bottom, 8 + (overlayActive ? PillDuckMetrics.contentClearance : 0)) } } diff --git a/AppleNugs/iOS/LibraryView.swift b/AppleNugs/iOS/LibraryView.swift index d4052f7..b951b7f 100644 --- a/AppleNugs/iOS/LibraryView.swift +++ b/AppleNugs/iOS/LibraryView.swift @@ -14,14 +14,9 @@ struct LibraryView: View { var body: some View { VStack(spacing: 0) { - Picker("Library section", selection: $segment) { - ForEach(Segment.allCases) { s in - Text(s.rawValue).tag(s) - } - } - .pickerStyle(.segmented) - .padding(.horizontal, 16) - .padding(.vertical, 8) + segmentPicker + .padding(.horizontal, 16) + .padding(.vertical, 8) switch segment { case .favorites: FavoritesView() @@ -36,4 +31,9 @@ struct LibraryView: View { .navigationTitle(segment == .favorites ? "Favorites" : "Downloads") .compactNavigationTitle() } + + private var segmentPicker: some View { + YetiSegmentedControl("Library section", selection: $segment, + options: Segment.allCases.map { .init(value: $0, title: $0.rawValue) }) + } } diff --git a/AppleNugs/iOS/NowPlayingColumns.swift b/AppleNugs/iOS/NowPlayingColumns.swift index 1785de2..6fd7566 100644 --- a/AppleNugs/iOS/NowPlayingColumns.swift +++ b/AppleNugs/iOS/NowPlayingColumns.swift @@ -23,9 +23,8 @@ enum NowPlayingColumns { case columns(queueWidth: CGFloat) } - /// The narrowest player column. Every player is made to fit this: the - /// Standard transport row and The Receiver's knurled row scale as a unit - /// below their natural widths, Click Wheel's pad already does. + /// The narrowest player column. The player is made to fit this: its + /// transport row scales as a unit below its natural width. static let minPlayerWidth: CGFloat = 300 /// The queue column's range. Below the minimum, track titles in Up Next /// truncate to a few words; above the maximum the extra width is better diff --git a/AppleNugs/iOS/NowPlayingPill/ExpandedPlayerPanel.swift b/AppleNugs/iOS/NowPlayingPill/ExpandedPlayerPanel.swift index 36f24f3..83de4e1 100644 --- a/AppleNugs/iOS/NowPlayingPill/ExpandedPlayerPanel.swift +++ b/AppleNugs/iOS/NowPlayingPill/ExpandedPlayerPanel.swift @@ -1,8 +1,8 @@ import SwiftUI /// The pill chevron's destination: everything the 48pt capsule cannot hold — -/// real scrub numerals with a thumb, the favourite star, AirPlay, the format -/// badge, and a way into the queue. (A drag-up gesture was tried first, but +/// real scrub numerals with a thumb, the favorite star, AirPlay, the format +/// badge (the price sticker), and a way into the queue. (A drag-up gesture was tried first, but /// it fought the tab bar's own scroll-to-minimize handling and was replaced /// by the chevron tap.) /// @@ -18,20 +18,26 @@ struct ExpandedPlayerPanel: View { private var player: PlayerService { app.player } var body: some View { - VStack(spacing: 12) { + // The 46pt play sticker in the transport row is taller than the glyph + // it replaced, so the stack keeps tight 6pt gaps to hold the whole + // panel inside the fixed 250pt detent. No price sticker here (Tim, + // 2026-11-07: next to the play sticker its weight competed with it): + // the quality is quiet text under the venue instead. + VStack(spacing: 6) { HStack(spacing: 10) { ArtChip(image: player.nowPlayingImage, fallbackText: player.current?.artist ?? "?", size: 44) VStack(alignment: .leading, spacing: 1) { Text(player.current?.title ?? "Nothing playing") - .font(theme.type.body(15).weight(.semibold)) + .font(theme.type.title(17)) .foregroundStyle(theme.palette.textPrimary) .lineLimit(1) NowPlayingMetaText(track: player.current, mode: .firstAvailable) .font(theme.type.body(12)) .foregroundStyle(theme.palette.textSecondary) .lineLimit(1) + qualityLine } .frame(maxWidth: .infinity, alignment: .leading) @@ -40,26 +46,19 @@ struct ExpandedPlayerPanel: View { PanelScrubRow() - PillTransportControls(controls: PillLayout.controls(for: .expanded)) + PillTransportControls(controls: PillLayout.controls(for: .expanded), + playControl: .sticker(.s46)) .font(.title2) Divider().overlay(theme.palette.hairline) HStack { - RoutePicker(tint: theme.palette.accent) + // AirPlay isn't time moving: slush, not foil. + RoutePicker(tint: theme.palette.textSecondary) .frame(width: 26, height: 26) .accessibilityLabel("AirPlay") Spacer() - if let pick = player.nowPick { - Text(pick.format.badge) - .font(theme.type.numeric(10).weight(.semibold)) - .foregroundStyle(theme.palette.textSecondary) - } - Spacer() - Button("Queue") { dashboardShown = true } - .font(theme.type.body(12)) - .foregroundStyle(theme.palette.accent) - .accessibilityHint("Opens the queue and stream details") + queueButton } } .padding(.horizontal, 18) @@ -75,13 +74,41 @@ struct ExpandedPlayerPanel: View { } } + /// "Lossless 16/44" in quiet slush text, from the same policy as the price + /// sticker: presence follows `player.current`, never `nowPick` (which is + /// nil'd on every track change), so it reads "Lossless" until the stream + /// is known instead of blinking. Always one line tall — a blank line when + /// hidden (a stream known to be lossy) — so the panel never reflows. + private var qualityLine: some View { + let label = PriceStickerLabel(player: player) + let text = label.map { [$0.title, $0.spec].compactMap { $0 }.joined(separator: " ") } ?? " " + return Text(text) + .font(theme.type.numeric(11)) + .foregroundStyle(theme.palette.textSecondary) + .lineLimit(1) + .accessibilityLabel(label?.accessibilityLabel ?? "") + .accessibilityHidden(label == nil) + } + + /// A frost-outlined button (never filled or tinted with the accent). + private var queueButton: some View { + Button { dashboardShown = true } label: { + Label("Queue", systemImage: "list.bullet") + } + .buttonStyle(.yetiSecondary) + .controlSize(.small) + .accessibilityHint("Opens the queue and stream details") + } + private var favoriteStar: some View { let saved = NowPlayingFavorite.isSaved(player.current, favorites: app.favorites) return Button { NowPlayingFavorite.toggle(player.current, favorites: app.favorites) } label: { Image(systemName: saved ? "star.fill" : "star") - .foregroundStyle(saved ? theme.palette.accent : theme.palette.textSecondary) + // A saved show isn't time moving: the filled star is snow, + // not an accent fill. + .foregroundStyle(saved ? theme.palette.textPrimary : theme.palette.textSecondary) } .buttonStyle(.plain) .disabled(player.current?.showId == nil) diff --git a/AppleNugs/iOS/NowPlayingPill/PillDuck.swift b/AppleNugs/iOS/NowPlayingPill/PillDuck.swift deleted file mode 100644 index 2bd2d14..0000000 --- a/AppleNugs/iOS/NowPlayingPill/PillDuck.swift +++ /dev/null @@ -1,231 +0,0 @@ -import SwiftUI - -// The now-playing pill's duck on iPhone Duo. -// -// Where the tab bar is laid out VERTICALLY (`\.toolbarVerticalEdge` non-nil), -// `.tabBarMinimizeBehavior(.onScrollDown)` never moves the bottom accessory out -// of the content's way. Toggling `tabViewBottomAccessory(isEnabled:)` can't -// stand in for it either — it pops (or crossfades in place) and every toggle -// reflows the tab content's bottom safe area by 38–56pt, mid-scroll, under the -// thumb. So there, and only there, the shell disables the accessory and mounts -// the pill as its own overlay (`PillDuckOverlay`), which sinks below the bottom -// edge while the user scrolls down and springs back when they scroll up, reach -// the top, or stop for a second. -// -// Pieces: `PillDuckPolicy` (pure, tested) decides; `PillDuck` holds the one -// observable flag plus the idle timer; `PillDuckScrollReporter` feeds it from -// every scrolling tab root; `PillDuckOverlay` is the only view that reads the -// flag. Regular iPhones — and the Duo wherever its bar is horizontal — never -// leave the system accessory. - -extension EnvironmentValues { - /// True while the pill is mounted as our own overlay: the tab bar is - /// vertical AND something is playing. Set once at the TabView by - /// `NowPlayingAccessory`. Changes only when either of those does, never on - /// a duck — the content clearance keyed on it must not reflow mid-scroll. - @Entry var pillOverlayActive = false -} - -/// Overlay geometry, measured against the system accessory on iPhone Duo's -/// outer display (probe: the accessory capsule spans y 606–654 in a 678pt -/// screen whose bottom safe-area edge is at 644, x 50–340 in a 382pt column). -enum PillDuckMetrics { - /// The system accessory's height (system-owned; 48 on every device measured). - static let height: CGFloat = 48 - - /// The system accessory dips this far BELOW the bottom safe-area edge. - /// The overlay is laid out against that edge, then pushed down to match. - static let overhang: CGFloat = 10 - - /// Horizontal inset inside the content column: 382 − 2×46 = the system's - /// 290pt on the outer display. - static let horizontalInset: CGFloat = 46 - - /// Never wider than the system accessory on a regular iPhone. - static let maxWidth: CGFloat = 360 - - /// How far the pill's top rises above the bottom safe-area edge, and so - /// the extra bottom margin scroll content gets while the overlay is - /// mounted — the same clearance the system accessory gives (outer display: - /// bottom safe area 34 → 72). The toast adds it to its 8pt gap. - static let contentClearance: CGFloat = height - overhang - - /// How far the pill sinks when ducked. Clears the pill plus any bottom - /// safe area up to ~80pt; past the screen edge the excess is invisible. - static let duckDrop: CGFloat = 130 -} - -/// The pill's ducked flag, owned by `IOSRootView` and shared through the -/// environment. Writes happen only on a transition, never per scroll frame — -/// the scroll bookkeeping lives in `@ObservationIgnored` storage. -@MainActor -@Observable -final class PillDuck { - private(set) var isDucked = false - - @ObservationIgnored private var state = PillDuckPolicy.State.shown - @ObservationIgnored private var scrollIsIdle = true - @ObservationIgnored private var idleReturn: Task? - - /// How long after scrolling stops a ducked pill comes back. - static let idleDelay: Duration = .seconds(1) - - func scrolled(from old: PillDuckPolicy.Sample, to new: PillDuckPolicy.Sample) { - state = PillDuckPolicy.step(state, from: old, to: new) - publish() - } - - func scrollPhaseChanged(isIdle: Bool) { - scrollIsIdle = isIdle - scheduleIdleReturn() - } - - /// Bring the pill back now and forget any partial travel. The idle timer - /// comes through here too, so the policy's state is reset along with the - /// flag — otherwise it would still think it was ducked and the next - /// downward scroll would have nothing to re-accumulate. - func showNow() { - idleReturn?.cancel() - idleReturn = nil - state = .shown - publish() - } - - private func publish() { - let ducked = state.visibility == .ducked - guard ducked != isDucked else { return } - isDucked = ducked - // Ducked by a scroll that is already settling (a programmatic scroll, - // or the last frame of a deceleration landing after `.idle`). - if ducked { scheduleIdleReturn() } - } - - private func scheduleIdleReturn() { - idleReturn?.cancel() - idleReturn = nil - guard scrollIsIdle, isDucked else { return } - idleReturn = Task { [weak self] in - try? await Task.sleep(for: Self.idleDelay) - guard !Task.isCancelled else { return } - self?.showNow() - } - } -} - -/// Feeds a scroll view's motion to `PillDuck`. Applied through -/// `View.reportsScrollForPillDuck()` (shared, a no-op on macOS) to every -/// vertically scrolling tab root. -/// -/// Always attached — never conditionally — so a fold/unfold or a playback -/// start can't change the scroll view's identity (which would reset its -/// position and its owner's `@State`). While the overlay is inactive the -/// geometry transform returns a constant, so the action fires at most once. -/// -/// Also gives the scroll view a constant extra bottom safe area while the -/// overlay is mounted, so the last row can scroll clear of the pill. The -/// hand-mounted overlay doesn't inset content the way the system accessory -/// does, and a `.safeAreaPadding` on the tab's NavigationStack does NOT reach -/// the hosted scroll views (measured: bottom inset stayed 34), so it is applied -/// on each scroll view, where it lands as a real content inset on both -/// ScrollView and List (measured: 72, the accessory's own figure). -/// `.contentMargins` also reaches a List, but folds into its `contentSize` -/// instead of its insets. Keyed on `pillOverlayActive` alone — -/// it reflows at playback start/stop exactly as the accessory would, and -/// NEVER on a duck. An empty edge set when inactive, so regular iPhones get -/// no margin effect at all. -struct PillDuckScrollReporter: ViewModifier { - /// False where the caller clears the pill some other way (Downloads pads - /// the list AND its storage footer with `clearsPillOverlay()` instead). - let insetsContent: Bool - - @Environment(PillDuck.self) private var duck: PillDuck? - @Environment(\.pillOverlayActive) private var overlayActive - @Environment(\.accessibilityVoiceOverEnabled) private var voiceOver - - func body(content: Content) -> some View { - // VoiceOver users never get a pill that runs away from them. - let active = overlayActive && !voiceOver && duck != nil - content - .safeAreaPadding(overlayActive && insetsContent ? .bottom : [], - PillDuckMetrics.contentClearance) - .onScrollGeometryChange(for: PillDuckPolicy.Sample.self) { geometry in - guard active else { return .inactive } - return PillDuckPolicy.Sample( - contentOffsetY: geometry.contentOffset.y, - insetTop: geometry.contentInsets.top, - insetBottom: geometry.contentInsets.bottom, - contentHeight: geometry.contentSize.height, - frameHeight: geometry.visibleRect.height) - } action: { old, new in - guard active else { return } - duck?.scrolled(from: old, to: new) - } - .onScrollPhaseChange { _, phase in - guard active else { return } - duck?.scrollPhaseChanged(isIdle: phase == .idle) - } - // Pushed away (or tab switched) mid-fling: the phase may never - // reach `.idle` here, so treat leaving as stopping. - .onDisappear { duck?.scrollPhaseChanged(isIdle: true) } - } -} - -/// Extra bottom safe area for a screen whose bottom isn't (only) a scroll view -/// — Downloads' storage footer sits below its list — while the overlay is -/// mounted. Same key and same reflow rules as the reporter's margin above. -struct PillOverlayClearance: ViewModifier { - @Environment(\.pillOverlayActive) private var overlayActive - - func body(content: Content) -> some View { - content.safeAreaPadding(overlayActive ? .bottom : [], - PillDuckMetrics.contentClearance) - } -} - -/// The pill, mounted by hand over the TabView while the tab bar is vertical. -/// The only view that reads `PillDuck.isDucked`, so a duck re-evaluates this -/// and nothing above it. -struct PillDuckOverlay: View { - @Environment(PillDuck.self) private var duck - @Environment(\.accessibilityReduceMotion) private var reduceMotion - @Environment(\.accessibilityVoiceOverEnabled) private var voiceOver - - let onTap: () -> Void - let onExpand: () -> Void - - var body: some View { - // VoiceOver turning on while ducked brings the pill straight back. - let ducked = duck.isDucked && !voiceOver - let moves = ducked && !reduceMotion - - // `tabViewBottomAccessoryPlacement` is nil out here; PillLayout reads - // nil as `.expanded` and still sheds controls by measured width. - NowPlayingPill(onTap: onTap, onExpand: onExpand) - .frame(height: PillDuckMetrics.height) - .glassEffect(in: Capsule()) - .frame(maxWidth: PillDuckMetrics.maxWidth) - .padding(.horizontal, PillDuckMetrics.horizontalInset) - // Squash on the way down (anchored at the bottom, so it flattens - // into the edge it's sinking past); the return spring overshoots - // through 1.0, so it briefly stretches tall before settling. - .scaleEffect(x: moves ? 1.06 : 1, y: moves ? 0.82 : 1, anchor: .bottom) - .offset(y: PillDuckMetrics.overhang + (moves ? PillDuckMetrics.duckDrop : 0)) - // Reduce Motion: no travel, no squash — a plain fade. - .opacity(ducked && reduceMotion ? 0 : 1) - .animation(animation(ducked: ducked), value: ducked) - // Out of reach while ducked: no taps on an off-screen pill, and - // nothing for VoiceOver focus to land on and get stranded by. - .allowsHitTesting(!ducked) - .accessibilityHidden(ducked) - // A pill re-mounted (playback restarted, or the bar went vertical - // again) never inherits a stale duck. - .onAppear { duck.showNow() } - } - - private func animation(ducked: Bool) -> Animation { - if reduceMotion { return .easeInOut(duration: 0.2) } - return ducked - ? .easeIn(duration: 0.22) - : .spring(response: 0.42, dampingFraction: 0.58) - } -} diff --git a/AppleNugs/iOS/NowPlayingPill/PillDuckPolicy.swift b/AppleNugs/iOS/NowPlayingPill/PillDuckPolicy.swift deleted file mode 100644 index 8fc55be..0000000 --- a/AppleNugs/iOS/NowPlayingPill/PillDuckPolicy.swift +++ /dev/null @@ -1,112 +0,0 @@ -import Foundation - -/// Scroll-direction policy for the now-playing pill's duck on iPhone Duo. -/// -/// Where the tab bar is laid out vertically, the system's -/// `.tabBarMinimizeBehavior(.onScrollDown)` never gets the pill out of the -/// content's way, so the shell mounts the pill itself and ducks it below the -/// bottom edge while the user scrolls down. This decides WHEN, from successive -/// scroll samples; the view layer decides how it looks, and owns the idle timer -/// that brings the pill back once scrolling stops. -/// -/// Deliberately SwiftUI-free so it compiles straight into the host-free -/// `AppleNugsTests` bundle (same arrangement as `PillLayout`). -enum PillDuckPolicy { - - enum Visibility: Equatable { - case shown - case ducked - } - - /// One scroll position, normalized so `offset == 0` is the true top of the - /// content (insets already accounted for) and `maxOffset` is the furthest - /// the content can scroll. `maxOffset <= 0` means the content fits and - /// does not scroll at all. - struct Sample: Equatable { - var offset: CGFloat - var maxOffset: CGFloat - - /// A constant every scroll view reports while the duck is inactive, so - /// `onScrollGeometryChange`'s action fires at most once. - static let inactive = Sample(offset: 0, maxOffset: 0) - - init(offset: CGFloat, maxOffset: CGFloat) { - self.offset = offset - self.maxOffset = maxOffset - } - - /// Builds a sample from raw `ScrollGeometry`. `contentOffset` starts - /// NEGATIVE under a navigation bar (it is measured from the content's - /// origin, which sits below the top inset), so the top inset is added - /// back to put the resting position at 0. - /// - /// `frameHeight` must be `visibleRect.height` — the scroll view's full - /// frame — NOT `containerSize.height`: measured on iPhone Duo's outer - /// display, a ScrollView reports its container with the insets already - /// taken out (visible 678, container 524 = 678 − 82 − 72) while a List - /// reports them included (visible = container = 542, bottom inset 34). - /// Against the frame, UIKit's own arithmetic holds for both. - init(contentOffsetY: CGFloat, insetTop: CGFloat, insetBottom: CGFloat, - contentHeight: CGFloat, frameHeight: CGFloat) { - offset = contentOffsetY + insetTop - maxOffset = contentHeight + insetTop + insetBottom - frameHeight - } - } - - /// `travel` is progress toward flipping, always >= 0: downward points - /// accumulated while shown, upward points accumulated while ducked. - struct State: Equatable { - var visibility: Visibility = .shown - var travel: CGFloat = 0 - - static let shown = State() - } - - /// Downward travel, uninterrupted by any upward movement, before the pill - /// ducks. Big enough that a finger settling on the glass doesn't flap it. - static let duckTravel: CGFloat = 24 - - /// Upward travel, uninterrupted by any downward movement, before a ducked - /// pill comes back. Smaller than `duckTravel` on purpose: getting playback - /// back should be easier than sending it away. - static let showTravel: CGFloat = 12 - - /// Within this distance of the top the pill is always shown. - static let topZone: CGFloat = 8 - - static func step(_ state: State, from old: Sample, to new: Sample) -> State { - // Content that doesn't scroll never ducks, and the top always shows - // (including the rubber-band overscroll above it). A non-scrolling - // `old` carries no motion either: it is either `Sample.inactive` (the - // gate just switched on — playback started, or the Duo folded — over - // a list already scrolled 500pt down, which must not read as 500pt of - // travel) or content that has only now grown tall enough to scroll. - guard new.maxOffset > 0, new.offset > topZone, old.maxOffset > 0 else { - return .shown - } - - // Clamp each sample to its own scrollable range, so the rubber band - // past the bottom — and its spring back — is not mistaken for travel, - // and neither is the content growing under a resting offset (a page - // of results arriving at the bottom). - let delta = clamp(new.offset, new.maxOffset) - clamp(old.offset, old.maxOffset) - guard delta != 0 else { return state } - - switch state.visibility { - case .shown: - let travel = delta > 0 ? state.travel + delta : 0 - return travel >= duckTravel - ? State(visibility: .ducked, travel: 0) - : State(visibility: .shown, travel: travel) - case .ducked: - let travel = delta < 0 ? state.travel - delta : 0 - return travel >= showTravel - ? .shown - : State(visibility: .ducked, travel: travel) - } - } - - private static func clamp(_ offset: CGFloat, _ maxOffset: CGFloat) -> CGFloat { - min(max(offset, 0), max(maxOffset, 0)) - } -} diff --git a/AppleNugs/iOS/NowPlayingPill/PillLayout.swift b/AppleNugs/iOS/NowPlayingPill/PillLayout.swift index f71412c..40dfd84 100644 --- a/AppleNugs/iOS/NowPlayingPill/PillLayout.swift +++ b/AppleNugs/iOS/NowPlayingPill/PillLayout.swift @@ -3,7 +3,7 @@ import Foundation /// Layout policy for the iOS now-playing pill. /// /// Deliberately SwiftUI-free so it compiles straight into the host-free -/// `AppleNugsTests` bundle (the same trick `TapeGeometry` uses). Every number +/// `AppleNugsTests` bundle (the same trick `NowPlayingFit` uses). Every number /// here comes from a real measurement of `tabViewBottomAccessory` on iOS 27, /// iPhone 17 Pro: 360x48pt expanded, 284x48pt inline. The system owns that /// height — there is no modifier to change it — so the pill has exactly one diff --git a/AppleNugs/iOS/NowPlayingPill/PillProgressRing.swift b/AppleNugs/iOS/NowPlayingPill/PillProgressRing.swift index 3437d2d..ce78270 100644 --- a/AppleNugs/iOS/NowPlayingPill/PillProgressRing.swift +++ b/AppleNugs/iOS/NowPlayingPill/PillProgressRing.swift @@ -19,7 +19,6 @@ import SwiftUI struct PillProgressRing: View { @Environment(AppModel.self) private var app @Environment(\.theme) private var theme - @Environment(\.artColor) private var artColor var body: some View { // Empty, never full, when the stream reports no duration — see @@ -46,7 +45,7 @@ struct PillProgressRing: View { // two arcs sit on different radii. .inset(by: PillLayout.ringLineWidth / 2) .trim(from: 0, to: fraction) - .stroke(theme.effectiveAccent(art: artColor), + .stroke(theme.palette.accent, style: StrokeStyle(lineWidth: PillLayout.ringLineWidth, lineCap: .round)) // Trim starts at 3 o'clock; rotate so the arc grows diff --git a/AppleNugs/iOS/NowPlayingPill/PillTransportControls.swift b/AppleNugs/iOS/NowPlayingPill/PillTransportControls.swift index b74488c..bee1c28 100644 --- a/AppleNugs/iOS/NowPlayingPill/PillTransportControls.swift +++ b/AppleNugs/iOS/NowPlayingPill/PillTransportControls.swift @@ -12,6 +12,15 @@ struct PillTransportControls: View { let controls: [PillLayout.Control] + /// How the play/pause control draws. The pill always uses `.glyph`; the + /// expanded panel passes `.sticker` (the play sticker, which is wider than + /// the pill's control slot, so it keeps its own frame). + enum PlayControl: Equatable { + case glyph + case sticker(PlaySticker.Size) + } + var playControl: PlayControl = .glyph + private var player: PlayerService { app.player } var body: some View { @@ -46,7 +55,13 @@ struct PillTransportControls: View { case .playPause: HapticButton(.transportToggle) { player.togglePlayPause() } label: { - if player.isBuffering { + if case .sticker(let size) = playControl { + // The spinner sits inside the sticker's own disc, so the + // control never changes size while buffering. + PlaySticker(isPlaying: player.isPlaying, + isBuffering: player.isBuffering, + size: size) + } else if player.isBuffering { ProgressView() .controlSize(.small) .frame(width: PillLayout.controlWidth) diff --git a/AppleNugs/macOS/AppleNugsApp.swift b/AppleNugs/macOS/AppleNugsApp.swift index 0a236d4..c85b3b2 100644 --- a/AppleNugs/macOS/AppleNugsApp.swift +++ b/AppleNugs/macOS/AppleNugsApp.swift @@ -5,7 +5,6 @@ import Sparkle struct AppleNugsApp: App { @State private var app = AppModel() @State private var ui = UIState() - @State private var themes = ThemeManager() #if DEBUG @NSApplicationDelegateAdaptor(UITestAppDelegate.self) private var uiTestDelegate #endif @@ -42,8 +41,6 @@ struct AppleNugsApp: App { RootView() .environment(app) .environment(ui) - .environment(themes) - .environment(\.theme, themes.theme) // Window minimum width is managed by WindowMinSizeUpdater in // RootView — it sets NSWindow.minSize directly so it correctly // accounts for the inspector column (250pt) when it is open. @@ -85,19 +82,6 @@ struct AppleNugsApp: App { } .disabled(!updaterModel.canCheckForUpdates) } - CommandGroup(after: .toolbar) { - Picker("Theme", selection: Binding( - get: { themes.selected }, - set: { newValue in - withAnimation(.easeInOut(duration: 0.35)) { themes.selected = newValue } - } - )) { - ForEach(ThemeID.allCases) { id in - Text(id.displayName).tag(id) - } - } - Divider() - } CommandMenu("Playback") { Button(app.player.isPlaying ? "Pause" : "Play") { app.player.togglePlayPause() @@ -114,8 +98,14 @@ struct AppleNugsApp: App { Divider() - Button("Clear Queue") { app.player.clear() } - .disabled(app.player.queue.isEmpty) + // Confirms first, like the ⋯ menu and the dashboard's Clear — + // but with an AppKit alert, not a SwiftUI dialog: a menu + // command has no view of its own, and borrowing one window's + // root fails whenever that window is closed, hidden or busy. + Button("Clear Queue") { + ClearQueueAlert.present { app.player.clear() } + } + .disabled(app.player.queue.isEmpty) } CommandGroup(after: .sidebar) { Button("Search") { ui.requestSearchFocus() } @@ -141,9 +131,7 @@ struct AppleNugsApp: App { NowPlayingScreen() .environment(app) .environment(ui) - .environment(themes) - .environment(\.theme, themes.theme) - .providesArtColor(app: app, theme: themes.theme) + .themed(.yeti) // A separate scene root with its own environment, so the meta // link interceptor has to be installed here too. .nowPlayingMetaLinks(app: app, ui: ui) diff --git a/AppleNugs/macOS/ClearQueueAlert.swift b/AppleNugs/macOS/ClearQueueAlert.swift new file mode 100644 index 0000000..4faffc4 --- /dev/null +++ b/AppleNugs/macOS/ClearQueueAlert.swift @@ -0,0 +1,63 @@ +import AppKit + +/// Playback ▸ Clear Queue's confirmation, asked with AppKit straight from the +/// menu command. +/// +/// A `Commands` builder has no view to present a SwiftUI dialog from, and +/// routing it through one window's root broke whenever that window wasn't the +/// one in front: with only the Now Playing window open nothing appeared (and +/// the dialog popped up unprompted once the main window came back), a +/// minimized or backgrounded main window took the sheet out of sight, two main +/// windows each presented one, and a window already showing a sheet collided +/// with it. So: a sheet on the key window when it can take one, otherwise an +/// app-modal alert. Same words as the in-window dialog (`ClearQueueCopy`). +@MainActor +enum ClearQueueAlert { + /// One question at a time: a second Clear Queue while the first alert is + /// up would otherwise stack a second alert behind it. + private static var isPresenting = false + + static func present(onConfirm: @escaping @MainActor () -> Void) { + guard !isPresenting else { return } + isPresenting = true + + let alert = NSAlert() + alert.alertStyle = .warning + alert.messageText = ClearQueueCopy.title + alert.informativeText = ClearQueueCopy.message + alert.addButton(withTitle: ClearQueueCopy.confirm).hasDestructiveAction = true + // A second button titled "Cancel" gets Escape from AppKit. + alert.addButton(withTitle: ClearQueueCopy.cancel) + + if let window = sheetHost() { + alert.beginSheetModal(for: window) { response in + MainActor.assumeIsolated { + finish(response, onConfirm: onConfirm) + } + } + } else { + finish(alert.runModal(), onConfirm: onConfirm) + } + } + + private static func finish(_ response: NSApplication.ModalResponse, + onConfirm: @MainActor () -> Void) { + isPresenting = false + if response == .alertFirstButtonReturn { onConfirm() } + } + + /// The window to attach the sheet to: the key window (else the main one) + /// if it's an ordinary on-screen window with no sheet of its own. A panel + /// (About, Sparkle), a sheet, or a window already showing one gets an + /// app-modal alert instead. + private static func sheetHost() -> NSWindow? { + let candidates = [NSApp.keyWindow, NSApp.mainWindow].compactMap { $0 } + return candidates.first { window in + window.canBecomeMain + && !window.isSheet + && window.attachedSheet == nil + && window.isVisible + && !window.isMiniaturized + } + } +} diff --git a/AppleNugs/macOS/FaceplateTransport.swift b/AppleNugs/macOS/FaceplateTransport.swift deleted file mode 100644 index 9b4ab5e..0000000 --- a/AppleNugs/macOS/FaceplateTransport.swift +++ /dev/null @@ -1,227 +0,0 @@ -import SwiftUI - -/// The Receiver's signature transport: a brushed-metal faceplate with a live -/// L/R VU meter, knurled buttons, an LED volume ladder, a tuner-style seek, and -/// a seven-segment-ish quality readout. Reuses the player's scrub/seek/volume -/// bindings unchanged. -struct FaceplateTransport: View { - @Environment(AppModel.self) private var app - @Environment(\.theme) private var theme - @Environment(\.openWindow) private var openWindow - - @State private var scrubbing = false - @State private var scrubValue: Double = 0 - - private var player: PlayerService { app.player } - - var body: some View { - HStack(spacing: 18) { - channelBlock - controls - VUMeter(isPlaying: player.isPlaying) - .frame(width: 150) - .accessibilityHidden(true) - seekBlock - volumeLadder - qualityReadout - .frame(width: 130, alignment: .trailing) - faceplateStar - - Button { openWindow(id: NowPlayingWindow.id) } label: { - Image(systemName: "macwindow") - .font(.system(size: 14)) - .foregroundStyle(theme.palette.textSecondary) - } - .buttonStyle(.plain) - .disabled(player.current == nil) - .help("Open the Now Playing window") - .accessibilityLabel("Open the Now Playing window") - } - .padding(.horizontal, 18) - .padding(.vertical, 9) - .background { - LinearGradient( - colors: [Color(hex: 0x1C1A17), Color(hex: 0x14120F)], - startPoint: .top, endPoint: .bottom) - .overlay(alignment: .top) { - Rectangle().fill(.white.opacity(0.06)).frame(height: 1) - } - .overlay(alignment: .bottom) { - Rectangle().fill(.black.opacity(0.45)).frame(height: 1) - } - } - } - - // --- left: channel counter + amber ticker ------------------------------- - - private var channelBlock: some View { - VStack(alignment: .leading, spacing: 3) { - HStack(spacing: 6) { - Text(channelText) - .font(theme.type.numeric(13).weight(.heavy)) - .foregroundStyle(theme.palette.accent) - if let track = player.current { - Text(track.title ?? "Unknown") - .font(theme.type.title(14)) - .foregroundStyle(theme.palette.textPrimary) - .lineLimit(1) - } - } - NowPlayingMetaText(track: player.current, casing: .upper, - idle: theme.copy.nowPlaying.uppercased()) - .font(theme.type.numeric(9)) - .tracking(0.6) - .foregroundStyle(theme.palette.textSecondary) - .lineLimit(1) - } - .frame(width: 220, alignment: .leading) - } - - private var channelText: String { - guard player.current != nil else { return "—" } - return "\(player.index + 1)/\(player.queue.count)" - } - - // --- center: knurled transport buttons ---------------------------------- - - private var controls: some View { - HStack(spacing: 12) { - KnurledButton(system: "backward.fill", size: 30, glow: false) { player.previous() } - .disabled(!player.hasPrevious) - .accessibilityLabel("Previous track") - KnurledButton(system: "gobackward.15", size: 26, glow: false) { player.seek(by: -15) } - .disabled(player.current == nil) - .accessibilityLabel("Back 15 seconds") - KnurledButton( - system: player.isPlaying ? "pause.fill" : "play.fill", - size: 40, glow: player.isPlaying) { player.togglePlayPause() } - .disabled(player.current == nil) - .accessibilityLabel(player.isPlaying ? "Pause" : "Play") - KnurledButton(system: "goforward.30", size: 26, glow: false) { player.seek(by: 30) } - .disabled(player.current == nil) - .accessibilityLabel("Forward 30 seconds") - KnurledButton(system: "forward.fill", size: 30, glow: false) { player.next() } - .disabled(!player.hasNext) - .accessibilityLabel("Next track") - } - } - - // --- seek (tuner dial) -------------------------------------------------- - - private var seekBlock: some View { - HStack(spacing: 8) { - Text(TransportBar.format(seconds: scrubbing ? scrubValue : player.currentTime)) - .font(theme.type.numeric(11)) - .foregroundStyle(theme.palette.accent) - .frame(width: 46, alignment: .trailing) - - Slider( - value: Binding( - // Duration-less streams would pin the thumb hard right - // (sliderMax falls back to 1), reading as "finished". - get: { scrubbing ? scrubValue - : (player.duration > 0 ? min(player.currentTime, sliderMax) : 0) }, - set: { scrubValue = $0 }), - in: 0...sliderMax - ) { editing in - if editing { scrubValue = player.currentTime } else { player.seek(to: scrubValue) } - scrubbing = editing - } - .controlSize(.small) - .tint(theme.palette.accent) - .disabled(player.duration <= 0) - .accessibilityLabel("Playback position") - .accessibilityValue(TransportBar.format(seconds: scrubbing ? scrubValue : player.currentTime)) - - Text(remainingText) - .font(theme.type.numeric(11)) - .foregroundStyle(theme.palette.textSecondary) - .frame(width: 52, alignment: .leading) - } - .frame(maxWidth: .infinity) - } - - private var sliderMax: Double { max(player.duration, 1) } - - private var remainingText: String { - guard player.duration > 0 else { return "--:--" } - let position = scrubbing ? scrubValue : player.currentTime - return "-" + TransportBar.format(seconds: max(player.duration - position, 0)) - } - - // --- volume LED ladder -------------------------------------------------- - - private var volumeLadder: some View { - @Bindable var player = app.player - let segs = 10 - let lit = Int((Double(player.volume) * Double(segs)).rounded()) - return GeometryReader { geo in - HStack(alignment: .bottom, spacing: 2) { - ForEach(0.. 0 { parts.append(String(format: "%.1fkHz", specs.sampleRate / 1000)) } - parts.append(specs.channels == 2 ? "STEREO" : "\(specs.channels)CH") - } - return parts.joined(separator: " · ") - } - - private var faceplateStar: some View { - let saved = NowPlayingFavorite.isSaved(player.current, favorites: app.favorites) - return Button { - NowPlayingFavorite.toggle(player.current, favorites: app.favorites) - } label: { - Image(systemName: saved ? "star.fill" : "star") - .font(.system(size: 13, weight: .bold)) - .foregroundStyle(saved ? theme.palette.accent : theme.palette.textSecondary) - } - .buttonStyle(.plain) - .disabled(player.current?.showId == nil) - .help("Save this show to Favorites") - .accessibilityLabel("Save show to Favorites") - .accessibilityAddTraits(saved ? .isSelected : []) - } -} diff --git a/AppleNugs/macOS/MiniPlayer/DashboardMiniPlayer.swift b/AppleNugs/macOS/MiniPlayer/DashboardMiniPlayer.swift index 5a833dd..a72c0a6 100644 --- a/AppleNugs/macOS/MiniPlayer/DashboardMiniPlayer.swift +++ b/AppleNugs/macOS/MiniPlayer/DashboardMiniPlayer.swift @@ -1,10 +1,7 @@ import SwiftUI -/// The Dashboard inspector's now-playing surface: a compact player carrying the -/// active theme's personality. -/// -/// Dispatches on `theme.transport` exactly as `TransportBar`'s now-playing block -/// and `NowPlayingScreen` do — host views are never forked per theme. +/// The Dashboard inspector's now-playing surface: the idle line, or the +/// compact player (`MiniPlayerStandard`) once something is loaded. struct DashboardMiniPlayer: View { @Environment(AppModel.self) private var app @Environment(\.theme) private var theme @@ -19,17 +16,7 @@ struct DashboardMiniPlayer: View { .lineLimit(2) .fixedSize(horizontal: false, vertical: true) } else { - variant - } - } - - @ViewBuilder - private var variant: some View { - switch theme.transport { - case .standard, .jCard: MiniPlayerStandard() - case .tapeLabel: MiniPlayerCassette() - case .faceplate: MiniPlayerFaceplate() - case .clickWheel: MiniPlayerClickWheel() + MiniPlayerStandard() } } } diff --git a/AppleNugs/macOS/MiniPlayer/MiniPlayerCassette.swift b/AppleNugs/macOS/MiniPlayer/MiniPlayerCassette.swift deleted file mode 100644 index eaee129..0000000 --- a/AppleNugs/macOS/MiniPlayer/MiniPlayerCassette.swift +++ /dev/null @@ -1,409 +0,0 @@ -import SwiftUI - -/// Tape Room's miniplayer: a Compact Cassette at true 25:16 proportions -/// (a real shell is 100 × 64 mm). -/// -/// Layout note — the ratio is a *target*, not a cage. Ornament (screws, corner -/// marks) is absolutely positioned in an overlay, while the label, window and -/// ridged band form a real `VStack`: the label takes its intrinsic height, the -/// window absorbs the slack, and if text ever needs more room than the ratio -/// allows, the stack reports a taller size and the ratio yields. Text is never -/// clipped to preserve a shape. -struct MiniPlayerCassette: View { - @Environment(AppModel.self) private var app - @Environment(\.theme) private var theme - - private var player: PlayerService { app.player } - - /// Cassette aspect: 100 mm × 64 mm. - private let shellRatio: CGFloat = 25.0 / 16.0 - - private var paper: Color { theme.palette.labelPaper ?? theme.palette.textPrimary } - private var ink: Color { theme.palette.labelInk ?? theme.palette.base } - - var body: some View { - VStack(spacing: 8) { - label.layoutPriority(2) - windowBlock - ridgedBand.layoutPriority(2) - } - .padding(.horizontal, 12) - .padding(.top, 12) - .padding(.bottom, 10) - .frame(maxWidth: .infinity) - .aspectRatio(shellRatio, contentMode: .fit) - .background(shell) - .overlay(ornament) - .clipShape(RoundedRectangle(cornerRadius: 7, style: .continuous)) - } - - // --- shell --------------------------------------------------------------- - - private var shell: some View { - LinearGradient( - colors: [theme.palette.hairline.opacity(0.9), - theme.palette.raised, - theme.palette.base], - startPoint: .topLeading, endPoint: .bottomTrailing) - .overlay(alignment: .top) { - Rectangle().fill(.white.opacity(0.10)).frame(height: 1) - } - .overlay(alignment: .bottom) { - Rectangle().fill(.black.opacity(0.5)).frame(height: 1) - } - } - - /// Screws and the two bottom-corner marks. Decorative only. - private var ornament: some View { - ZStack { - VStack { - HStack { screw; Spacer(); screw } - Spacer() - HStack { screw; Spacer(); screw } - } - .padding(6) - - VStack { - Spacer() - HStack { - corner { EmptyView() } - Spacer() - corner { - Image(systemName: "play.fill") - .font(.system(size: 6)) - .foregroundStyle(theme.palette.textSecondary) - } - } - } - .padding(.horizontal, 6) - .padding(.bottom, 8) - } - .allowsHitTesting(false) - .accessibilityHidden(true) - } - - private var screw: some View { - Circle() - .fill(RadialGradient(colors: [theme.palette.textIdle.opacity(0.55), - theme.palette.base], - center: .init(x: 0.35, y: 0.32), - startRadius: 0, endRadius: 6)) - .overlay { Circle().strokeBorder(.black.opacity(0.5), lineWidth: 1) } - .frame(width: 9, height: 9) - } - - private func corner(@ViewBuilder mark: () -> Mark) -> some View { - ZStack { - Circle() - .fill(theme.palette.base) - .overlay { Circle().strokeBorder(.black.opacity(0.5), lineWidth: 1) } - mark() - } - .frame(width: 12, height: 12) - } - - // --- the write-on label --------------------------------------------------- - - private var label: some View { - VStack(spacing: 0) { - Rectangle() - .fill(LinearGradient(colors: [theme.palette.accent.opacity(0.75), - theme.palette.accent.opacity(0.45)], - startPoint: .top, endPoint: .bottom)) - .frame(height: 4) - - VStack(alignment: .leading, spacing: 1) { - HStack(alignment: .top, spacing: 6) { - Text(player.current?.title ?? "Unknown track") - .font(theme.type.title(13)) - .foregroundStyle(ink) - .lineLimit(1) - .truncationMode(.tail) - Spacer(minLength: 0) - losslessTick - } - // `tint:` rather than `.foregroundStyle`: these draw in the - // simulated paper's local ink, not a theme token, and a link - // run's color has to be set on the attributed string itself or - // it stops matching the line around it. - NowPlayingMetaText(track: player.current, fields: [.artist], - tint: ink.opacity(0.78)) - .font(theme.type.body(10)) - .lineLimit(1) - .truncationMode(.tail) - NowPlayingMetaText(track: player.current, fields: [.show], - casing: .upper, tint: ink.opacity(0.55)) - .font(theme.type.numeric(9)) - .tracking(0.5) - .lineLimit(1) - .truncationMode(.tail) - } - .frame(maxWidth: .infinity, alignment: .leading) - .fixedSize(horizontal: false, vertical: true) - .padding(.horizontal, 7) - .padding(.vertical, 4) - } - .background(paper) - .clipShape(RoundedRectangle(cornerRadius: 2, style: .continuous)) - .overlay { - RoundedRectangle(cornerRadius: 2, style: .continuous) - .strokeBorder(ink.opacity(0.35), lineWidth: 1) - } - } - - /// The `NR ☐YES ☐NO` corner of a real label, repurposed for the one bit of - /// state worth printing there. - private var losslessTick: some View { - let isLossless = player.nowPick?.format.isLossless ?? false - return Text(isLossless ? "LOSSLESS ☑" : "LOSSLESS ☐") - .font(theme.type.numeric(7)) - .foregroundStyle(ink.opacity(0.5)) - .lineLimit(1) - .truncationMode(.tail) - .fixedSize() - .accessibilityHidden(true) - } - - // --- shoulders + window ---------------------------------------------------- - - private var windowBlock: some View { - HStack(spacing: 8) { - leftShoulder - window - .frame(maxWidth: .infinity, maxHeight: .infinity) - rightShoulder - } - } - - private var leftShoulder: some View { - VStack(alignment: .leading, spacing: 1) { - Text("A") - .font(theme.type.title(14)) - if let badge = player.nowPick?.format.badge { - Text(badge) - .font(theme.type.numeric(11).weight(.bold)) - } - Text("POSITION HIGH") - .font(theme.type.numeric(6)) - .tracking(0.75) - if let specsLine { - Text(specsLine) - .font(theme.type.numeric(12).weight(.bold)) - } - } - .foregroundStyle(theme.palette.accent.opacity(0.85)) - .lineLimit(1) - .truncationMode(.tail) - .fixedSize(horizontal: false, vertical: true) - // 62, not 54: "POSITION HIGH" at numeric(6)/tracking(0.75) measures - // 57.97 pt (NSFont.monospacedSystemFont equivalent); 54 clipped it to - // "POSITION HIG…" in every session. Keep in lockstep with rightShoulder - // so the window stays centered. - .frame(width: 62, alignment: .leading) - .accessibilityHidden(true) - } - - /// Rendered only when both halves are known — never a `--/--` placeholder. - private var specsLine: String? { - guard let specs = player.specs, let bits = specs.bitDepth, specs.sampleRate > 0 else { - return nil - } - return "\(bits)/\(Int((specs.sampleRate / 1000).rounded()))" - } - - private var rightShoulder: some View { - let saved = NowPlayingFavorite.isSaved(player.current, favorites: app.favorites) - return VStack(spacing: 4) { - HapticButton(.transportStep) { - NowPlayingFavorite.toggle(player.current, favorites: app.favorites) - } label: { - Image(systemName: saved ? "star.fill" : "star") - .font(.system(size: 13)) - .foregroundStyle(theme.palette.accent) - .frame(width: 22, height: 22) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .disabled(player.current?.showId == nil) - .accessibilityLabel("Save show to Favorites") - .accessibilityAddTraits(saved ? .isSelected : []) - - Text("yeti") - .font(theme.type.numeric(6).weight(.bold)) - .tracking(0.9) - .foregroundStyle(theme.palette.accent.opacity(0.85)) - .lineLimit(1) - .truncationMode(.tail) - .fixedSize() - } - .frame(width: 62) // matches leftShoulder — see its comment - } - - /// The cover shows through the window behind the reels, and the exposed - /// tape span across the bottom **is** the scrubber — the detail the whole - /// variant is built around. `MiniPlayerScrubTrack` already reserves a 20 pt - /// hit area behind its 4 pt strip, so it is draggable despite being thin. - /// - /// The reels are pinned to the *top* of the window on purpose: now that - /// their tape packs are real (up to 36 pt across, Task 5), a bottom-anchor - /// would run the packs straight through the scrubber strip instead of a - /// real cassette's read: two full reels up top, a bare span of tape - /// exposed along the bottom edge underneath them. - private var window: some View { - ZStack { - LinearGradient(colors: [.white.opacity(0.16), .black.opacity(0.45)], - startPoint: .top, endPoint: .bottom) - - VStack(spacing: 0) { - CassetteReels() // Task 5 gives these their progress-driven packs - .padding(.top, 6) - Spacer(minLength: 2) - MiniPlayerScrubTrack(trackHeight: 4, - showsClocks: false, - fillColor: theme.palette.accent) - .padding(.horizontal, 8) - .padding(.bottom, 2) - } - } - .background { - if let image = player.nowPlayingImage { - Image(platformImage: image) - .resizable() - .aspectRatio(contentMode: .fill) - .opacity(0.8) - .accessibilityHidden(true) - } else { - theme.palette.raised - } - } - .clipShape(RoundedRectangle(cornerRadius: 3, style: .continuous)) - .overlay { - RoundedRectangle(cornerRadius: 3, style: .continuous) - .strokeBorder(.black.opacity(0.55), lineWidth: 1) - } - // The honest floor: 6 top pad + 36 reels + 2 spacer min + 20 scrub hit - // + 2 bottom pad = 66. (62 here was never reachable.) - .frame(minHeight: 66) - } - - // --- ridged bottom edge ----------------------------------------------------- - - /// Transport molded into the ridged edge, with the clocks in the shell's - /// bottom corners where a real cassette has its two round openings. - private var ridgedBand: some View { - HStack(spacing: 6) { - MiniPlayerClock(kind: .elapsed, size: 8) - .frame(width: 44, alignment: .leading) - - MiniPlayerTransportTriad(style: .bare, glyphSize: 12, playDiameter: 26) - .padding(.vertical, 3) - .background { - RoundedRectangle(cornerRadius: 3, style: .continuous) - .fill(theme.palette.raised) - .overlay { - RoundedRectangle(cornerRadius: 3, style: .continuous) - .strokeBorder(.white.opacity(0.07), lineWidth: 1) - } - } - - MiniPlayerClock(kind: .remaining, size: 8) - .frame(width: 44, alignment: .trailing) - } - } -} - -/// The two reels in the cassette window, with tape packs that trade size as the -/// track runs — the supply reel on the left empties into the take-up on the right. -/// -/// **A leaf view.** Pack radii derive from `currentTime`, which the playback -/// tick mutates ~4×/sec. Keeping that read here — and not in -/// `MiniPlayerCassette` — is what stops the whole shell, and the inspector's -/// queue list below it, from re-diffing on every tick. -struct CassetteReels: View { - @Environment(AppModel.self) private var app - @Environment(\.theme) private var theme - - private let hubRadius: Double = 7.5 - - /// This is the number someone reaches for to draw bigger reels, so the - /// shell's 25:16 budget (`MiniPlayerCassette.shellRatio`) is recorded here - /// rather than left to a gitignored report — it has already slipped twice. - /// At the inspector's 340 pt column the shell renders 308 pt wide, so the - /// budget is 308 × 16/25 ≈ 197 pt tall. The rigid content — label ~42 - /// (4 strip + 8 padding + 30 of title/artist text), ridged band 32, the - /// outer VStack's spacing/padding 38, plus the window's own 66 pt floor — - /// totals ~178 pt, leaving ~19 pt of slack that lives in the window's - /// `Spacer(minLength: 2)` between the reels and the scrub strip. Grow any - /// band past that headroom — a fourth label line, a bigger play puck, a - /// taller clock font, or a bigger `fullRadius` here — and the shell either - /// grows past 197 pt or clips, and stops being cassette-shaped. (Purely - /// horizontal changes, like shoulder width, don't touch this budget.) - private let fullRadius: Double = 18 - - private var player: PlayerService { app.player } - - var body: some View { - let progress = TapeGeometry.progress(currentTime: player.currentTime, - duration: player.duration) - HStack { - reel(fraction: 1 - progress) // supply - Spacer() - reel(fraction: progress) // take-up - } - .padding(.horizontal, 10) - .accessibilityHidden(true) - } - - private func reel(fraction: Double) -> some View { - let radius = TapeGeometry.packRadius(fraction: fraction, - hub: hubRadius, - full: fullRadius) - return ZStack { - Circle() - .fill(RadialGradient(colors: [theme.palette.raised, theme.palette.base], - center: .center, - startRadius: 0, - endRadius: CGFloat(radius))) - .frame(width: CGFloat(radius) * 2, height: CGFloat(radius) * 2) - ReelHub(isPlaying: player.isPlaying, diameter: CGFloat(hubRadius) * 2) - } - // Reserve the full footprint so a shrinking pack never shifts the layout. - .frame(width: CGFloat(fullRadius) * 2, height: CGFloat(fullRadius) * 2) - } -} - -/// A splined hub that turns while the tape runs. `TimelineView` with -/// `paused:` costs nothing when stopped — same idiom as `VUMeter`. -/// -/// The schedule itself already stops advancing `timeline.date` once paused, -/// so the angle derived from it holds at wherever the tape last stopped -/// instead of springing back to a fixed rest position — a real reel doesn't -/// snap backwards when you hit pause. -struct ReelHub: View { - @Environment(\.theme) private var theme - let isPlaying: Bool - var diameter: CGFloat = 15 - - var body: some View { - TimelineView(.animation(minimumInterval: 1.0 / 20.0, paused: !isPlaying)) { timeline in - let angle = Angle.degrees(timeline.date.timeIntervalSinceReferenceDate * 100) - ZStack { - Circle().fill(theme.palette.textPrimary) - // The splines must contrast with the disc: filled with the - // same textPrimary as the disc behind them, the rotation was - // invisible at any speed and pause-freeze unverifiable. - ForEach(0..<3, id: \.self) { i in - Capsule() - .fill(theme.palette.base) - .frame(width: diameter * 0.18, height: diameter * 0.82) - .rotationEffect(.degrees(Double(i) * 60)) - } - Circle() - .fill(theme.palette.base) - .frame(width: diameter * 0.45, height: diameter * 0.45) - } - .frame(width: diameter, height: diameter) - .rotationEffect(angle) - } - } -} diff --git a/AppleNugs/macOS/MiniPlayer/MiniPlayerClickWheel.swift b/AppleNugs/macOS/MiniPlayer/MiniPlayerClickWheel.swift deleted file mode 100644 index 0548a4f..0000000 --- a/AppleNugs/macOS/MiniPlayer/MiniPlayerClickWheel.swift +++ /dev/null @@ -1,120 +0,0 @@ -import SwiftUI - -/// Click Wheel's miniplayer: a track card over a compact dial. -/// -/// An iPod's defining feature is the screen above the wheel, so unlike the -/// full-screen iOS `ClickWheelScreen` this compact form still leads with the -/// cover. Monochrome throughout — the art is the only pigment. -struct MiniPlayerClickWheel: View { - @Environment(AppModel.self) private var app - @Environment(\.theme) private var theme - - private var player: PlayerService { app.player } - - var body: some View { - VStack(spacing: 10) { - trackCard - MiniPlayerScrubTrack(fillColor: theme.palette.textPrimary) - wheel - } - .padding(10) - } - - private var trackCard: some View { - HStack(spacing: 10) { - ArtChip(image: player.nowPlayingImage, - fallbackText: player.current?.artist ?? player.current?.title ?? "?", - size: 34) - .accessibilityHidden(true) - VStack(alignment: .leading, spacing: 1) { - NowPlayingMetaText(track: player.current, fields: [.artist]) - .font(theme.type.body(11)) - .foregroundStyle(theme.palette.textSecondary) - .lineLimit(1) - .truncationMode(.tail) - Text(player.current?.title ?? "Unknown track") - .font(theme.type.title(14)) - .foregroundStyle(theme.palette.textPrimary) - .lineLimit(1) - .truncationMode(.tail) - } - .frame(maxWidth: .infinity, alignment: .leading) - .fixedSize(horizontal: false, vertical: true) - } - .padding(8) - .background { - RoundedRectangle(cornerRadius: 6, style: .continuous) - .fill(theme.palette.raised) - } - } - - private var wheel: some View { - ZStack { - Circle() - .fill(theme.palette.raised) - .overlay { Circle().strokeBorder(theme.palette.hairline, lineWidth: 1) } - - VStack { - Text("MENU") - .font(theme.type.numeric(8).weight(.bold)) - .tracking(1.2) - .foregroundStyle(theme.palette.textSecondary) - .lineLimit(1) - .truncationMode(.tail) - .accessibilityHidden(true) - Spacer() - } - .padding(.vertical, 12) - - HStack { - ringButton("backward.fill", label: "Previous track", help: "Previous (p)", - enabled: player.hasPrevious) { player.previous() } - Spacer() - ringButton("forward.fill", label: "Next track", help: "Next (n)", - enabled: player.hasNext) { player.next() } - } - .padding(.horizontal, 10) - - HapticButton(.transportToggle) { - player.togglePlayPause() - } label: { - ZStack { - Circle().fill(theme.palette.base) - if player.isBuffering { - ProgressView().controlSize(.small).tint(theme.palette.textPrimary) - } else { - Image(systemName: player.isPlaying ? "pause.fill" : "play.fill") - .font(.system(size: 17, weight: .bold)) - .foregroundStyle(theme.palette.textPrimary) - } - } - .frame(width: 54, height: 54) - .contentShape(Circle()) - } - .buttonStyle(.plain) - .disabled(player.current == nil) - .help("Play / pause (space)") - .accessibilityLabel(player.isPlaying ? "Pause" : "Play") - .accessibilityValue(player.isBuffering ? "Buffering" : "") - } - .frame(width: 150, height: 150) - } - - private func ringButton(_ system: String, - label: String, - help: String, - enabled: Bool, - action: @escaping () -> Void) -> some View { - HapticButton(.transportStep, action: action) { - Image(systemName: system) - .font(.system(size: 14, weight: .semibold)) - .foregroundStyle(theme.palette.textPrimary) - .frame(width: 30, height: 30) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .disabled(!enabled) - .help(help) - .accessibilityLabel(label) - } -} diff --git a/AppleNugs/macOS/MiniPlayer/MiniPlayerFaceplate.swift b/AppleNugs/macOS/MiniPlayer/MiniPlayerFaceplate.swift deleted file mode 100644 index f0a85b6..0000000 --- a/AppleNugs/macOS/MiniPlayer/MiniPlayerFaceplate.swift +++ /dev/null @@ -1,98 +0,0 @@ -import SwiftUI - -/// The Receiver's miniplayer: a tuner faceplate. -/// -/// Deliberately carries **no cover art** — a hi-fi tuner has none, and the L/R -/// VU meters are this theme's visual subject. This is the one variant that -/// departs from the miniplayer's usual art-plus-text shape. -struct MiniPlayerFaceplate: View { - @Environment(AppModel.self) private var app - @Environment(\.theme) private var theme - - private var player: PlayerService { app.player } - - var body: some View { - VStack(alignment: .leading, spacing: 9) { - receivingLine - titleBlock - VUMeter(isPlaying: player.isPlaying) - .frame(maxWidth: .infinity, alignment: .leading) - .accessibilityHidden(true) - MiniPlayerScrubTrack(fillColor: theme.palette.accent) - knurledTriad - } - .padding(10) - .background { - LinearGradient(colors: [theme.palette.raised, theme.palette.base], - startPoint: .top, endPoint: .bottom) - } - .clipShape(RoundedRectangle(cornerRadius: 4, style: .continuous)) - } - - private var receivingLine: some View { - HStack(spacing: 5) { - Circle() - .fill(theme.palette.accent) - .frame(width: 5, height: 5) - .shadow(color: player.isPlaying ? theme.palette.accent : .clear, radius: 4) - Text("RECEIVING") - .font(theme.type.numeric(9).weight(.bold)) - .tracking(1.6) - .lineLimit(1) - .truncationMode(.tail) - } - .foregroundStyle(theme.palette.accent.opacity(player.isPlaying ? 1 : 0.5)) - .accessibilityHidden(true) - } - - private var titleBlock: some View { - VStack(alignment: .leading, spacing: 2) { - Text((player.current?.title ?? "Unknown track").uppercased()) - .font(theme.type.numeric(13).weight(.bold)) - .foregroundStyle(theme.palette.textPrimary) - .lineLimit(1) - .truncationMode(.tail) - NowPlayingMetaText(track: player.current, fields: [.artist], casing: .upper) - .font(theme.type.numeric(9)) - .tracking(0.8) - .foregroundStyle(theme.palette.textSecondary) - .lineLimit(1) - .truncationMode(.tail) - } - .frame(maxWidth: .infinity, alignment: .leading) - .fixedSize(horizontal: false, vertical: true) - } - - private var knurledTriad: some View { - HStack(spacing: 0) { - KnurledButton(system: "backward.fill", size: 30, glow: false) { - player.previous() - } - .disabled(!player.hasPrevious) - .help("Previous (p)") - .accessibilityLabel("Previous track") - - Spacer(minLength: 8) - - KnurledButton(system: player.isPlaying ? "pause.fill" : "play.fill", - size: 30, - glow: player.isPlaying) { - player.togglePlayPause() - } - .disabled(player.current == nil) - .help("Play / pause (space)") - .accessibilityLabel(player.isPlaying ? "Pause" : "Play") - .accessibilityValue(player.isBuffering ? "Buffering" : "") - - Spacer(minLength: 8) - - KnurledButton(system: "forward.fill", size: 30, glow: false) { - player.next() - } - .disabled(!player.hasNext) - .help("Next (n)") - .accessibilityLabel("Next track") - } - .frame(maxWidth: .infinity) - } -} diff --git a/AppleNugs/macOS/MiniPlayer/MiniPlayerParts.swift b/AppleNugs/macOS/MiniPlayer/MiniPlayerParts.swift index df6699f..88146e4 100644 --- a/AppleNugs/macOS/MiniPlayer/MiniPlayerParts.swift +++ b/AppleNugs/macOS/MiniPlayer/MiniPlayerParts.swift @@ -10,13 +10,9 @@ import SwiftUI struct MiniPlayerScrubTrack: View { @Environment(AppModel.self) private var app @Environment(\.theme) private var theme - @Environment(\.artColor) private var artColor /// Drawn thickness. The hit area stays 20 pt tall regardless. - var trackHeight: CGFloat = 3 - var showsClocks: Bool = true - /// Defaults to the theme's effective (possibly art-driven) accent. - var fillColor: Color? = nil + private static let trackHeight: CGFloat = 3 @State private var scrubbing = false @State private var scrubValue: Double = 0 @@ -30,7 +26,7 @@ struct MiniPlayerScrubTrack: View { var body: some View { VStack(spacing: 5) { track - if showsClocks { clocks } + clocks } } @@ -42,10 +38,10 @@ struct MiniPlayerScrubTrack: View { ZStack(alignment: .leading) { Capsule().fill(theme.palette.hairline) Capsule() - .fill(fillColor ?? theme.effectiveAccent(art: artColor)) + .fill(theme.palette.accent) .frame(width: width * fraction) } - .frame(height: trackHeight) + .frame(height: Self.trackHeight) } .contentShape(Rectangle()) .gesture( @@ -93,70 +89,20 @@ struct MiniPlayerScrubTrack: View { .lineLimit(1) } + /// "-mm:ss" remaining, and its "--:--" placeholder before duration is known. private var remaining: String { - MiniPlayerClock.remainingText(elapsed: position, duration: duration) - } -} - -/// A single time readout, for variants that place elapsed and remaining apart -/// from the scrubber (Tape Room puts them in the shell's bottom corners). -/// -/// **A leaf view**, for the same reason as `MiniPlayerScrubTrack`: it owns a -/// `currentTime` read. -struct MiniPlayerClock: View { - enum Kind { case elapsed, remaining } - - @Environment(AppModel.self) private var app - @Environment(\.theme) private var theme - - let kind: Kind - var size: CGFloat = 9 - - var body: some View { - Text(text) - .font(theme.type.numeric(size)) - .foregroundStyle(theme.palette.textSecondary) - .lineLimit(1) - .fixedSize() - .accessibilityHidden(true) // the scrubber already announces position - } - - private var text: String { - let player = app.player - switch kind { - case .elapsed: - guard player.duration > 0 else { return "--:--" } - return TransportBar.format(seconds: player.currentTime) - case .remaining: - return Self.remainingText(elapsed: player.currentTime, duration: player.duration) - } - } - - /// Shared "-mm:ss" remaining-time formula, and its "--:--" placeholder - /// before duration is known. Reused by `MiniPlayerScrubTrack` so the two - /// clocks can't drift as more variants (Task 4+) add their own readouts. - static func remainingText(elapsed: Double, duration: Double) -> String { guard duration > 0 else { return "--:--" } - return "-" + TransportBar.format(seconds: max(duration - elapsed, 0)) + return "-" + TransportBar.format(seconds: max(duration - position, 0)) } } -/// prev / play-pause / next, spread evenly. The play button's treatment is the -/// theme's business, so it is chosen by `Style` rather than forked per variant. +/// prev / play-pause / next, spread evenly, with the 46pt play sticker in the +/// middle (an accent disc would be a pink fill). struct MiniPlayerTransportTriad: View { - enum Style: Equatable { - case filled // Soundboard: solid accent disc - case outline // Shoebox: rust keyline - case bare // Tape Room's ridged band: glyph only - } - @Environment(AppModel.self) private var app @Environment(\.theme) private var theme - @Environment(\.artColor) private var artColor - var style: Style = .filled - var glyphSize: CGFloat = 13 - var playDiameter: CGFloat = 34 + var glyphSize: CGFloat = 18 private var player: PlayerService { app.player } @@ -183,7 +129,7 @@ struct MiniPlayerTransportTriad: View { HapticButton(.transportStep, action: action) { Image(systemName: system) .font(.system(size: glyphSize)) - .foregroundStyle(style == .bare ? theme.palette.accent : theme.palette.textPrimary) + .foregroundStyle(theme.palette.textPrimary) // 20pt minimum hit area, however small the glyph is drawn. .frame(width: 30, height: 22) .contentShape(Rectangle()) @@ -198,35 +144,18 @@ struct MiniPlayerTransportTriad: View { HapticButton(.transportToggle) { player.togglePlayPause() } label: { - ZStack { - switch style { - case .filled: Circle().fill(theme.effectiveAccent(art: artColor)) - case .outline: Circle().strokeBorder(theme.palette.playState, lineWidth: 1.5) - case .bare: Color.clear - } - if player.isBuffering { - ProgressView().controlSize(.small).tint(playGlyphColor) - } else { - Image(systemName: player.isPlaying ? "pause.fill" : "play.fill") - .font(.system(size: style == .bare ? glyphSize + 2 : glyphSize)) - .foregroundStyle(playGlyphColor) - } - } - .frame(width: playDiameter, height: playDiameter) - .contentShape(Rectangle()) + // Its own disc, glyph and spinner; one size whatever the state. + PlaySticker(isPlaying: player.isPlaying, + isBuffering: player.isBuffering, + size: .s46) + .foilFocusOutline(Circle()) } .buttonStyle(.plain) + // The sticker draws its own foil focus outline; no system ring too. + .macFocusEffectDisabled() .disabled(player.current == nil) .help("Play / pause (space)") .accessibilityLabel(player.isPlaying ? "Pause" : "Play") .accessibilityValue(player.isBuffering ? "Buffering" : "") } - - private var playGlyphColor: Color { - switch style { - case .filled: return theme.palette.base - case .outline: return theme.palette.playState - case .bare: return theme.palette.accent - } - } } diff --git a/AppleNugs/macOS/MiniPlayer/MiniPlayerStandard.swift b/AppleNugs/macOS/MiniPlayer/MiniPlayerStandard.swift index 1111da3..7ba6b95 100644 --- a/AppleNugs/macOS/MiniPlayer/MiniPlayerStandard.swift +++ b/AppleNugs/macOS/MiniPlayer/MiniPlayerStandard.swift @@ -1,73 +1,63 @@ import SwiftUI -/// The compact miniplayer for Soundboard (`.standard`) and Shoebox (`.jCard`). +/// The Dashboard inspector's compact miniplayer. /// -/// One view, not two: their differences are token reads — a J-card spine rule, -/// tracked small caps, an outline play button instead of a filled one — in the -/// same spirit as `DashboardPanel.sectionHeader` branching on -/// `.condensedHeaders`. +/// The art in a small blister bubble with the title beside it (foil sweep on a +/// new track), the venue on its own line below, and the play sticker as the +/// triad's middle button. struct MiniPlayerStandard: View { @Environment(AppModel.self) private var app @Environment(\.theme) private var theme - @Environment(\.artColor) private var artColor private var player: PlayerService { app.player } - private var isJCard: Bool { theme.transport == .jCard } var body: some View { VStack(alignment: .leading, spacing: 10) { - infoRow + packInfo MiniPlayerScrubTrack() - MiniPlayerTransportTriad(style: isJCard ? .outline : .filled) + MiniPlayerTransportTriad() } .padding(10) - .background(Color.clear.artWash(theme.washStyle, color: artColor)) } - private var infoRow: some View { - HStack(spacing: 10) { - ArtChip(image: player.nowPlayingImage, - fallbackText: player.current?.artist ?? player.current?.title ?? "?", - size: 56) + /// The info block, as drawn in the first sheet's `.mini`: a 100pt small + /// bubble (no card — the bubble alone is the Mac miniplayer's pack), the + /// title beside it with the one-shot foil sweep, the artist under it, and + /// the venue on a full-width line below — which also gives a long venue + /// the column's whole width. + private var packInfo: some View { + VStack(alignment: .leading, spacing: 10) { + HStack(spacing: 14) { + BlisterBubble(metrics: .small) { + PackArt(image: player.nowPlayingImage, + monogram: player.current?.artist ?? player.current?.title ?? "?") + } + .frame(width: Self.bubbleSide, height: Self.bubbleSide) .accessibilityHidden(true) - if isJCard { - Rectangle() - .fill(theme.palette.hairline) - .frame(width: 1, height: 40) - } - - VStack(alignment: .leading, spacing: 3) { - Text(player.current?.title ?? "Unknown track") - .font(theme.type.title(15)) - .lineLimit(1) - .truncationMode(.tail) - meta(fields: [.artist]) - meta(fields: [.show]) - .foregroundStyle(theme.palette.textSecondary) + VStack(alignment: .leading, spacing: 4) { + Text(player.current?.title ?? "Unknown track") + .font(theme.type.title(24)) + .foregroundStyle(theme.palette.textPrimary) + .lineLimit(2) + .truncationMode(.tail) + .foilSweep(identity: player.current?.trackId, isPlaying: player.isPlaying) + NowPlayingMetaText(track: player.current, fields: [.artist]) + .font(theme.type.body(14).weight(.semibold)) + .lineLimit(1) + .truncationMode(.tail) + } + .frame(maxWidth: .infinity, alignment: .leading) + .fixedSize(horizontal: false, vertical: true) } - .frame(maxWidth: .infinity, alignment: .leading) - .fixedSize(horizontal: false, vertical: true) - } - } - - /// One field of the meta line. Nothing renders when the field is absent, so - /// the block keeps the height it had when these were `if let` Texts. - @ViewBuilder - private func meta(fields: [NowPlayingMetaLine.Field]) -> some View { - let line = NowPlayingMetaText(track: player.current, fields: fields, - casing: isJCard ? .upper : .asIs) - if isJCard { - line - .font(theme.type.numeric(9)) - .tracking(0.9) - .lineLimit(1) - .truncationMode(.tail) - } else { - line - .font(.caption) + NowPlayingMetaText(track: player.current, fields: [.show]) + .font(theme.type.body(12)) + .foregroundStyle(theme.palette.textSecondary) .lineLimit(1) .truncationMode(.tail) } } + + /// The sheet's `.mini` bubble: 100pt in a 340pt inspector column. + private static let bubbleSide: CGFloat = 100 } diff --git a/AppleNugs/macOS/MiniPlayer/TapeGeometry.swift b/AppleNugs/macOS/MiniPlayer/TapeGeometry.swift deleted file mode 100644 index 5bb4533..0000000 --- a/AppleNugs/macOS/MiniPlayer/TapeGeometry.swift +++ /dev/null @@ -1,33 +0,0 @@ -import Foundation - -/// Geometry for Tape Room's cassette reels. -/// -/// Foundation-only on purpose: this file is compiled directly into the -/// host-free `AppleNugsTests` bundle (see `project.yml`), which admits no -/// SwiftUI. Callers convert to `CGFloat` at the use site. -enum TapeGeometry { - - /// Radius of a tape pack holding `fraction` (0...1) of the reel's tape. - /// - /// Tape winds in a flat spiral, so the pack's *area* — not its radius — is - /// proportional to the length of tape on the reel. The radius therefore - /// goes as the square root, which is why a real reel visibly slows its - /// growth as it fills. A linear interpolation looks wrong in motion. - static func packRadius(fraction: Double, hub: Double, full: Double) -> Double { - let f = clampUnit(fraction) - return (hub * hub + (full * full - hub * hub) * f).squareRoot() - } - - /// Playback position as a 0...1 fraction. Zero unless both inputs are - /// finite and the duration is positive — `duration` is 0 before a track - /// loads and can be NaN for a stream that never reports one. - static func progress(currentTime: Double, duration: Double) -> Double { - guard duration.isFinite, duration > 0, currentTime.isFinite else { return 0 } - return clampUnit(currentTime / duration) - } - - private static func clampUnit(_ value: Double) -> Double { - guard value.isFinite else { return 0 } - return min(max(value, 0), 1) - } -} diff --git a/AppleNugs/macOS/RootView.swift b/AppleNugs/macOS/RootView.swift index ff7dcee..279e969 100644 --- a/AppleNugs/macOS/RootView.swift +++ b/AppleNugs/macOS/RootView.swift @@ -3,7 +3,6 @@ import SwiftUI struct RootView: View { @Environment(AppModel.self) private var app @Environment(UIState.self) private var ui - @Environment(ThemeManager.self) private var themes @Environment(\.theme) private var theme @State private var offlineShown = false @@ -33,26 +32,22 @@ struct RootView: View { connectionFailedView(message) } } - .providesArtColor(app: app, theme: theme) + .themed(theme) .nowPlayingMetaLinks(app: app, ui: ui) .task { await app.bootstrap() } .onAppear { KeyboardShortcuts.install(app: app, ui: ui) } } private func connectionFailedView(_ message: String) -> some View { - ContentUnavailableView { - Label("Couldn't reach nugs.net", systemImage: "wifi.exclamationmark") - } description: { - Text(message) - } actions: { - Button("Retry") { Task { await app.retryBootstrap() } } - .buttonStyle(.borderedProminent) - // The whole point of downloads is being reachable right here. - if !app.downloads.manifest.shows.isEmpty { - Button("Listen Offline") { offlineShown = true } - } - Button("Sign Out") { app.logout() } - } + let hasDownloads = !app.downloads.manifest.shows.isEmpty + return ConnectionFailedView( + message: message, + hasDownloads: hasDownloads, + canPlayOffline: hasDownloads && OfflineAccessPolicy.allowsOfflinePlay( + lastVerifiedAt: OfflineVerification.lastVerifiedAt(), now: Date()), + onRetry: { Task { await app.retryBootstrap() } }, + onListenOffline: { offlineShown = true }, + onSignOut: { app.logout() }) .frame(maxWidth: .infinity, maxHeight: .infinity) .sheet(isPresented: $offlineShown) { NavigationStack { @@ -205,17 +200,6 @@ struct RootView: View { if case .loggedIn(let plan) = app.sessionState, let plan { Text(plan) } - Picker("Theme", selection: Binding( - get: { themes.selected }, - set: { newValue in - withAnimation(.easeInOut(duration: 0.35)) { themes.selected = newValue } - } - )) { - ForEach(ThemeID.allCases) { id in - Text(id.displayName).tag(id) - } - } - .pickerStyle(.menu) #if DEBUG Divider() Button("Network Probe (Debug)") { probeShown = true } @@ -225,7 +209,7 @@ struct RootView: View { confirmingStatsReset = true } Divider() - Button("Log Out") { app.logout() } + Button("Sign Out") { app.logout() } } label: { Label("Account", systemImage: "person.circle") } @@ -242,12 +226,7 @@ struct RootView: View { @ViewBuilder private var toastOverlay: some View { if let toast = ui.toast { - Text(toast) - .font(.callout.weight(.medium)) - .padding(.horizontal, 14) - .padding(.vertical, 8) - .background(.regularMaterial, in: Capsule()) - .shadow(radius: 4) + ToastCapsule(text: toast.text, kind: toast.kind) .padding(.bottom, 78) // float above the transport bar .transition(.opacity) .allowsHitTesting(false) diff --git a/AppleNugsTests/FoilSweepTriggerTests.swift b/AppleNugsTests/FoilSweepTriggerTests.swift new file mode 100644 index 0000000..7370610 --- /dev/null +++ b/AppleNugsTests/FoilSweepTriggerTests.swift @@ -0,0 +1,109 @@ +import XCTest + +final class FoilSweepTriggerTests: XCTestCase { + + // MARK: - a new track while playing fires once + + func testNewTrackWhilePlayingFiresOnce() { + var trigger = FoilSweepTrigger() + XCTAssertTrue(trigger.observe(identity: "a", isPlaying: true)) + // Any further report of the same state is not a second sweep. + XCTAssertFalse(trigger.observe(identity: "a", isPlaying: true)) + XCTAssertFalse(trigger.observe(identity: "a", isPlaying: true)) + } + + func testTrackChangeWhilePlayingFiresForEachNewTrack() { + var trigger = FoilSweepTrigger() + XCTAssertTrue(trigger.observe(identity: "a", isPlaying: true)) + XCTAssertTrue(trigger.observe(identity: "b", isPlaying: true)) + XCTAssertTrue(trigger.observe(identity: "c", isPlaying: true)) + XCTAssertEqual(trigger.lastSwept, "c") + } + + /// A gapless track change can report the new identity before `isPlaying` + /// settles; it fires when playback is running, not before. + func testNewTrackWaitsForPlaybackToRun() { + var trigger = FoilSweepTrigger() + XCTAssertTrue(trigger.observe(identity: "a", isPlaying: true)) + XCTAssertFalse(trigger.observe(identity: "b", isPlaying: false)) + XCTAssertTrue(trigger.observe(identity: "b", isPlaying: true)) + } + + // MARK: - pause then resume does not fire + + func testPauseThenResumeDoesNotFire() { + var trigger = FoilSweepTrigger() + XCTAssertTrue(trigger.observe(identity: "a", isPlaying: true)) + for _ in 0..<10 { + XCTAssertFalse(trigger.observe(identity: "a", isPlaying: false)) + XCTAssertFalse(trigger.observe(identity: "a", isPlaying: true)) + } + } + + // MARK: - a launch-restored queue fires on its first play, not at launch + + func testRestoredQueueFiresOnFirstPlayNotAtLaunch() { + var trigger = FoilSweepTrigger() + trigger.appear(identity: "restored", isPlaying: false) + XCTAssertFalse(trigger.observe(identity: "restored", isPlaying: false)) + XCTAssertNil(trigger.lastSwept) + XCTAssertTrue(trigger.observe(identity: "restored", isPlaying: true)) + XCTAssertFalse(trigger.observe(identity: "restored", isPlaying: true)) + } + + /// The view appearing mid-playback (opening the player during a song, the + /// Duo rebuilding it on rotation) marks that track as seen — no sweep for a + /// screen opening — while the next track still sweeps. + func testAppearingMidPlaybackMarksTheTrackSeen() { + var trigger = FoilSweepTrigger() + trigger.appear(identity: "a", isPlaying: true) + XCTAssertEqual(trigger.lastSwept, "a") + XCTAssertFalse(trigger.observe(identity: "a", isPlaying: true)) + XCTAssertTrue(trigger.observe(identity: "b", isPlaying: true)) + } + + func testNothingCurrentNeverFires() { + var trigger = FoilSweepTrigger() + XCTAssertFalse(trigger.observe(identity: nil, isPlaying: true)) + XCTAssertFalse(trigger.observe(identity: nil, isPlaying: false)) + XCTAssertNil(trigger.lastSwept) + } + + // MARK: - the same track coming back later + + func testSameTrackBackAfterADifferentTrackPlayedFiresAgain() { + var trigger = FoilSweepTrigger() + XCTAssertTrue(trigger.observe(identity: "a", isPlaying: true)) + XCTAssertTrue(trigger.observe(identity: "b", isPlaying: true)) + XCTAssertTrue(trigger.observe(identity: "a", isPlaying: true)) + } + + func testSameTrackRequeuedWithNothingInBetweenDoesNotFire() { + var trigger = FoilSweepTrigger() + XCTAssertTrue(trigger.observe(identity: "a", isPlaying: true)) + // Re-queued (a new queue entry for the same catalog track) and played. + XCTAssertFalse(trigger.observe(identity: "a", isPlaying: false)) + XCTAssertFalse(trigger.observe(identity: "a", isPlaying: true)) + } + + /// "In between" means a different track PLAYED: one that was only current + /// (skipped past while paused) doesn't reset the memory. + func testATrackThatWasCurrentButNeverPlayedDoesNotCountAsInBetween() { + var trigger = FoilSweepTrigger() + XCTAssertTrue(trigger.observe(identity: "a", isPlaying: true)) + XCTAssertFalse(trigger.observe(identity: "a", isPlaying: false)) + XCTAssertFalse(trigger.observe(identity: "b", isPlaying: false)) + XCTAssertFalse(trigger.observe(identity: "a", isPlaying: false)) + XCTAssertFalse(trigger.observe(identity: "a", isPlaying: true)) + } + + /// An emptied queue (nothing current) is not a different track either: + /// clearing and re-queueing the same track doesn't re-fire. + func testClearedQueueThenSameTrackDoesNotFire() { + var trigger = FoilSweepTrigger() + XCTAssertTrue(trigger.observe(identity: "a", isPlaying: true)) + XCTAssertFalse(trigger.observe(identity: nil, isPlaying: false)) + XCTAssertEqual(trigger.lastSwept, "a") + XCTAssertFalse(trigger.observe(identity: "a", isPlaying: true)) + } +} diff --git a/AppleNugsTests/LedgerSummaryTests.swift b/AppleNugsTests/LedgerSummaryTests.swift index bcc2d3c..e37a926 100644 --- a/AppleNugsTests/LedgerSummaryTests.swift +++ b/AppleNugsTests/LedgerSummaryTests.swift @@ -84,4 +84,47 @@ final class LedgerSummaryTests: XCTestCase { XCTAssertEqual(LedgerSummary.playsText(1), "1 play") XCTAssertEqual(LedgerSummary.playsText(2), "2 plays") } + + // --- split figures (Yeti's nice list) ---------------------------------- + + func testTotalsSplitIntoValueAndUnit() { + let totals = LedgerSummary.totals(sample) + + XCTAssertEqual(totals.hours, .init(value: "164", unit: "hours listened")) + XCTAssertEqual(totals.artists, .init(value: "6", unit: "artists")) + } + + func testTotalsFigureSingularizesAndKeepsLessThanOne() { + XCTAssertEqual(LedgerSummary.totalHoursFigure(seconds: 0), + .init(value: "0", unit: "hours listened")) + XCTAssertEqual(LedgerSummary.totalHoursFigure(seconds: 1_800), + .init(value: "<1", unit: "hour listened")) + XCTAssertEqual(LedgerSummary.totalHoursFigure(seconds: 3_600), + .init(value: "1", unit: "hour listened")) + XCTAssertEqual(LedgerSummary.totalHoursFigure(seconds: 7_200), + .init(value: "2", unit: "hours listened")) + XCTAssertEqual(LedgerSummary.artistsFigure(1), .init(value: "1", unit: "artist")) + XCTAssertEqual(LedgerSummary.artistsFigure(0), .init(value: "0", unit: "artists")) + } + + func testRowFiguresMatchTheJoinedStrings() { + let rows = LedgerSummary.top(sample) + + XCTAssertEqual(rows[0].plays, .init(value: "41", unit: "plays")) + XCTAssertEqual(rows[0].hours, .init(value: "63", unit: "h")) + for row in rows { + XCTAssertEqual(row.plays.value + " " + row.plays.unit, row.playsText) + XCTAssertEqual(row.hours.value + row.hours.unit, row.hoursText) + } + } + + func testRowHoursFigureFallsBackToMinutes() { + XCTAssertEqual(LedgerSummary.hoursFigure(seconds: 3_600), .init(value: "1", unit: "h")) + XCTAssertEqual(LedgerSummary.hoursFigure(seconds: 2_460), .init(value: "41", unit: "m")) + XCTAssertEqual(LedgerSummary.hoursFigure(seconds: 5), .init(value: "1", unit: "m")) + XCTAssertEqual(LedgerSummary.hoursFigure(seconds: 0), .init(value: "0", unit: "m")) + XCTAssertEqual(LedgerSummary.hoursFigure(seconds: .nan), .init(value: "0", unit: "m")) + XCTAssertEqual(LedgerSummary.playsFigure(1), .init(value: "1", unit: "play")) + XCTAssertEqual(LedgerSummary.playsFigure(0), .init(value: "0", unit: "plays")) + } } diff --git a/AppleNugsTests/NowPlayingMetaLineTests.swift b/AppleNugsTests/NowPlayingMetaLineTests.swift index b9b1a9e..980b3aa 100644 --- a/AppleNugsTests/NowPlayingMetaLineTests.swift +++ b/AppleNugsTests/NowPlayingMetaLineTests.swift @@ -88,8 +88,8 @@ final class NowPlayingMetaLineTests: XCTestCase { // MARK: - fields - /// MiniPlayerFaceplate, MiniPlayerClickWheel and ClickWheelScreen render the - /// artist alone; asking for one field must not leave a separator behind. + /// The Mac miniplayer renders the artist alone; asking for one field must + /// not leave a separator behind. func testRestrictingToASingleFieldDropsTheOther() { let s = Line.segments(artist: "Goose", show: "The Anthem", showId: "42", fields: [.artist]) diff --git a/AppleNugsTests/OfflineAccessPolicyTests.swift b/AppleNugsTests/OfflineAccessPolicyTests.swift index 8e122fb..9533019 100644 --- a/AppleNugsTests/OfflineAccessPolicyTests.swift +++ b/AppleNugsTests/OfflineAccessPolicyTests.swift @@ -68,3 +68,16 @@ final class OfflineAccessPolicyTests: XCTestCase { XCTAssertNil(OfflineVerification.lastVerifiedAt(in: defaults)) } } + +final class OfflineExpiryDetailTests: XCTestCase { + + func testTheExpiryErrorExplainsItself() { + XCTAssertEqual(OfflineAccessPolicy.detail(forError: OfflineAccessPolicy.expiredMessage), + "Offline play needs nugs.net to confirm your subscription at least once every 30 days.") + } + + func testOtherErrorsStandAlone() { + XCTAssertNil(OfflineAccessPolicy.detail(forError: "nugs has no stream for this track.")) + XCTAssertNil(OfflineAccessPolicy.detail(forError: "")) + } +} diff --git a/AppleNugsTests/PillDuckPolicyTests.swift b/AppleNugsTests/PillDuckPolicyTests.swift deleted file mode 100644 index af65f41..0000000 --- a/AppleNugsTests/PillDuckPolicyTests.swift +++ /dev/null @@ -1,165 +0,0 @@ -import XCTest - -final class PillDuckPolicyTests: XCTestCase { - - private typealias P = PillDuckPolicy - - /// A long list: 2000pt of scrollable range. - private func s(_ offset: CGFloat, max: CGFloat = 2000) -> P.Sample { - P.Sample(offset: offset, maxOffset: max) - } - - /// Feeds a run of offsets through the policy, pairing each with the last. - private func run(_ offsets: [CGFloat], from state: P.State = .shown, - max: CGFloat = 2000) -> P.State { - var state = state - for (a, b) in zip(offsets, offsets.dropFirst()) { - state = P.step(state, from: s(a, max: max), to: s(b, max: max)) - } - return state - } - - private let ducked = P.State(visibility: .ducked, travel: 0) - - // --- sample normalization ---------------------------------------------- - - /// Under a navigation bar `contentOffset.y` rests at minus the top inset; - /// the sample must read that as the top, or the top zone never engages. - /// The real resting geometry of Home (a ScrollView under the nav bar) on - /// iPhone Duo's outer display: it rests at the top and doesn't scroll. - func testScrollViewSampleNormalizesTheTopInset() { - let sample = P.Sample(contentOffsetY: -82, insetTop: 82, insetBottom: 72, - contentHeight: 434, frameHeight: 678) - XCTAssertEqual(sample.offset, 0) - XCTAssertEqual(sample.maxOffset, -90) - } - - /// The real resting geometry of the Artists List (below its filter field, - /// so no top inset) on the same display. - func testListSampleCountsItsBottomInset() { - let sample = P.Sample(contentOffsetY: 0, insetTop: 0, insetBottom: 34, - contentHeight: 363, frameHeight: 542) - XCTAssertEqual(sample.offset, 0) - XCTAssertEqual(sample.maxOffset, -145) - } - - /// A long list scrolled 400pt: the range is content + insets − frame. - func testScrolledSample() { - let sample = P.Sample(contentOffsetY: 318, insetTop: 82, insetBottom: 72, - contentHeight: 1500, frameHeight: 678) - XCTAssertEqual(sample.offset, 400) - XCTAssertEqual(sample.maxOffset, 976) - } - - func testInactiveSampleDoesNotScroll() { - XCTAssertEqual(P.step(ducked, from: .inactive, to: .inactive), .shown) - } - - /// The gate switching on (playback starts, or the Duo folds) over a list - /// already scrolled down delivers `.inactive` → real sample. That jump is - /// not travel; the pill must not duck the moment it appears. - func testActivationFromInactiveIsNotTravel() { - XCTAssertEqual(P.step(.shown, from: .inactive, to: s(500)), .shown) - } - - /// Content that has only just become scrollable (a page arriving under a - /// short list) carries no motion from the pass before. - func testBecomingScrollableIsNotTravel() { - XCTAssertEqual(P.step(.shown, from: s(0, max: 0), to: s(300)), .shown) - } - - // --- ducking ------------------------------------------------------------- - - func testStartsShown() { - XCTAssertEqual(P.State.shown.visibility, .shown) - } - - func testSmallDownwardTravelHolds() { - let state = run([100, 110, 120]) - XCTAssertEqual(state.visibility, .shown) - XCTAssertEqual(state.travel, 20) - } - - func testDownwardTravelPastTheThresholdDucks() { - XCTAssertEqual(run([100, 112, 124]).visibility, .ducked) - } - - /// Travel accumulates across many small frames, as real scrolling delivers it. - func testTravelAccumulatesAcrossFrames() { - let offsets = stride(from: CGFloat(100), through: 124, by: 2).map { $0 } - XCTAssertEqual(run(offsets).visibility, .ducked) - } - - /// A jitter back up starts the count over — a settling finger never ducks. - func testUpwardJitterResetsDownwardTravel() { - let state = run([100, 120, 119, 139]) - XCTAssertEqual(state.visibility, .shown) - XCTAssertEqual(state.travel, 20) - } - - // --- returning ----------------------------------------------------------- - - func testUpwardTravelReturnsADuckedPill() { - XCTAssertEqual(run([500, 494, 488], from: ducked), .shown) - } - - func testSmallUpwardTravelKeepsItDucked() { - let state = run([500, 495], from: ducked) - XCTAssertEqual(state.visibility, .ducked) - XCTAssertEqual(state.travel, 5) - } - - func testDownwardMovementResetsUpwardTravel() { - let state = run([500, 490, 495, 487], from: ducked) - XCTAssertEqual(state.visibility, .ducked) - XCTAssertEqual(state.travel, 8) - } - - func testReachingTheTopShowsEvenWithoutTravel() { - XCTAssertEqual(P.step(ducked, from: s(9), to: s(8)), .shown) - } - - /// The rubber band above the top is still the top. - func testOverscrollAboveTheTopShows() { - XCTAssertEqual(P.step(ducked, from: s(-30), to: s(-20)), .shown) - } - - /// Scrolling down from the very top: the first 8pt are the top zone, and - /// travel only counts once the content has left it. - func testLeavingTheTopCountsOnlyTravelPastTheZone() { - XCTAssertEqual(run([0, 8, 20]).visibility, .shown) - XCTAssertEqual(run([0, 8, 20, 32]).visibility, .ducked) - } - - // --- content that doesn't scroll ---------------------------------------- - - func testContentThatFitsNeverDucks() { - XCTAssertEqual(run([0, 30, 60, 90], max: 0), .shown) - XCTAssertEqual(run([0, 30, 60, 90], max: -40), .shown) - } - - /// A range shorter than the duck travel can never accumulate enough. - func testBarelyScrollableContentNeverDucks() { - XCTAssertEqual(run([0, 10, 20], max: 20).visibility, .shown) - } - - // --- the bottom edge ----------------------------------------------------- - - /// The rubber band past the bottom and its spring back are not travel: - /// the spring back must not read as "scrolling up" and return the pill. - func testBottomRubberBandIsNotTravel() { - XCTAssertEqual(run([2000, 2040, 2060, 2030, 2000], from: ducked), ducked) - } - - /// A page of results landing under a resting offset grows the range but - /// moves nothing. - func testContentGrowingAtTheBottomIsNotTravel() { - let state = P.step(ducked, from: s(2000, max: 2000), to: s(2000, max: 3000)) - XCTAssertEqual(state, ducked) - } - - func testNoMovementHoldsState() { - let partial = P.State(visibility: .shown, travel: 10) - XCTAssertEqual(P.step(partial, from: s(300), to: s(300)), partial) - } -} diff --git a/AppleNugsTests/PriceStickerLabelTests.swift b/AppleNugsTests/PriceStickerLabelTests.swift new file mode 100644 index 0000000..5ca65de --- /dev/null +++ b/AppleNugsTests/PriceStickerLabelTests.swift @@ -0,0 +1,91 @@ +import XCTest + +final class PriceStickerLabelTests: XCTestCase { + + // MARK: - pick unknown: lossless is the honest default + + func testUnknownPickSaysLosslessWithNoSpecLine() throws { + let label = try XCTUnwrap(PriceStickerLabel(pickKnown: false, isLossless: false, + bitDepth: nil, sampleRate: nil)) + XCTAssertEqual(label.title, "Lossless") + XCTAssertNil(label.spec) + XCTAssertEqual(label.accessibilityLabel, "Lossless") + } + + /// While the pick is unknown any depth/rate can only be left over from the + /// previous track, and `isLossless` means nothing yet — neither may hide the + /// sticker or print a spec line. + func testUnknownPickIgnoresLosslessFlagAndSpecs() throws { + for isLossless in [false, true] { + let label = try XCTUnwrap(PriceStickerLabel(pickKnown: false, isLossless: isLossless, + bitDepth: 24, sampleRate: 96_000)) + XCTAssertEqual(label.title, "Lossless") + XCTAssertNil(label.spec) + XCTAssertEqual(label.accessibilityLabel, "Lossless") + } + } + + // MARK: - pick known, lossless + + func testKnownLosslessWithSpecsPrintsDepthOverRate() throws { + let label = try XCTUnwrap(PriceStickerLabel(pickKnown: true, isLossless: true, + bitDepth: 24, sampleRate: 96_000)) + XCTAssertEqual(label.title, "Lossless") + XCTAssertEqual(label.spec, "24/96") + XCTAssertEqual(label.accessibilityLabel, "Lossless, 24-bit 96 kilohertz") + } + + func testFortyFourPointOnePrintsFortyFourButSpeaksTheTrueRate() throws { + let label = try XCTUnwrap(PriceStickerLabel(pickKnown: true, isLossless: true, + bitDepth: 16, sampleRate: 44_100)) + XCTAssertEqual(label.spec, "16/44") + XCTAssertEqual(label.accessibilityLabel, "Lossless, 16-bit 44.1 kilohertz") + } + + func testOtherRatesPrintWholeKilohertz() { + let cases: [(Double, String)] = [ + (48_000, "16/48"), (88_200, "16/88"), (176_400, "16/176"), + (192_000, "16/192"), (47_999.99, "16/48"), + ] + for (hz, expected) in cases { + XCTAssertEqual(PriceStickerLabel(pickKnown: true, isLossless: true, + bitDepth: 16, sampleRate: hz)?.spec, + expected, "\(hz) Hz") + } + } + + func testKnownLosslessWithoutBothSpecsHasNoSpecLine() { + let partials: [(Int?, Double?)] = [ + (nil, nil), (24, nil), (nil, 96_000), + (0, 96_000), (-1, 96_000), (24, 0), (24, -44_100), (24, .nan), (24, .infinity), + ] + for (depth, rate) in partials { + let label = PriceStickerLabel(pickKnown: true, isLossless: true, + bitDepth: depth, sampleRate: rate) + let context = "depth=\(String(describing: depth)) rate=\(String(describing: rate))" + XCTAssertNotNil(label, context) + XCTAssertEqual(label?.title, "Lossless", context) + XCTAssertNil(label?.spec, context) + XCTAssertEqual(label?.accessibilityLabel, "Lossless", context) + } + } + + // MARK: - pick known, not lossless: hidden + + func testKnownNonLosslessIsHidden() { + XCTAssertNil(PriceStickerLabel(pickKnown: true, isLossless: false, + bitDepth: nil, sampleRate: nil)) + XCTAssertNil(PriceStickerLabel(pickKnown: true, isLossless: false, + bitDepth: 16, sampleRate: 44_100)) + } + + /// The real formats, end to end through `AudioFormat.isLossless`. + func testEveryAudioFormatMapsThroughIsLossless() { + let formats: [AudioFormat] = [.unknown, .alac16, .flac16, .mqa24, .s360ra, .aac150, .hls] + for format in formats { + let label = PriceStickerLabel(pickKnown: true, isLossless: format.isLossless, + bitDepth: format.impliedBitDepth, sampleRate: 44_100) + XCTAssertEqual(label != nil, format.isLossless, "\(format)") + } + } +} diff --git a/AppleNugsTests/TapeGeometryTests.swift b/AppleNugsTests/TapeGeometryTests.swift deleted file mode 100644 index 7fc5acc..0000000 --- a/AppleNugsTests/TapeGeometryTests.swift +++ /dev/null @@ -1,75 +0,0 @@ -import XCTest - -final class TapeGeometryTests: XCTestCase { - - // --- packRadius --------------------------------------------------------- - - func testPackRadiusOnEmptyReelIsTheHubRadius() { - XCTAssertEqual(TapeGeometry.packRadius(fraction: 0, hub: 7.5, full: 21), - 7.5, accuracy: 0.0001) - } - - func testPackRadiusOnFullReelIsTheFullRadius() { - XCTAssertEqual(TapeGeometry.packRadius(fraction: 1, hub: 7.5, full: 21), - 21, accuracy: 0.0001) - } - - /// Tape winds in a flat spiral, so pack AREA is proportional to tape length - /// and the radius goes as the square root — a half-full reel is noticeably - /// fatter than the linear midpoint. - func testPackRadiusAtHalfIsAreaWeightedNotLinear() { - let r = TapeGeometry.packRadius(fraction: 0.5, hub: 7.5, full: 21) - XCTAssertEqual(r, 15.7679, accuracy: 0.001) - XCTAssertGreaterThan(r, 14.25) // the linear midpoint - } - - func testPackRadiusClampsFractionsOutsideUnitRange() { - XCTAssertEqual(TapeGeometry.packRadius(fraction: -3, hub: 7.5, full: 21), - 7.5, accuracy: 0.0001) - XCTAssertEqual(TapeGeometry.packRadius(fraction: 9, hub: 7.5, full: 21), - 21, accuracy: 0.0001) - } - - func testPackRadiusTreatsNonFiniteFractionAsEmpty() { - XCTAssertEqual(TapeGeometry.packRadius(fraction: .nan, hub: 7.5, full: 21), - 7.5, accuracy: 0.0001) - XCTAssertEqual(TapeGeometry.packRadius(fraction: .infinity, hub: 7.5, full: 21), - 7.5, accuracy: 0.0001) - } - - func testPackRadiusIncreasesMonotonically() { - var previous = -1.0 - for i in 0...10 { - let r = TapeGeometry.packRadius(fraction: Double(i) / 10, hub: 7.5, full: 21) - XCTAssertGreaterThan(r, previous) - previous = r - } - } - - // --- progress ----------------------------------------------------------- - - func testProgressIsZeroWhenDurationIsNotPositive() { - XCTAssertEqual(TapeGeometry.progress(currentTime: 30, duration: 0), 0) - XCTAssertEqual(TapeGeometry.progress(currentTime: 30, duration: -5), 0) - } - - func testProgressIsZeroWhenDurationIsNotFinite() { - XCTAssertEqual(TapeGeometry.progress(currentTime: 30, duration: .infinity), 0) - XCTAssertEqual(TapeGeometry.progress(currentTime: 30, duration: .nan), 0) - } - - func testProgressIsZeroWhenCurrentTimeIsNotFinite() { - XCTAssertEqual(TapeGeometry.progress(currentTime: .nan, duration: 100), 0) - XCTAssertEqual(TapeGeometry.progress(currentTime: .infinity, duration: 100), 0) - } - - func testProgressClampsToUnitRange() { - XCTAssertEqual(TapeGeometry.progress(currentTime: -10, duration: 100), 0) - XCTAssertEqual(TapeGeometry.progress(currentTime: 250, duration: 100), 1) - } - - func testProgressReportsTheOrdinaryFraction() { - XCTAssertEqual(TapeGeometry.progress(currentTime: 25, duration: 100), - 0.25, accuracy: 0.0001) - } -} diff --git a/CLAUDE.md b/CLAUDE.md index 4e2a16b..2b83062 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -11,38 +11,70 @@ Native **macOS + iOS SwiftUI** player for nugs.net, shipped to users as **Yeti ## Environment & build - Work in the git **worktree** you were started in; **don't `cd` to the main checkout** (the repo-root clone). Merge to `main` via a fast-forward from the main checkout using `git -C`, then push (releases/pushes are the maintainer's call). - `xcodegen generate` after any `project.yml` edit (`AppleNugs.xcodeproj/` is **gitignored**). Regenerate before building/opening. -- Schemes: `AppleNugs` (macOS, `-destination 'platform=macOS'`), `AppleNugs-iOS` (`-destination 'generic/platform=iOS Simulator'` or a device id), `AppleNugsTests` (host-free unit tests, **338** as of 2026-11-07 — CI runs them on every push, so take the count from the suite rather than from this line). +- Schemes: `AppleNugs` (macOS, `-destination 'platform=macOS'`), `AppleNugs-iOS` (`-destination 'generic/platform=iOS Simulator'` or a device id), `AppleNugsTests` (host-free unit tests, **330** as of stage e4, 2026-11-07 — CI runs them on every push, so take the count from the suite rather than from this line). - **Always build/verify with a fresh `-derivedDataPath`** — incremental builds can report a false "clean". Project must stay warning-free under `SWIFT_STRICT_CONCURRENCY=complete` on **both** schemes. SourceKit "No such module 'UIKit'" on iOS-only files = per-target index noise; `xcodebuild` is authoritative. - `docs/superpowers/` is **gitignored** (specs, plans, reports — local only, and therefore NOT a safe place for handoff state: a worktree removal deletes it. Durable session state belongs in this file plus the auto-memory index). -- Visual verify without a login: launch with `-UITEST -UITestSeedQueue` (bypasses login, seeds a now-playing queue). Theme switcher = account menu (person icon). Dashboard = `sidebar.right` toolbar button / ⌥⌘I. +- Visual verify without a login: launch with `-UITEST -UITestSeedQueue` (bypasses login, seeds a now-playing queue). Yeti is the only design (no theme switcher, no `-UITestTheme`). Dashboard = `sidebar.right` toolbar button / ⌥⌘I. - iOS device install: `DEVELOPMENT_TEAM=$APPLENUGS_TEAM_ID` (the paid team — value in the local `applenugs-distribution` memory, not in the repo) + `-allowProvisioningUpdates`; **the iPhone must be UNLOCKED** or the developer disk image won't mount. Notary profile name and Developer ID team: see the local `applenugs-distribution` memory. Full release runbook: `DISTRIBUTION.md` + the `applenugs-distribution` memory. ## Conventions worth knowing -- **Themes** (5: Soundboard, Tape Room, Shoebox, The Receiver, Click Wheel) drive personality via `theme.transport` (a `TransportSignature` enum in `AppleNugs/Theme/Theme.swift`) plus `theme.palette/type/copy/caps/washStyle`. Now-playing surfaces dispatch on it (see `AppleNugs/Views/NowPlaying/`). Reusable themed parts live in `AppleNugs/Theme/Components/` (VUMeter/KnurledButton in `FaceplateParts`, `EqualizerBars`, `ArtWashBackground`, `MonogramTile`). +- **Design system: Yeti is the only design** (macOS + iOS, dark only; the five old themes, their picker, + transport/miniplayer variants, art-color pipeline, wash and grain were deleted 2026-11-07, stages d–e). + ONE token struct, `Theme` (`AppleNugs/Theme/Theme.swift`, the only place literal hex lives): `palette` + (semantic roles — `base`/`raised`/`hairline`, `textPrimary`/`textSecondary`/`textIdle`, `accent` = foil, + kept for things that move with time, `playState` = sale yellow, `cardStock`, `stickerInk`, the sticker + stock stops, `segmentThumb`, `videoErrorBackdrop`, the bubble's plastic), `type` (size-taking font + closures) and `copy` (the app's voice). Its single value `Theme.yeti` is the `\.theme` environment + default, so views just read `@Environment(\.theme)`; `ThemedRoot` (`.themed(_:)`) sets the snow root tint + (`palette.textPrimary` — system controls never paint an accent fill; foil is applied explicitly only where + time moves), the base background, the body font and `.dark`. The Yeti parts live in + `AppleNugs/Theme/Components/`: `BlisterPack` (card stock + bubble, `PackArt`, `BlisterGlass`), the + stickers (`StickerStock`, `PlaySticker`, `PriceSticker`, `RowSticker`, `LabelSticker`), `FoilSweep` + (reads no time), `YetiButtonStyles` (+ the macOS foil focus outline), `YetiSegmentedControl`, + `EmptyPackMessage`, `MonogramTile`. Design pages: `docs/design/yeti-theme/` (`yeti-theme.html` token + sheet, `yeti-surfaces.html`, `yeti-decisions.html`) and `docs/design/yeti-icon/`. The app icon is + `AppleNugs/AppIcon.icon` (Icon Composer), regenerated by `scripts/render-app-icon.py`. - **macOS vs iOS:** `AppleNugs/macOS/` and `AppleNugs/iOS/` are per-platform (excluded from the other target); everything else is shared. Keep the untouched platform building. -- **Dashboard inspector perf:** `DashboardPanel` is a `ScrollView`+`VStack` (NOT a `List` — macOS-26 `NSTableView` reentrancy). The ~4Hz playback tick (`currentTime`) is isolated in **leaf views** so the panel body / queue `ForEach` don't re-diff every frame. **Only leaf views may read `currentTime`** — never hoist the read into a host or variant body. The current readers are `MiniPlayerScrubTrack`, `MiniPlayerClock`, `CassetteReels`, and the iOS-only `ElapsedTimeLine`/`BufferedRow`, `PanelScrubRow` (in `ExpandedPlayerPanel`) and `PillProgressRing`. Follow this pattern for any live-updating inspector content, and add to this list rather than treating it as closed. **A leaf must also render CONSTANT-HEIGHT content** — a leaf that collapses to `EmptyView` when its value is unknown changes size, and a child size change invalidates the parent's layout, which is both a visible jump and the very re-layout the isolation exists to prevent. `BufferedRow` used to do exactly that; it now always renders a row with a `SignalReadout.unknown` placeholder. +- **Dashboard inspector perf:** `DashboardPanel` is a `ScrollView`+`VStack` (NOT a `List` — macOS-26 `NSTableView` reentrancy). The ~4Hz playback tick (`currentTime`) is isolated in **leaf views** so the panel body / queue `ForEach` don't re-diff every frame. **Only leaf views may read `currentTime`** — never hoist the read into a host or variant body. The current readers are `MiniPlayerScrubTrack`, `BufferedRow` (the Signal section, both platforms), the iOS-only `ElapsedTimeLine`, `PanelScrubRow` (in `ExpandedPlayerPanel`), `PillProgressRing`, and the shared `ResumeProgressRule` (`Views/HomeResumeHero.swift`; the Mac `YetiResumeCard` + the iPhone `YetiHomeHero`, a constant 3pt rule). Follow this pattern for any live-updating inspector content, and add to this list rather than treating it as closed. **A leaf must also render CONSTANT-HEIGHT content** — a leaf that collapses to `EmptyView` when its value is unknown changes size, and a child size change invalidates the parent's layout, which is both a visible jump and the very re-layout the isolation exists to prevent. `BufferedRow` used to do exactly that; it now always renders a row with a `SignalReadout.unknown` placeholder. - **The inspector's Signal section holds a constant row set** (`SignalReadout`, pure + unit-tested). `startCurrent()` nils `nowPick`/`specs` on every track change, so gating rows on them collapsed the section from six rows to two — jumping the whole queue below it — and hid it entirely on a launch-restored queue. Gate on `player.current`, never on `nowPick`/`specs`; let unknown values degrade to `"—"` (dimmed via `foregroundStyle` ONLY — a font or weight change moves the row height and brings the bug back one row at a time). `"--:--"` stays reserved for clocks. -- **`DashboardPanel.swift` is SHARED, not macOS code.** It lives in `AppleNugs/Views/`, and iOS presents the same panel as a sheet from `ClickWheelScreen`, `NowPlayingScreen`, and `TouchFaceplate`. Anything under `AppleNugs/macOS/` called from it MUST sit behind `#if os(macOS)` or the iOS build breaks — the macOS build won't catch it. -- **Dashboard miniplayer** (`AppleNugs/macOS/MiniPlayer/`, macOS only): `DashboardMiniPlayer` dispatches on `theme.transport` to `MiniPlayerStandard` (Soundboard + Shoebox share one token-driven view), `MiniPlayerCassette`, `MiniPlayerFaceplate`, `MiniPlayerClickWheel`; shared leaves in `MiniPlayerParts.swift`, pure reel math in `TapeGeometry.swift`. The inspector column is pinned `min: 340, ideal: 340, max: 380` because a real 44-char venue string clips below 340. Tape Room's cassette holds a true **25:16** ratio with only ~19pt of slack — growing the label, the play puck, the clock font, or the reels silently yields the ratio (it has already regressed twice); the budget arithmetic is commented in `MiniPlayerCassette.swift`. -- **Now-playing metadata is clickable** (`NowPlayingMetaText` + the pure `NowPlayingMetaLine`). The artist and the show are separate `.link` runs inside ONE `Text(AttributedString)` — never an HStack of Buttons, because 8 of the 16 render sites join the two fields into one string and several have no layout slack (the cassette's 25:16, the pill's text budget, the faceplate's fixed 220pt ticker). Taps arrive as `x-applenugs-meta://` URLs and are intercepted by `.nowPlayingMetaLinks(app:ui:)`, installed at all THREE scene roots (`RootView`, `IOSRootView`, and the macOS Now Playing `Window` — a separate scene with its own environment). **The interceptor MUST return `.systemAction` for foreign URLs** or `VideoDetailView.benefitNotes`' real donation links break. Links are invisible at rest (each run carries the surrounding color explicitly — SwiftUI honors it over its own link tint, verified by pixel-comparing an offscreen render) and reveal a dotted underline on hover, macOS only. Build attributes with the `AttributeScopes.…Attribute.self` subscript, NOT the `run.foregroundColor =` dynamic-member form, which forms a key path over a non-Sendable type and warns under strict concurrency. +- **`DashboardPanel.swift` is SHARED, not macOS code.** It lives in `AppleNugs/Views/`, and iOS presents the same panel as a sheet from `NowPlayingScreen` (and beside it in `NowPlayingArrangement`). Anything under `AppleNugs/macOS/` called from it MUST sit behind `#if os(macOS)` or the iOS build breaks — the macOS build won't catch it. +- **Dashboard miniplayer** (`AppleNugs/macOS/MiniPlayer/`, macOS only): `DashboardMiniPlayer` shows the idle line or `MiniPlayerStandard` (the blister-pack miniplayer — the only variant); shared leaves in `MiniPlayerParts.swift`. The inspector column is pinned `min: 340, ideal: 340, max: 380` because a real 44-char venue string clips below 340. +- **Now-playing metadata is clickable** (`NowPlayingMetaText` + the pure `NowPlayingMetaLine`). The artist and the show are separate `.link` runs inside ONE `Text(AttributedString)` — never an HStack of Buttons, because several render sites join the two fields into one string and have no layout slack (the pill's text budget above all; the deleted cassette and faceplate variants were the original tight cases). Taps arrive as `x-applenugs-meta://` URLs and are intercepted by `.nowPlayingMetaLinks(app:ui:)`, installed at all THREE scene roots (`RootView`, `IOSRootView`, and the macOS Now Playing `Window` — a separate scene with its own environment). **The interceptor MUST return `.systemAction` for foreign URLs** or `VideoDetailView.benefitNotes`' real donation links break. Links are invisible at rest (each run carries the surrounding color explicitly — SwiftUI honors it over its own link tint, verified by pixel-comparing an offscreen render) and reveal a dotted underline on hover, macOS only. Build attributes with the `AttributeScopes.…Attribute.self` subscript, NOT the `run.foregroundColor =` dynamic-member form, which forms a key path over a non-Sendable type and warns under strict concurrency. - **Navigating from a modal surface uses `UIState.navigate(to:)`, not `open(_:)`.** It bumps a tick that every presentation owner observes via `dismissesOnNavigation(_:ui:)`. Needed because on iOS the dashboard is a sheet inside a sheet AND a sheet inside a fullScreenCover, so a tap there unwinds two layers and `@Environment(\.dismiss)` reaches only the innermost. `navigate(to:)` deliberately does NOT touch `sidebarSelection` — its `didSet` resets `navPath` and would wipe the push. Deep links route through it too. -- **`NowPlayingMetaText` carries a `tappableText` flag** (and `showsLinks` for the offline Downloads sheet, which has no stack to push onto). All 16 sites are real tap links today, the pill and Home hero included — Tim asked for that explicitly after they initially shipped as long-press/VoiceOver-only. If a mis-tap problem ever shows up on those two (both have their whole body as a competing tap target), `tappableText: false` is the dial, not a rewrite. +- **`NowPlayingMetaText` carries a `tappableText` flag** (and `showsLinks` for the offline Downloads sheet, which has no stack to push onto). Every render site (10 since stage e) is a real tap link, the pill and both Home heroes included (`AlbumDetailView`'s artist link reuses the `x-applenugs-meta://` route) — Tim asked for that explicitly after they initially shipped as long-press/VoiceOver-only. If a mis-tap problem ever shows up on the pill or a Home hero (each has its whole body as a competing tap target), `tappableText: false` is the dial, not a rewrite. - **`PlaybackStateStore` no longer writes under `-UITEST`** (both `save` and `clear`, each behind `#if DEBUG` because `AppModel.isUITestRun` is itself DEBUG-only and is now `nonisolated` so the nonisolated store can read it). This retires the long-standing seeded-launch hazard: a `-UITestSeedQueue` run that touched the transport used to persist stub tracks over the real resume queue, and "Clear" deleted it outright. Reads are deliberately still live. -- **iPhone Duo layout rules.** (1) **Now-playing art must yield height** — never a rigid tile: the no-art - `MonogramTile` scales with its box (capped at its old 280/260pt), exactly like real artwork; a fixed - tile overflows short canvases (the Duo's landscape inner display, and portrait iPhones by ~15pt). +- **iPhone Duo layout rules.** (1) **Now-playing art must yield height** — never a rigid tile: the + bubble is square and takes what the proposal leaves after the nameplate (capped by `maxBubbleSide`, + 320pt on the now-playing card), and with no cover `PackArt`'s `GeometryReader` sizes the `MonogramTile` + to that box, exactly like real artwork; a fixed tile overflows short canvases (the Duo's landscape inner + display, and portrait iPhones by ~15pt). (2) **The pill sheds controls by measured WIDTH** (`PillLayout.controls(slot:width:hasLeadingSlot:trailingReserve:)`, 90pt text floor), because on the Duo's outer display a vertical tab bar leaves a ~290pt accessory that - still reports `.expanded`. (3) The iOS full-screen cover hosts `NowPlayingArrangement`; the pure + still reports `.expanded`. **The Duo's pill is Apple's `tabViewBottomAccessory`, exactly like every other + iPhone, vertical tab bar or not** (`NowPlayingAccessory` in `IOSRootView`): the system's scroll edge effect + handles content under the bars (the tab stacks set `.scrollEdgeEffectStyle(.soft, for: .bottom)` — with a + vertical bar `.automatic` drew none and content ran crisp under the pill; iPhones render unchanged), and on the folded Duo the pill does not tuck away on scroll — accepted. + No custom overlay, shelf or duck, and no fork on `\.toolbarVerticalEdge` (Tim, 2026-11-07: follow the + system; the `feedback-system-guidelines-first` memory). (3) The iOS full-screen cover hosts `NowPlayingArrangement`; the pure `NowPlayingColumns` policy picks compact → player alone, regular + wider-than-tall → iOS 27.1 `ArrangementView` (player + `DashboardPanel(showsNowPlaying: false)`), regular + upright → an explicit HStack with a 220–260pt queue column (ArrangementView's horizontal split refuses tall canvases). It also owns **swipe-down to close** (pure `NowPlayingDismissDrag`; a plain `.gesture` on the PLAYER only, - so sliders/buttons/The Receiver's ladder win; the moving layer must carry its own `safeAreaPadding` + so sliders and buttons win; the moving layer must carry its own `safeAreaPadding` because offset content stops painting its safe-area backgrounds). Players read `\.nowPlayingQueueBeside` and swap their queue button for a same-size hidden placeholder. Scaling a fixed-geometry cluster uses `NowPlayingFit.scale` + `scaleEffect` + a matching `.frame` — `scaleEffect` alone keeps the old footprint. +- **Audio/video arbiter: only one plays, the last one DELIBERATELY started wins** (2026-11-08 fix for + audio and video playing over each other). `VideoPlayerService` calls `PlayerService.pauseForExternalAudio()` + on EVERY claim, even one it thinks it already holds; that silences the engine unconditionally and reads + "was playing" from `isPlaying || player.rate != 0`, not the flag alone. While `suspendedForVideo`, + `PlayerService.startPlayback()` REFUSES to start, so an automatic start (a stream resolve or retry landing + after the video took over) can't play underneath. Deliberate audio starts — `startAt` (play/enqueue/jump/ + next/previous; `itemDidEnd`'s auto-advance passes `deliberate: false`), `resume`, `togglePlayPause`, the gapless `next()` — call `takePlaybackFromVideo()` first, + which fires the `yieldExternalOwner` hook → `VideoPlayerService.yieldToAudio()` (pause the video, hand + back with `resume: false`). Any new audio entry point that means "the listener pressed play" must call + `takePlaybackFromVideo()`; one that doesn't will be silently swallowed while a video owns playback. - Catalog dates are UTC-midnight instants — use `CrateSection.catalogCalendar`, not `Calendar.current` (see the `applenugs-catalog-dates-are-utc` memory). - **Listening stats are artists-only rolling aggregates with NO event log** (landed 2026-11-07; the Home "nice list" the Yeti design builds on). `ListeningStats` (the table), `ListeningCredit` (accumulator + @@ -70,11 +102,55 @@ Native **macOS + iOS SwiftUI** player for nugs.net, shipped to users as **Yeti (the store seam is neutral so `VideoPlayerService` could become a second caller). Reset lives in the account menu behind a `.confirmationDialog` (verified presenting from the macOS toolbar `Menu`). -## START HERE — state as of 2026-11-07 (handoff for a new session) +## START HERE — state as of 2026-11-08 (handoff for a new session) + +**LANDED ON `main` 2026-11-08** (fast-forward of `yeti-design`, Tim's call): everything below, plus three +fixes Tim found living with it on his Duo — audio and video no longer play over each other (`8ed1f93`, +`f77d049`; see Conventions → arbiter), and the bubble's halo clears the hang slot (`ef97eb2`; the card's top +padding is DERIVED from the slot + clearance + halo — don't copy the sheet's 30–34pt tops back). Both fixes +are on Tim's Duo; he confirmed the hang slot ("looks great"), the audio/video fix is awaiting his check. + +**YETI IS THE ONLY DESIGN — stages (d) and (e) BUILT (2026-11-07, branch `yeti-design`, 330 tests, +gate green at e4).** (d) `f1503dc` Yeti-only + the macOS foil focus outline; then the app icon (`8969457`, +"The bubble") and the Duo pill back on the system accessory (`a114586`); (e) e1 `653769c` transport + +miniplayer variants deleted, e2 `cb040e4` art-color pipeline + wash + grain deleted, e3 `8bf351d` every +caps branch collapsed to Yeti's side, e4 (the commit after it) `Theme` stripped to ONE token struct — +`ThemeID`, `ThemeManager`, `Capabilities`, `TransportSignature` and the five theme literals gone, the +off-palette hex folded into `Palette` (see Conventions → Design system). iOS sim screenshots were +pixel-identical stage to stage (bar the clock and Home's random crate row). **Next:** (1) ~~the whole-branch +review~~ done; (2) a visual pass on the iPhone sim, the Duo sim (both +displays) and the Mac (seeded launch) against the three design pages; (3) a device install on Tim's Duo; +(4) the hardware-only checks listed below ("Unverified, needs hardware") plus Mac keyboard focus showing +the foil outline (not the system ring) on our own controls — Keyboard navigation ON, Tab to Login's "Sign in with nugs.net", Album's "Play all", Follow: if BOTH rings show, `.focusEffectDisabled()` inside the Yeti ButtonStyles doesn't reach the Button, so add `.macFocusEffectDisabled()` at the `.yetiPrimary`/`.yetiSecondary` call sites (the four `.plain` sites already have it). Also glance at the Mac `EntryTile` icon, drawn in foil (pre-existing; foil is meant only where time moves). Review of `9c879d9..HEAD` (2026-11-07): no must-fix; its one should-fix (soft edge effect not reaching pushed screens on the folded Duo) is applied. Spec: `docs/superpowers/specs/2026-11-07-yeti-only-design.md` (local). + +*History — stage (c), kept for its decisions and its unverified list:* Yeti was built as a selectable sixth theme covering every surface (spec/plan local: `docs/superpowers/specs|plans/2026-11-07-yeti-theme-six*`). +Commits `9792b1b`..`8cec8e9`: tokens/caps/rootTint → stickers → BlisterPack + FoilSweep → now-playing card → +panel/dashboard/Mac mini/transport bar → Home (+ iPhone hero) → browse → video → front door + app-wide copy → +polish (`25c1f9b`) → two review-fix commits. A Debug build of tasks 1–8 is on Tim's real Duo; he said "this looks +great". Gate script + sim screenshot script live in the session scratchpad (gone with it): rebuild them from the +plan's "Shared rules" if needed. **Next, in order:** (1) the Duo pill SHELF (`docs/design/yeti-theme/yeti-pill-shelf.html`, +awaiting Tim's OK on the motion) — design guard: ignore scroll deltas while the shelf animates, or a fold at the +bottom of a page reads as a scroll up and bounces the pill back — Tim APPROVED the motion ("the pink swipe looks +really good… it's fun"), BUILT (`17f0f12`, `3ccfb93`) — then **REVERSED the same day**: on his Duo the system's vertical tab rail crossed the shelf's lip, and Tim chose **FULLY SYSTEM** ("I would rather follow the system guidelines than run anything that needs like custom stuff"): the Duo uses Apple's `tabViewBottomAccessory` like every iPhone, the system's scroll edge effect handles content under it, and the custom overlay, duck, shelf, `PillDuck*` and `-UITestPillTucked` are removed. Accepted trade: on the folded Duo the pill no longer tucks away on scroll. Don't rebuild a custom pill overlay/shelf/rail; see the `feedback-system-guidelines-first` memory. (`yeti-pill-shelf.html`, `yeti-rail.html` are history.) (2) stage (d) — DONE `f1503dc` Yeti-only + Mac foil focus, (3) stage (e) delete the five themes — +spec `docs/superpowers/specs/2026-11-07-yeti-only-design.md` (local), approved: Yeti on launch with NO migration +(Tim: "no one's really using this app… it's only on my phone" — he is the only user; never design for other +users' saved state here), foil focus on our own controls only, the art-color pipeline deleted, ONE `Theme` token +struct kept. (4) A new app icon — Tim chose the BLISTER PACK direction; concepts in `docs/design/yeti-icon/`; **Tim picked +concept 2, "The bubble"** (the bubble filling the tile, holding the yellow playing sticker with its four stepping +bars, one thin foil glint on the rim) as-is — "that's the right choice", no tweaks. The whole-branch review listed the collapse hazards for (d)/(e) (ALL RESOLVED in e1–e4): ~8 `tintIfSet(plainVoice ? x : nil)` +sites, ~20 per-view `plainVoice`/`stickers` computed properties, `.stickers`-before-`.equalizerRows` precedence +checks (DashboardPanel queue slot, `titleBlock`, CrateHeader), `card ? X : 0` paddings in `NowPlayingScreen` (was `StandardNowPlayingScreen`), +two resume cards (`HomeView.resumeCard` + `YetiResumeCard`), duplicate helpers `yetiShowAction` (AlbumDetailView) ≡ +`videoSave` (VideoDetailView), dead `MiniPlayerTransportTriad.Style.sticker` path, off-palette hex (`YetiSegmentedControl` +thumb 0x2A355A, `VideoErrorMessage` backdrop 0x05070D), `IdleCopy`'s defaulted fields. **Unverified, needs hardware:** +iPhone Home hero tap arbitration (meta link vs card `.onTapGesture`), `ExpandedPlayerPanel` fitting 250pt, FoilSweep on a +real track change, price sticker across a track change, back label after `toolbar(removing: .title)` on the artist page, +Mac Playback ▸ Clear Queue `NSAlert` in each window state, VoiceOver on the hero/segmented control/login. Known nits left: +`SearchView.sentenceCased` turns "DJ SETS" into "Dj sets"; `YetiSegmentedControl` isn't one arrow-key control on macOS. **Where the code is.** Worktree `.claude/worktrees/iphone-duo` (stay in it; don't cd to the main checkout). - **`main` == `origin/main` == `e6ab835`** — everything above is LANDED: the iPhone Duo work (PR #5) and the - pill duck + Yeti compliance/rename/encrypted downloads/perf + the theme design (PR #6, merged 2026-11-07, + pill duck (since REMOVED — the Duo uses the system accessory, see Conventions) + Yeti compliance/rename/encrypted downloads/perf + the theme design (PR #6, merged 2026-11-07, CI green on `xcode-27`, fast-forward — every hash in this file is on `main`). 307 tests. - **Listening stats LANDED on top (2026-11-07)** — `listening-stats` rebuilt on this base as `listening-stats-rebased` (Tim's feature commit replayed as-is, plus a fix crediting background @@ -108,14 +184,54 @@ Pushing is Tim's call. 3. **DECISION (Tim, 2026-11-07): Yeti becomes the ONLY design, on macOS and iOS — the five themes go** (reverses the Aug-2026 "keep all themes" call). Staged plan proposed: (a) land the branches; (b) design pass for surfaces the mockup doesn't cover (video, search, downloads, login/connection-failed, Duo two-column + - pill-duck overlay, Mac Now Playing window, sheets, toasts); (c) add Yeti as theme #6 through the existing + pill-duck overlay [history: overlay since removed, system accessory], Mac Now Playing window, sheets, toasts); (c) add Yeti as theme #6 through the existing token system and verify on device; (d) make it the only theme (remove pickers, force dark); (e) delete the five themes, transport variants, art-color pipeline, wash/grain, ThemeID/ThemeManager (~2,300 lines); keep ONE token struct as the design system. The Home "nice list" ledger is `ListeningLedger` (landed 2026-11-07). Spec it before building. Source design: `docs/design/yeti-theme/yeti-theme.html` (+ `.png`, and the three concepts it beat). It carries a token sheet that maps 1:1 onto `Theme` fields and a - "Wiring it in" list (ThemeID `.yeti`, EqualizerBars branch, play-sticker sites, FoilSweep, BlisterBubble). + "Wiring it in" list (ThemeID `.yeti`, EqualizerBars branch, play-sticker sites, FoilSweep, BlisterBubble — + HISTORY: ThemeID and EqualizerBars are deleted now). **STATUS: stages (c), (d) and (e) are all BUILT — see + the top of START HERE.** The rest of this item is the record of how it was decided. Never quote the song's lyrics in the app or docs (copyrighted) — paraphrase only. + **Decisions taken at kickoff (Tim, 2026-11-07, session on `yeti-design`):** (b) the design pass comes FIRST, + before any Swift (Tim chose it over building theme #6 for the mocked surfaces straight away); the FULL + play sticker ships (Home hero, iPhone now playing, Mac mini triad — not the price-sticker fallback); the + copy "It's on its way" / "Here it comes" / "Says so on the box" / "Coming up next" / "The nice list" is + cleared as paraphrase, not lyrics; the macOS custom foil focus outline is DEFERRED to stage (d), when + Yeti is the only theme. The superpowers skills were not installed in that session, so the + brainstorm → spec → plan steps are being done by hand. Design-pass output: + `docs/design/yeti-theme/yeti-surfaces.html` + `.png`, beside `yeti-theme.html` (UNCOMMITTED until Tim + says so). **Tim's rulings (2026-11-07, via `yeti-decisions.html`), all final:** (1) iPhone Home gets a + hero = the Mac's resume card ported (what's playing; absent when nothing is loaded) — NOT a "newest from + artists you follow" card, which the first sheet's label implied and which doesn't exist; (2) the Artists + list stays names only, no per-artist show counts (the catalog list is name + id only; a fan-out would + break polite networking); (3) the now-playing counter stays "Track 3 of 24" (sentence case); set-aware + "Set 2, track 3 of 6" is a later follow-up; (4) the Mac Home resume card is a FLAT cover on card stock — + the Mac's bubble belongs to the dashboard miniplayer (and the Now Playing window, drawn as a second bubble + on purpose). Proposals all accepted: "Sign out" app-wide (Title Case "Sign Out" in menus; replaces "Log + Out"); all three clear-queue paths confirm with "Playback stops and the whole queue empties." + (`PlayerService.clear()` empties the whole queue AND stops playback); the app's real tabs and sidebar + stay; Signal keeps its six constant rows (restyle only); system traffic lights stay. Every new copy line + on `yeti-surfaces.html` is cleared as not-lyrics. `yeti-decisions.html` is the walkthrough Tim answered + (needs a real browser — the Claude browser pane renders local files with scripts disabled). + **Tim, after living with theme #6 on his Duo (2026-11-07): "this looks great, make this the default + experience"** = Yeti becomes the ONLY design (stage d, then e) — confirmed over "Yeti default, keep the + picker". Also (HISTORY — built, then REVERSED 2026-11-07 for the system accessory; see Conventions): the Duo's own pill overlay (vertical tab bar) crowds busy content, so the pill gets its + OWN SHELF — content stops above it instead of running under it, and when the pill ducks the shelf + folds shut with a Yeti-style motion and content eases into the freed space (chosen over moving the + pill to the top, or keeping it floating over a fade). Design that motion as a mockup before building. + **Stage (c) spec:** `docs/superpowers/specs/2026-11-07-yeti-theme-six-design.md` (local-only; awaiting + Tim's review as of 2026-11-07). Its load-bearing decisions (the caps gating is HISTORY — the caps and + `theme.id` are deleted; the rest still holds): gating by four caps (`.blisterPack`, `.stickers`, + `.foilSweep`, `.plainVoice`), never `theme.id`; the ROOT TINT is snow (`palette.textPrimary`, set by + `ThemedRoot`), and foil is applied explicitly only where time moves (scrubs, ring, sweep, + caret, download/resume progress) — otherwise every prominent control turns into a pink fill; the price + sticker's PRESENCE keys on `player.current`, its text on the pick (unknown pick → "Lossless", known + non-lossless → hidden) so it never blinks at a track change; `EqualizerBars` sites are decided one by + one (queue → 16pt row sticker, setlist → new 20pt site, title + crate header → nothing); "Listen + offline" keeps today's downloads-exist gate. The page is assembled from `yeti-theme.html`'s CSS + drawings by a + splice script; to edit it, edit the HTML directly. 4. **Nightstand / tent** (Tim chose all three: propped-open player with clock + night dimming, tent-pose landscape player that auto-opens, StandBy Now Playing widget). Start with a DEBUG pose probe (hinge status/angle, division regions, display) Tim screenshots in each pose. See the NEXT block below. @@ -134,6 +250,9 @@ copied into this file). ## Pick up here — state as of 2026-08-18 +*HISTORY. Written while five themes existed: the Soundboard/Tape Room/Shoebox/The Receiver/Click Wheel +names below refer to deleted themes and players; the measurements, recipes and rules still hold.* + ### iPhone Duo layout — MERGED to `main` (`805d766`, PR #5, 2026-11-06); CI green on `xcode-27` **Landed:** pushed as `iphone-duo`, PR #5's CI passed (first run on the `xcode-27` runner, 2m24s — the @@ -179,7 +298,9 @@ disclaimers · `2db580f` encrypted downloads. **302 tests.** Rules that fail sil `pill-duck` in the same worktree; spec `docs/superpowers/specs/2026-11-05-pill-duck-on-scroll-design.md`; probe verdict: the system accessory can't be animated (toggling `isEnabled` pops/crossfades AND reflows the content 38–56pt), so where `\.toolbarVerticalEdge != nil` (iOS 27.1+) the pill becomes our own glass overlay -with a constant bottom content margin. (2) nightstand/tent — spec +with a constant bottom content margin. **[HISTORY — REVERSED 2026-11-07: the overlay, its duck and the later +shelf are removed; the Duo uses the system `tabViewBottomAccessory` like every iPhone. See Conventions.]** +(2) nightstand/tent — spec `docs/superpowers/specs/2026-11-05-duo-nightstand-tent-design.md`: propped-open player (art + title + clock above the fold, dims at night), tent-pose landscape player (auto-opens; app otherwise stays portrait), and an interactive StandBy Now Playing widget (first widget extension + App Group). No posture enum exists — @@ -267,17 +388,22 @@ venue hint is given.** If a picker ever gets built, update that doc and the site fallback on 2026-08-18 — a machine without AppleNugs forwards to nugs.net after the ~1.2s race, as designed. Still owed: signed-in spot-check of `play.nugs.net/release/46887|46884|46883`. -### Earlier state (2026-08-10 → 2026-08-17, still-true details below) +### Earlier state (2026-08-10 → 2026-08-17) — HISTORY; still-true details below + +Written when there were five themes and an art-driven accent. Since the Yeti-only refactor (2026-11-07) the +pill ring is tinted `palette.accent` (static foil), there is no `\.artColor`/`effectiveAccent(art:)`, and the +faceplate/click-wheel/cassette surfaces named below are deleted. The pill's structure, the circle clip and +the accessory facts still hold. **The iOS now-playing pill is SHIPPED — merged and pushed.** `main` == `origin/main` == `11dae49`, tree clean, no worktrees, no stale branches. The 10-task plan (`docs/superpowers/plans/2026-08-10-ios-nowplaying-pill.md`, spec `docs/superpowers/specs/2026-08-10-ios-nowplaying-pill-design.md`, both gitignored/local-only) replaced the iOS full-width bottom transport bar with a Liquid Glass pill hosted in `tabViewBottomAccessory`. Both defects the iOS parity review diagnosed are structurally gone: the pill lives on the `TabView`, so it rides above pushed detail screens for free, and the toast's hardcoded `.padding(.bottom, 78)` is replaced by a plain `8` anchored to the NavigationStack. -**The pill's progress ring is MERGED and PUSHED** (2026-08-11; `main` == `origin/main` == `7d2c318`, tree clean, branch deleted). Reviewed clean; **device verification is PARTIAL — see below.** Spec `docs/superpowers/specs/2026-08-10-pill-progress-ring-design.md`, plan `docs/superpowers/plans/2026-08-10-pill-progress-ring.md` (both gitignored/local-only). `PillSeekEdge` is deleted entirely (drag, 20pt hit strip, hairline) — scrubbing belongs to the expanded panel and the full-screen player, both of which have real sliders. In its place `PillProgressRing` draws a 2pt arc around the 32pt leading slot, tinted `theme.effectiveAccent(art:)`, empty when `duration <= 0` (a full ring on a no-duration stream would read as "finished" — same defect `11dae49` fixed on the sliders). **`PillLeadingSlot` collapsed from five themed variants to one uniform `ArtChip`**; **no themes were removed** (considered and explicitly rejected) — theme identity stays in the full-screen player, palette, type and the Mac miniplayer. `PillLayout.seekEdgeHeight` / `seekHitHeight` / `controlsTrailingInset` and their three tests are gone; `ringLineWidth`/`ringGap`/`artChipSize(slot:)`/`progressFraction(currentTime:duration:)` replace them. Gate at HEAD: both schemes `** BUILD SUCCEEDED **` warning-free, `AppleNugsTests` `** TEST SUCCEEDED **` at **98 tests**. +**The pill's progress ring is MERGED and PUSHED** (2026-08-11; `main` == `origin/main` == `7d2c318`, tree clean, branch deleted). Reviewed clean; **device verification is PARTIAL — see below.** Spec `docs/superpowers/specs/2026-08-10-pill-progress-ring-design.md`, plan `docs/superpowers/plans/2026-08-10-pill-progress-ring.md` (both gitignored/local-only). `PillSeekEdge` is deleted entirely (drag, 20pt hit strip, hairline) — scrubbing belongs to the expanded panel and the full-screen player, both of which have real sliders. In its place `PillProgressRing` draws a 2pt arc around the 32pt leading slot, tinted `theme.effectiveAccent(art:)` (today `palette.accent`), empty when `duration <= 0` (a full ring on a no-duration stream would read as "finished" — same defect `11dae49` fixed on the sliders). **`PillLeadingSlot` collapsed from five themed variants to one uniform `ArtChip`**; **no themes were removed** (considered and explicitly rejected) — theme identity stays in the full-screen player, palette, type and the Mac miniplayer. `PillLayout.seekEdgeHeight` / `seekHitHeight` / `controlsTrailingInset` and their three tests are gone; `ringLineWidth`/`ringGap`/`artChipSize(slot:)`/`progressFraction(currentTime:duration:)` replace them. Gate at HEAD: both schemes `** BUILD SUCCEEDED **` warning-free, `AppleNugsTests` `** TEST SUCCEEDED **` at **98 tests**. **One design decision worth not re-deriving: the art chip is clipped to a CIRCLE.** `artChipSize` yields 26pt as an *inscribed-circle diameter*, but `ArtChip` renders a rounded square whose corners reach r≈16.9 against the ring's inner edge at r=14 — so a square chip put the accent arc on top of the cover across roughly half its circumference, worst on the art-driven themes where the arc *is* the cover's dominant color. `.clipShape(Circle())` (applied before the 32pt `.frame`, so it clips the 26pt chip) holds the 1pt `ringGap` at every angle. The rejected alternatives were a ~20pt square (clears the ring but wastes the slot) and a rounded-rect track (non-uniform angular speed). **Device verification — what's CONFIRMED on a real iPhone 16 Pro (iOS 27.0, real logged-in session, 2026-08-11):** the circular art chip renders clean with **no keyline slivers** at 12/3/6/9 — that computed-remnant worry is closed, `ArtChip` needs no `shape` parameter; the **background track ring renders** with its gap holding all the way around (no arc-on-artwork, which was the whole point of the circle clip); chevron → `ExpandedPlayerPanel` still reaches scrub row, five controls, favorite star, ALAC badge, AirPlay and Queue. `\.artColor` is reaching the app generally — the tab tint picked up the cover's magenta, not the amber fallback. -**Still UNCONFIRMED, and the one that matters:** the **accent arc itself**. Every screenshot so far was taken seconds into a 25-minute track, where the fraction is ~0.013 → an arc of about 4.6°, i.e. a pixel and a half. **The test is: scrub to ~50%, then look at the pill.** Half the ring in the cover's accent = the arc advances AND `effectiveAccent(art:)` resolves inside the tab accessory (which nothing had ever read there before). Half the ring in amber = `\.artColor` is not propagating into `tabViewBottomAccessory` and needs fixing. Also still owed: ring **empty, not full**, on a no-duration stream (livestream/webcast — the one case with a real defect behind it); legibility at `.inline`; the ~0.25s stepping (the trim has no `.animation` by design — add one if it stutters); gestures (the pill now has NO drag gesture at all, so a vertical swipe should go straight to the tab bar's scroll-to-minimize); and the offline Downloads sheet mount. +**[Partly moot since stage e2: no art-driven accent exists, so the `\.artColor` propagation question is closed; the arc's advance, the empty ring on a no-duration stream and the other items below are still unconfirmed on hardware.]** **Still UNCONFIRMED, and the one that matters:** the **accent arc itself**. Every screenshot so far was taken seconds into a 25-minute track, where the fraction is ~0.013 → an arc of about 4.6°, i.e. a pixel and a half. **The test is: scrub to ~50%, then look at the pill.** Half the ring in the cover's accent = the arc advances AND `effectiveAccent(art:)` resolves inside the tab accessory (which nothing had ever read there before). Half the ring in amber = `\.artColor` is not propagating into `tabViewBottomAccessory` and needs fixing. Also still owed: ring **empty, not full**, on a no-duration stream (livestream/webcast — the one case with a real defect behind it); legibility at `.inline`; the ~0.25s stepping (the trim has no `.animation` by design — add one if it stutters); gestures (the pill now has NO drag gesture at all, so a vertical swipe should go straight to the tab bar's scroll-to-minimize); and the offline Downloads sheet mount. **Known accepted cost:** VoiceOver loses the pill's at-a-glance position *value* — the deleted seek edge exposed "Playback position" + a formatted `accessibilityValue`, and the ring is `accessibilityHidden`. Nothing became unreachable (both accessibility actions are applied unconditionally on the `onExpand != nil` path, including at `.inline`, and both destinations have real sliders), but the announcement is genuinely gone. @@ -287,11 +413,11 @@ race, as designed. Still owed: signed-in spot-check of `play.nugs.net/release/46 **Three hard-won facts, don't re-derive them.** (1) **`tabViewBottomAccessory` measures 360×48pt expanded, 284×48pt inline; the height is system-owned with no API knob** — see the `applenugs-ios-tab-accessory-geometry` memory. (2) **A `.sheet` attached INSIDE the accessory runs its content's body but never appears** — the accessory is a system-hosted container, not a presentation context; the shell presents the panel and the pill takes an injected `onExpand`. (3) **The drag-up gesture loses to the tab bar's own scroll-to-minimize handling** — that's why there's a chevron. Also: `TabBarMinimizeBehavior` has only `.automatic/.onScrollDown/.onScrollUp/.never` — there is no "always minimized", so a permanently one-row bottom is not reachable without abandoning the accessory. -**Verified on a real iPhone 16 Pro (iOS 27.0), with a real logged-in session:** the pill survives a push to `ArtistDetailView` (the core fix); tap opens the full-screen player; the transport buttons keep their own taps (the container tap does NOT steal them — measured both ways); the chevron opens the panel and its Queue button reaches `DashboardPanel`; the panel is legible with real art and an art-driven accent; drag-to-seek on the pill's edge worked (that affordance has since been **replaced by the progress ring** — see above, so this line is history, not current behaviour); and **the `.inline` collapse works and looks good** — tab bar shrinks to the Home button with the pill folded in beside it. All five themes verified at the tab root in the simulator. Gate: both schemes `** BUILD SUCCEEDED **` warning-free under `SWIFT_STRICT_CONCURRENCY=complete` bar the known `appintentsmetadataprocessor` one; `AppleNugsTests` `** TEST SUCCEEDED **` at **94 tests**. +**Verified on a real iPhone 16 Pro (iOS 27.0), with a real logged-in session:** the pill survives a push to `ArtistDetailView` (the core fix); tap opens the full-screen player; the transport buttons keep their own taps (the container tap does NOT steal them — measured both ways); the chevron opens the panel and its Queue button reaches `DashboardPanel`; the panel is legible with real art and an art-driven accent; drag-to-seek on the pill's edge worked (that affordance has since been **replaced by the progress ring** — see above, so this line is history, not current behaviour); and **the `.inline` collapse works and looks good** — tab bar shrinks to the Home button with the pill folded in beside it. All five themes (since deleted) verified at the tab root in the simulator. Gate: both schemes `** BUILD SUCCEEDED **` warning-free under `SWIFT_STRICT_CONCURRENCY=complete` bar the known `appintentsmetadataprocessor` one; `AppleNugsTests` `** TEST SUCCEEDED **` at **94 tests**. **Still unverified, honestly:** the offline Downloads sheet's pill (needs a real `.connectionFailed` state), VoiceOver behaviour anywhere in the pill, Dynamic Type at accessibility sizes against the panel's fixed `.height(250)` detent, and the keyboard-up triple-stacked-chrome risk carried over from the parity review. Evidence ledger + screenshots + all task briefs/reports/review diffs: `docs/superpowers/pill-artifacts/` (gitignored, local-only). -**`11dae49` fixed a cross-cutting scrub bug:** `sliderMax = max(duration, 1)` meant a stream reporting no duration pinned the thumb hard right, reading as "finished". Fixed on four iOS surfaces (`NowPlayingScreen`, `ClickWheelScreen`, `TouchFaceplate`, `ExpandedPlayerPanel`), and **the two macOS surfaces are now fixed too** — `FaceplateTransport` and `TransportBar.seekBlock` (`be51ccf`, merged to main). **All six scrub surfaces now carry the same guard**; grep `player.duration > 0 ? min(player.currentTime, sliderMax) : 0` to confirm the count is still six if you add a seventh slider. +**`11dae49` fixed a cross-cutting scrub bug:** `sliderMax = max(duration, 1)` meant a stream reporting no duration pinned the thumb hard right, reading as "finished". Fixed on four iOS surfaces (`NowPlayingScreen`, `ClickWheelScreen`, `TouchFaceplate`, `ExpandedPlayerPanel`), and **the two macOS surfaces are now fixed too** — `FaceplateTransport` and `TransportBar.seekBlock` (`be51ccf`, merged to main). **Since stage e1 deleted `ClickWheelScreen`, `TouchFaceplate` and `FaceplateTransport`, three scrub surfaces remain, all carrying the guard: `NowPlayingScreen`, `ExpandedPlayerPanel`, `TransportBar.seekBlock`**; grep `player.duration > 0 ? min(player.currentTime, sliderMax) : 0` to confirm the count is still three, and give any new slider the same guard. (The Mac miniplayer's `MiniPlayerScrubTrack` is a custom track, not a `Slider`.) **TOOLCHAIN — check it, don't assume it.** **As of 2026-11-05 `xcode-select` points at Xcode 27.2** (`/Applications/Xcode.app`, iOS SDK 27.2, Swift 6.4.2); unchanged `origin/main` builds both schemes warning-free on it. The 26.6 note that follows is history. As of 2026-08-17 `xcode-select` points at the **stable** `/Applications/Xcode.app/Contents/Developer` (Xcode 26.6, build 17F113), which is also what CI now uses, so local and CI agree. The durable fact behind the old warning still holds: **CoreDevice's developer disk image comes from the *selected* Xcode, not from `DEVELOPER_DIR`**, so installing to Tim's iPhone while it runs a newer iOS than the stable SDK supports means selecting the beta first — and switching back before any release work, since the notarized DMG and Sparkle build from the same `project.yml`: @@ -313,10 +439,10 @@ sudo xcode-select -s /Applications/Xcode.app/Contents/Developer # back fo **Verification on v1.3, and what's still open.** Tim confirmed the full-screen button works and playback keeps running across the transition — first on the signed pre-release build, then again on the installed 1.3 after updating — so the `onDisappear`-teardown risk did NOT materialize. Two things were never exercised and remain unproven: **layout/LIVE-badge restore on exit**, and **the end-in-full-screen audio case** (video finishes full screen with the audio queue previously playing → nothing should start underneath, and audio should resume on exit). That deferral logic also has **no unit test** — `VideoPlayerService` pulls in `PlayerService`/`NugsClient`/`VideoProgressStore` and `AppleNugsTests` is host-free. Note the harness limitation recorded in the `applenugs-swiftui-hypothesis-testing` memory: **AVKit renders its floating controls out-of-process**, so subview walks, AX walks, and `cacheDisplay` PNGs are all blind to them — don't burn time re-deriving that. -**Previously — the miniplayer visual-verification campaign (2026-08-01) is CLOSED.** All 15 checklist items got verdicts (evidence ledger: `docs/superpowers/miniplayer-artifacts/reports/verification-campaign-report.md`; contact sheet beside it; renders in `../renders/`). It was run three ways: an offscreen `swiftc`+`NSHostingView` harness extended to **PNG capture** via `NSWindow.cacheDisplay` (41 renders — needs no login or Screen Recording, and reaches states the live app can't), a seeded live walkthrough, and a real-playback session on a Developer ID-signed local build with the real session (real art, VU sway, drag-to-seek, 4Hz, stability, CPU all verified live). **One real defect found and fixed (`ef33039`):** ReelHub's splines were textPrimary-on-textPrimary, so reel rotation was invisible at any speed — exactly the class of bug the campaign existed to catch. A short human-residue checklist for Tim (VoiceOver, focus rings, divider/drag feel, window floor) is the last section of the ledger. +**Previously — the miniplayer visual-verification campaign (2026-08-01) is CLOSED** (HISTORY: the cassette, faceplate and click-wheel miniplayers it covered were deleted in stage e1; the offscreen harness recipe still applies). All 15 checklist items got verdicts (evidence ledger: `docs/superpowers/miniplayer-artifacts/reports/verification-campaign-report.md`; contact sheet beside it; renders in `../renders/`). It was run three ways: an offscreen `swiftc`+`NSHostingView` harness extended to **PNG capture** via `NSWindow.cacheDisplay` (41 renders — needs no login or Screen Recording, and reaches states the live app can't), a seeded live walkthrough, and a real-playback session on a Developer ID-signed local build with the real session (real art, VU sway, drag-to-seek, 4Hz, stability, CPU all verified live). **One real defect found and fixed (`ef33039`):** ReelHub's splines were textPrimary-on-textPrimary, so reel rotation was invisible at any speed — exactly the class of bug the campaign existed to catch. A short human-residue checklist for Tim (VoiceOver, focus rings, divider/drag feel, window floor) is the last section of the ledger. **The iOS parity review's now-playing-bar findings are RESOLVED by the pill work above, on this branch.** Original brief: `docs/superpowers/specs/2026-08-01-ios-parity-review-brief.md` (screenshots in `docs/superpowers/ios-parity-artifacts/`) diagnosed **two real defects + one risk** in the old full-width bar: the toast's hardcoded `.padding(.bottom, 78)` drew it ON the bar; the bar vanished on every pushed detail screen (the `safeAreaInset` wrapped the NavigationStack root, not the stack); and keyboard-up likely triple-stacked chrome (was unverified then — needs taps, and is **still unverified now**, not exercised by this pass either). The pill's `tabViewBottomAccessory` placement fixes the first two structurally (see "This worktree" above) and also answers the review's design question — ~138-144pt of flush bottom chrome against iOS 26's floating tab capsule — by replacing the flush bar with a floating capsule instead of trying to slim the old shape down. The keyboard-up interaction remains the one open item from that review this branch didn't specifically re-check. -**Also parked:** `docs/superpowers/miniplayer-artifacts/` holds the spec, plan, all task reports, the SDD ledger, and the brainstorm mockups (gitignored, local only). Known follow-ups with no owner: shuffle and repeat (which should first collapse the *three* partial transport-control implementations — see the player-backlog memory), a buffering state for the faceplate's knurled play button, and a `!isUITestRun` guard for `PlaybackStateStore` (see the nowplaying-clobber memory — a seeded launch already ate the real resume queue once). +**Also parked:** `docs/superpowers/miniplayer-artifacts/` holds the spec, plan, all task reports, the SDD ledger, and the brainstorm mockups (gitignored, local only). Known follow-ups with no owner: shuffle and repeat (which should first collapse the partial transport-control implementations — see the player-backlog memory). (The faceplate's knurled play button is deleted, and the `PlaybackStateStore` `-UITEST` guard has shipped — see Conventions.) **When verifying UI here:** launching the app is fine, but always quit it afterward, don't grind on stuck GUI automation, and never enter credentials at a Keychain prompt — see the `applenugs-no-gui-automation` memory. Screenshot capture works when the Claude app has Screen Recording; synthetic split-divider drags and AX window-width resizes remain unreliable. **Magnet's invisible overlay swallows synthetic clicks** — quit it for the session (relaunch after). For live runs: build Developer ID-signed (ad-hoc crashes on Sparkle AND prompts for the Keychain), launch the built app by explicit path, and back up `nowplaying.json` first (Tim must copy it — the container is TCC-blocked for the shell; Finder driven via `open -R` + allowlisted copy/paste works too). For pure layout questions the `swiftc` + `NSHostingView` harness is usually the better tool — and it can render PNGs now (recipe in the verification ledger; remember `.themed()` does NOT set the `\.theme` environment key, and use `cacheDisplay`, not `ImageRenderer`). diff --git a/DISTRIBUTION.md b/DISTRIBUTION.md index 58858db..737d088 100644 --- a/DISTRIBUTION.md +++ b/DISTRIBUTION.md @@ -106,7 +106,7 @@ The project is already configured with everything review checks for: | Requirement | Status | | --------------------------------- | ------------------------------------------------- | -| App icon (all sizes) | ✅ `Assets.xcassets/AppIcon.appiconset` | +| App icon (all sizes) | ✅ `AppleNugs/AppIcon.icon` (Icon Composer) | | App Sandbox | ✅ `com.apple.security.app-sandbox` | | Hardened Runtime | ✅ `ENABLE_HARDENED_RUNTIME = YES` | | Privacy manifest | ✅ `PrivacyInfo.xcprivacy` (UserDefaults, no data)| diff --git a/docs/design/yeti-icon/yeti-icon-concepts.html b/docs/design/yeti-icon/yeti-icon-concepts.html new file mode 100644 index 0000000..c4442f9 --- /dev/null +++ b/docs/design/yeti-icon/yeti-icon-concepts.html @@ -0,0 +1,267 @@ + + + + + +Yeti icon concepts + + + + +
+
+

The pack on the peg, as an icon

+

Tim picked the blister pack. Here it is six ways on a 1024 tile, each walked down to the + 16 px Finder row, where most icons quietly turn into a smudge. The material is the theme's: + card stock in navy, a punched hang slot, a clear bubble with a bright rim and one glare, a sale-yellow + sticker in night ink. Pink only ever rides the plastic as a thin glint.

+

No wordmark, no band art, no flake. What sits inside the bubble is the real decision, because at + 32 px the pack itself is just a lighter square and only the contents are left to tell it apart. + Every icon is one hand-drawn SVG symbol, placed here under the iOS mask and inside the macOS tile; + nothing is a bitmap.

+
+ +
+
+
1024, shown at 260
+
+

Mint on card

+

The whole pack, front-on: card stock, a punched hang slot, the bubble, a price sticker on its corner. Inside, a small picture of a show.

+
+
At 16 px
A pale rounded square on navy with a yellow dot at its corner. The picture inside is gone by 32.
+
iOS dark
Already night; a dark variant only drops the card to transparent so the system's dark backing shows.
+
iOS tinted
Reads as a gray frame with a bright corner dot; the moon inside goes muddy.
+
+
+
180
64
32
16

iOS, full bleed under the system mask, at true pixels

+
64
32
16

macOS tile, margin included: at 16 the pack is about 13 px

+
Yeti
Notes
Maps
Light wallpaper
Yeti
Notes
Maps
Dark wallpaper
Yeti
Notes
Maps
Tinted mode, simulated
macOS Dock
+
+
+
+
+
1024, shown at 260
+
+

The bubble

+

Lift the bubble off the card and fill the tile with it. The toy sealed inside is the row sticker, the same yellow disc and stepping bars the app draws on the playing row.

+
+
At 16 px
A yellow disc with dark bars cut into it, in a pale rim. The only one that is still a shape and not a dot.
+
iOS dark
Same art; the night field can go transparent and the bubble and sticker carry it.
+
iOS tinted
The disc becomes the brightest shape and the bars stay cut out, so it keeps its meaning.
+
+
+
180
64
32
16

iOS, full bleed under the system mask, at true pixels

+
64
32
16

macOS tile, margin included: at 16 the pack is about 13 px

+
Yeti
Notes
Maps
Light wallpaper
Yeti
Notes
Maps
Dark wallpaper
Yeti
Notes
Maps
Tinted mode, simulated
macOS Dock
+
+
+
+
+
1024, shown at 260
+
+

Hung askew

+

The pack hanging from a peg on pegboard, six degrees off true, card bleeding off the bottom. The bubble holds the stair in snow.

+
+
At 16 px
A tilted pale panel with white bars. The peg and pegboard vanish below 64; the tilt reads as a mistake at 16.
+
iOS dark
Pegboard is already dark; nothing to change.
+
iOS tinted
The white stair holds up; the sticker turns into a second bright blob competing with it.
+
+
+
180
64
32
16

iOS, full bleed under the system mask, at true pixels

+
64
32
16

macOS tile, margin included: at 16 the pack is about 13 px

+
Yeti
Notes
Maps
Light wallpaper
Yeti
Notes
Maps
Dark wallpaper
Yeti
Notes
Maps
Tinted mode, simulated
macOS Dock
+
+
+
+
+
1024, shown at 260
+
+

Corner, close up

+

Crop tight on the bubble's corner where the price sticker overlaps it. The sticker carries the play mark; one foil streak crosses the plastic.

+
+
At 16 px
A yellow dot in the corner of a gray-blue square; the play mark is gone by 16, and above that it is a play button anyone could own.
+
iOS dark
Unchanged; the card behind the corner is already the darkest value.
+
iOS tinted
Strong: one bright disc, one dark triangle. Loses the material entirely.
+
+
+
180
64
32
16

iOS, full bleed under the system mask, at true pixels

+
64
32
16

macOS tile, margin included: at 16 the pack is about 13 px

+
Yeti
Notes
Maps
Light wallpaper
Yeti
Notes
Maps
Dark wallpaper
Yeti
Notes
Maps
Tinted mode, simulated
macOS Dock
+
+
+
+
+
1024, shown at 260
+
+

Sold out

+

The empty pack: hang slot, flange, plastic, and nothing inside but the snow-light. The foil sweep is the only color.

+
+
At 16 px
A soft lighter square in a navy square. At 32 it could be any app; at 16 it is a blank.
+
iOS dark
Already dark; it would want the glow pushed brighter to survive the system's dimming.
+
iOS tinted
Becomes a gray gradient with no subject. Weakest in this mode.
+
+
+
180
64
32
16

iOS, full bleed under the system mask, at true pixels

+
64
32
16

macOS tile, margin included: at 16 the pack is about 13 px

+
Yeti
Notes
Maps
Light wallpaper
Yeti
Notes
Maps
Dark wallpaper
Yeti
Notes
Maps
Tinted mode, simulated
macOS Dock
+
+
+
+
+
1024, shown at 260
+
+

Footprint

+

One big print pressed into the bubble's light, a small price sticker on the corner. The creature is implied, never drawn.

+
+
At 16 px
The print holds at 32. At 16 it is a white blob beside a yellow dot, though still a distinct one.
+
iOS dark
Unchanged.
+
iOS tinted
Clean: a bright print. The sticker adds a second bright spot.
+
+
+
180
64
32
16

iOS, full bleed under the system mask, at true pixels

+
64
32
16

macOS tile, margin included: at 16 the pack is about 13 px

+
Yeti
Notes
Maps
Light wallpaper
Yeti
Notes
Maps
Dark wallpaper
Yeti
Notes
Maps
Tinted mode, simulated
macOS Dock
+
+
+
+
+
+

My pick: the bubble

+

It is the only concept whose contents are already the app's own mark. The yellow disc with four + stepping bars is the row sticker the app draws on whatever is playing, so the icon and the playing row + say the same thing. It is also the only one that still has a shape at 16 px in the Finder once it gets the + small-size tuning shown at right: a bright disc with bars cut into it, ringed by a pale rim, on navy. Nothing else on the Dock is that combination.

+

The full pack (Mint on card) is the better illustration of the theme, but at 32 px it is a pale square + with a dot, and the hang slot that makes it a pack is the first thing to go. If it has to be the whole + pack, the fix is to put the row sticker inside Mint on card's bubble rather than the picture, which is + most of the way to the bubble anyway.

+

Before it goes into the asset catalog

+
    +
  • Build it in Icon Composer as layers, not one flat bitmap: night and glow as the background, + the sticker as the middle, the bubble's rim and film as the top. iOS 26 and macOS 26 add their own + Liquid Glass highlight to the top layer, so the painted hot spot and glare streaks should come out + or the plastic gets lit twice.
  • +
  • Hand-tune 16 and 32 px for the Mac: drop the flange and the streaks, thicken the rim, and grow + the sticker to fill more of the bubble. The 16 px Finder row is really about 13 px of artwork.
  • +
  • Dark is nearly free: the art is already night, so the dark variant just makes the background + transparent. Tinted keeps only luminance, which leaves the disc as the brightest shape with the + bars cut out of it. That is the right thing to survive.
  • +
  • macOS 26 forces the squircle on Mac icons too, so nothing here can hang out of the tile, on + either platform; the askew pack only works inside it.
  • +
+
+
+

Finder sizes, as drawn and hand-tuned

+
+
32
16
+
32 tuned
16 tuned
+
+

Tuned: no flange or glare, a heavier rim, the sticker grown to fill the bubble, wider bars.

+

iOS appearances

+
+
Default
+
Dark
+
Tinted
+
+

Dark leaves the background to the system; tinted is simulated from luminance.

+
+
+
+ + diff --git a/docs/design/yeti-icon/yeti-icon-concepts.png b/docs/design/yeti-icon/yeti-icon-concepts.png new file mode 100644 index 0000000..65bb294 Binary files /dev/null and b/docs/design/yeti-icon/yeti-icon-concepts.png differ diff --git a/docs/design/yeti-icon/yeti-icon-dark.svg b/docs/design/yeti-icon/yeti-icon-dark.svg new file mode 100644 index 0000000..c186978 --- /dev/null +++ b/docs/design/yeti-icon/yeti-icon-dark.svg @@ -0,0 +1 @@ + diff --git a/docs/design/yeti-icon/yeti-icon-master.svg b/docs/design/yeti-icon/yeti-icon-master.svg new file mode 100644 index 0000000..bdba133 --- /dev/null +++ b/docs/design/yeti-icon/yeti-icon-master.svg @@ -0,0 +1 @@ + diff --git a/docs/design/yeti-icon/yeti-icon-small.svg b/docs/design/yeti-icon/yeti-icon-small.svg new file mode 100644 index 0000000..5293081 --- /dev/null +++ b/docs/design/yeti-icon/yeti-icon-small.svg @@ -0,0 +1 @@ + diff --git a/docs/design/yeti-icon/yeti-icon-tinted.svg b/docs/design/yeti-icon/yeti-icon-tinted.svg new file mode 100644 index 0000000..6e113b1 --- /dev/null +++ b/docs/design/yeti-icon/yeti-icon-tinted.svg @@ -0,0 +1 @@ + diff --git a/docs/design/yeti-theme/yeti-decisions.html b/docs/design/yeti-theme/yeti-decisions.html new file mode 100644 index 0000000..4126209 --- /dev/null +++ b/docs/design/yeti-theme/yeti-decisions.html @@ -0,0 +1,1132 @@ + + + + + +Yeti, your calls + + + + + + + + + + +
+
+

Yeti, your calls

+

Everything the design pass left for you, one at a time. Click the option you want under each item; the bar at the bottom keeps count and writes your answers out to paste back.

+

Four decisions change what gets built. Five proposals tidy what the first sheet drew against what the app really does. Then the new copy lines, for the lyrics check. A yellow suggested tag is my pick; ignore it freely.

+
+ + + + +
+
+ 1 +

What hangs on the peg on iPhone Home?

+

The first sheet put the blister pack at the top of iPhone Home, but labeled it “Newest show from artists you follow”, a feature the app doesn’t have. The card that does exist, Continue listening, is Mac-only today; iPhone Home starts at the greeting.

+
+
+ + + + + +
+
+ + +
+
+ 2 +

Show counts on the Artists list?

+

The first sheet printed “418 shows” beside each artist. The catalog list only gives a name and an id, so a count means asking nugs once per artist, and there are well over a thousand.

+
+
+ + +
+
+ + +
+
+ 3 +

What does the line above the card say?

+

The first sheet wrote “Set 2, track 3 of 6”. The app writes “TRACK 3 OF 24”, counting the whole queue, because queued tracks don’t carry their set. The show page knows the sets, so carrying them into the queue is possible: real work, but small.

+
+
+ + +
+
+ + +
+
+ 4 +

Does the Mac Home card get the bubble?

+

The first sheet kept the Mac’s only bubble for the dashboard miniplayer, so the Mac Home card is drawn flat. With the dashboard open, a bubbled card would put two bubbles of the same cover on screen at once.

+
+
+ + +
+
+ + +
+
P1

One phrase: “Sign out”

+

Today the connection screen says Sign Out and the account menu says Log Out, while sign-in says Sign in with nugs.net.

+
+ + +
+
+ +
+
P2

Clearing the queue always asks first

+

Clearing empties the whole queue and stops playback. Only one of the three ways to do it asks first.

+
+ + +
+
+ +
+
P3

The tab bar and sidebar stay the app’s

+

The first sheet drew different tabs and a Mac sidebar with a “Following” list. Following it would drop Videos from iPhone.

+
+ + +
+
+ +
+
P4

The Signal section keeps its six rows

+

The first sheet showed five rows. The app’s six are fixed on purpose: when rows came and went with each track, the whole queue below jumped.

+
+ + +
+
+ +
+
P5

The window buttons stay the system’s

+

The first sheet drew the red, yellow and green window buttons in frost gray. macOS doesn’t let an app recolor them; it would mean drawing fake ones.

+
+ + +
+
+ + +
+
✓

New copy, for the lyrics check

+

You cleared the first sheet’s five lines. These are new. Mark any that are, or come too close to, a line from the song.

+
    +
  • Your downloads still play while you’re offline.Couldn’t reach nugs.net
  • +
  • Nothing boxed up yetDownloads, empty
  • +
  • Nothing on the shelf for “…”Search, no results
  • +
  • Pick up where you left offVideos section (was Continue Watching)
  • +
  • Live and coming upVideos section (was Live & Upcoming)
  • +
  • Just streamedVideos section (was Recent Exclusives)
  • +
  • At the live edgeVideo, Go Live when there’s nothing to catch up
  • +
  • Offline play needs nugs.net to confirm your subscription at least once every 30 days.Offline-expiry explanation
  • +
  • Playback stops and the whole queue empties.Clear-queue confirmation
  • +
  • Pick up where you left off, or dig through the crate.Home lede (today: with a dash)
  • +
+
+ +
+ 0 of 10 answered + + +
+
+ + + + + diff --git a/docs/design/yeti-theme/yeti-pill-shelf.html b/docs/design/yeti-theme/yeti-pill-shelf.html new file mode 100644 index 0000000..55ce92b --- /dev/null +++ b/docs/design/yeti-theme/yeti-pill-shelf.html @@ -0,0 +1,1003 @@ + + + + + +The pill’s shelf + + + + + + + + + + +
+
+

The pill’s shelf

+

On the Duo’s outer display the tab bar stands up the side, so the pill is ours, not the system’s, and today it floats over whatever is scrolling: Favorites, the nice list, the crate. Here it gets its own shelf. Content stops at the shelf’s lip instead of running under the glass.

+

When you scroll down the pill tips back into the shelf through its hang slot, the shelf folds shut to a thin lip, and the page eases down into the room it leaves. Only the punched slot stays, like an empty peg. Scroll up, stop for a second, or tap the slot, and the shelf opens and the pill pops back up. Scroll the right-hand phone, or use the buttons.

+
+ +
+
+
+
+
+
+

Good evening

+

Pick up where you left off, or dig through the crate.

+
+
+
Lossless16/44
+

The Way It Is

Goose · The Midland Theatre

+
+
+

Favorites

See all
+
GooseBilly StringsPhishGreensky BluegrassDogs in a Pile
+

The nice list

+

112hours listened9artists

+
  1. Goose41 plays62 h
  2. Billy Strings17 plays21 h
  3. Phish9 plays14 h
  4. Pigeons Playing Ping Pong6 plays7 h
  5. Dogs in a Pile4 plays5 h
+

From the crate

+
+DCDead & CompanyDBDisco Biscuits +KGKing GizzardGMGov't Mule +DPDogs in a PilePPPigeons +TPTwiddleSPSpafford +MSmoe.UMUmphrey's McGee +
+
+
The Way It IsGoose
+
+
+ 7:33 +
+
+
Today. The pill floats 10pt into the bottom edge with content running underneath it; the only room it gets is a margin at the very end of the scroll. At the top of Home it sits on the Favorites chips, as in your screenshot.
+
+ +
+
+
+
+
+

Good evening

+

Pick up where you left off, or dig through the crate.

+
+
+
Lossless16/44
+

The Way It Is

Goose · The Midland Theatre

+
+
+

Favorites

See all
+
GooseBilly StringsPhishGreensky BluegrassDogs in a Pile
+

The nice list

+

112hours listened9artists

+
  1. Goose41 plays62 h
  2. Billy Strings17 plays21 h
  3. Phish9 plays14 h
  4. Pigeons Playing Ping Pong6 plays7 h
  5. Dogs in a Pile4 plays5 h
+

From the crate

+
+DCDead & CompanyDBDisco Biscuits +KGKing GizzardGMGov't Mule +DPDogs in a PilePPPigeons +TPTwiddleSPSpafford +MSmoe.UMUmphrey's McGee +
+
+
+
The Way It IsGoose
+
+
+ 7:33 +
+
+
+ + + +
+

Pill on its shelf.

+
Proposed. The shelf is an opaque night band with a frost lip, 76pt open (the 48pt pill plus breathing room above the home indicator), 16pt closed. The page is clipped at the lip, so nothing busy is ever behind the glass.
+
+ +
+

The motion

+

Two beats each way, overlapped so it reads as one gesture. Nothing here reads playback time, and nothing moves while the pill is still.

+

Tucking away (scroll down)

+
Pill tips in
+
Shelf folds
+
Slot appears
+
0150300450600 ms
+

Coming back (scroll up, 1s still, or tap the slot)

+
Shelf opens
+
Pill pops up
+
0150300450600 ms
+
    +
  • Tuck. The pill tips forward through the slot (down 70pt, squashed to 80% tall, fading late) in 240ms on an ease-in, the way a toy drops back into its box. The shelf starts folding 70ms later and takes 300ms.
  • +
  • Pop. The shelf opens first (260ms, ease-out); the pill rises out of the slot with one small overshoot (5pt high, 2% wide) over 440ms and settles. One bounce, never two.
  • +
  • Content follows the lip. The page’s visible area grows and shrinks with the shelf. The page itself doesn’t move: its top stays put and more of it shows at the bottom.
  • +
  • The slot is a button. It brings the pill back, with VoiceOver label “Show the player”.
  • +
  • Reduce Motion: no tip, no squash, no tween. The shelf snaps open or shut and the pill fades.
  • +
  • VoiceOver: never tucks, as today.
  • +
  • Only where the pill is ours: the Duo with its tab bar standing up. Everywhere else the system’s tab-bar accessory keeps doing its own thing.
  • +
+
+
+
+ + + + + diff --git a/docs/design/yeti-theme/yeti-rail.html b/docs/design/yeti-theme/yeti-rail.html new file mode 100644 index 0000000..cd8021d --- /dev/null +++ b/docs/design/yeti-theme/yeti-rail.html @@ -0,0 +1,1064 @@ + + + + + +The rail and the shelf + + + + + + + + + + +
+
+

The rail and the shelf

+

The shelf gave the pill a home, and in doing so drew a line: the lip. On the folded Duo the system’s vertical tab bar stands right across that line, so the one border we added is the one thing something else crosses. Two ways out, both live below. Scroll each phone to watch them with the shelf tucking and coming back.

+

A keeps Apple’s rail exactly as it is and pulls the shelf back to stop short of it: two separate objects with air between them. B replaces the rail with our own, a post of the same card stock that stands on the shelf, so the lip runs up and around it instead of being crossed. Both keep the same five tabs.

+
+ +
+
+
+
+
+
+

Good evening

+

Pick up where you left off, or dig through the crate.

+
+
+
Lossless16/44
+

The Way It Is

Goose · The Midland Theatre

+
+
+

Favorites

See all
+
GooseBilly StringsPhishGreensky BluegrassDogs in a Pile
+

The nice list

+

112hours listened9artists

+
  1. Goose41 plays62 h
  2. Billy Strings17 plays21 h
  3. Phish9 plays14 h
  4. Pigeons Playing Ping Pong6 plays7 h
  5. Dogs in a Pile4 plays5 h
+

From the crate

+
+DCDead & CompanyDBDisco Biscuits +KGKing GizzardGMGov't Mule +DPDogs in a PilePPPigeons +TPTwiddleSPSpafford +MSmoe.UMUmphrey's McGee +
+
+
+
The Way It IsGoose
+
+
+ + 9:06 +
+
+
TodayAs on your phone: the shelf runs under the rail, so its lip cuts across the rail’s lower icons.
+
+ +
+
+
+
+
+

Good evening

+

Pick up where you left off, or dig through the crate.

+
+
+
Lossless16/44
+

The Way It Is

Goose · The Midland Theatre

+
+
+

Favorites

See all
+
GooseBilly StringsPhishGreensky BluegrassDogs in a Pile
+

The nice list

+

112hours listened9artists

+
  1. Goose41 plays62 h
  2. Billy Strings17 plays21 h
  3. Phish9 plays14 h
  4. Pigeons Playing Ping Pong6 plays7 h
  5. Dogs in a Pile4 plays5 h
+

From the crate

+
+DCDead & CompanyDBDisco Biscuits +KGKing GizzardGMGov't Mule +DPDogs in a PilePPPigeons +TPTwiddleSPSpafford +MSmoe.UMUmphrey's McGee +
+
+
+
The Way It IsGoose
+
+
+ + 9:06 +
+
+
AKeep Apple’s rail; the shelf steps aside. The shelf spans only the content column and rounds off at its trailing corner, so the lip ends before the rail and nothing crosses it. The rail keeps every system behavior and its gray glass. Small: a few lines. The rail still doesn’t look like Yeti.
+
+ +
+
+
+
+
+

Good evening

+

Pick up where you left off, or dig through the crate.

+
+
+
Lossless16/44
+

The Way It Is

Goose · The Midland Theatre

+
+
+

Favorites

See all
+
GooseBilly StringsPhishGreensky BluegrassDogs in a Pile
+

The nice list

+

112hours listened9artists

+
  1. Goose41 plays62 h
  2. Billy Strings17 plays21 h
  3. Phish9 plays14 h
  4. Pigeons Playing Ping Pong6 plays7 h
  5. Dogs in a Pile4 plays5 h
+

From the crate

+
+DCDead & CompanyDBDisco Biscuits +KGKing GizzardGMGov't Mule +DPDogs in a PilePPPigeons +TPTwiddleSPSpafford +MSmoe.UMUmphrey's McGee +
+
+
+
The Way It IsGoose
+
+
+ + 9:06 +
+
+
BOur own rail: a post on the shelf. Same card stock and frost lip as the shelf, joined to it with a curved fillet, with a punched hole at the top like the pack’s hang slot. The selected tab sits on a raised shelf chip in snow; the rest are slush. When the shelf tucks, the post steps down with it. One fixture, no crossings, all Yeti. Bigger: we take over the tab bar where it goes vertical, and own its accessibility, keyboard and VoiceOver.
+
+
+ +
+
+

Trade-offs

+
    +
  • A is safe and quick. Apple’s rail keeps working exactly as the system intends (its own glass, its own selection, VoiceOver, the iPadOS-style behaviors iOS 27.1 gives it). The cost: the rail stays gray glass with white icons, the only system-styled object on that screen.
  • +
  • B is the Yeti answer. Rail, shelf and pill read as one piece of shop furniture. The cost: where the tab bar is vertical we hide Apple’s and draw ours, so we own tab selection, VoiceOver traits (“tab, 1 of 5, selected”), keyboard focus, and the hit targets. The regular iPhone and the Duo unfolded keep Apple’s horizontal bar either way.
  • +
  • Either way the shelf, its motion and the pill don’t change.
  • +
+
+
+
+ + + + + diff --git a/docs/design/yeti-theme/yeti-surfaces.html b/docs/design/yeti-theme/yeti-surfaces.html new file mode 100644 index 0000000..735304f --- /dev/null +++ b/docs/design/yeti-theme/yeti-surfaces.html @@ -0,0 +1,1406 @@ + + + + + +Yeti, the rest of the shop + + + + + + + + + + + +
+ +
+

Yeti, the rest of the shop

+

The first sheet drew the window display: Home, now playing, the pill and the Mac dashboard. This one walks the other surfaces, the ones a user meets daily, and holds it to the same stock: night shelves, snow light, sale stickers, one foil pink for anything that moves through time.

+

Two ideas carry most of it. The empty pack is the backing card with nothing in the bubble yet, and it becomes sign-in and every empty state. The label sticker is the price sticker cut square, for the offers a poster makes (Live, Free). Everything else is the first sheet's rules applied without new material. The tabs, copy and states here are the app's real ones; where the first sheet drew something the app doesn't do, it's listed at the bottom for a ruling.

+
+ + +
+ + +
+
+
+
+
9:41
+
+
+
+

Yeti

+
+
+

An unofficial player for nugs.net

+
+ +
+

You sign in on nugs.net’s own page. Yeti never sees your password.

+

Unofficial. Not affiliated with nugs.net or Apple.

+
+
+
+
+
Sign-in. The empty pack with the nameplate printed on its card: the app before the show arrives. One snow primary. While the browser sheet is up the button reads Opening nugs.net… with a night-ink spinner; a failure prints in snow under the line, never red. The Mac is the same column centered in the window.
+
+ + +
+
+
+
+
9:41
+
+
+
+
+
+

Couldn’t reach nugs.net

+

Your downloads still play while you’re offline.

+

The Internet connection appears to be offline.

+
+ + + +
+
+
+
+
+
Couldn’t reach nugs.net. Same pack, smaller, still empty. The raw error drops to a quiet monospaced line under a sentence a person can act on. Listen offline and the line about downloads only appear when there are downloads that can still play offline (today the button keys on downloads existing). Sign out replaces this screen’s Sign Out and the account menu’s Log Out, so the app uses one phrase.
+
+ + +
+
+
+
+
9:41
+
+

Search

+
yeti
+
Artists
+
    +
  • GGoose
  • +
+
Shows
+
    +
  • Westville Music BowlJun 15, 2024 · New Haven, CT
  • +
  • The Capitol TheatreDec 30, 2023 · Port Chester, NY
  • +
+
Songs
+
    +
  • YetiGoose · Jun 15, 2024
  • +
  • YetiGoose · Dec 30, 2023
  • +
+
+
Added to queue
+
YetiGoose
+ +
+
+
Search. One field, a foil caret (focus is foil). Sections in the barker’s sentence case, rows as plain shelf lines: no art here, as today. A song’s play mark is an outlined disc, not a sticker, because nothing is playing yet. The toast is pill glass with a snow check, sitting 8pt above the pill.
+
+
+ + +
+ + +
+
+
+
+
9:41
+ +
+
+
+
+

Westville Music Bowl

+

Goose

+

June 15, 2024 · New Haven, CT

+

2:41:10 · 24 tracks

+
+
+
+ + + + + + +
+
Show notes
+
Set 2
+
    +
  1. 1Drive18:04
  2. +
  3. 2Tumble11:40
  4. +
  5. Yeti22:41
  6. +
  7. 4Dripfield10:15
  8. +
  9. 5Empress of Organos13:33
  10. +
+
+
YetiGoose
+ +
+
+
Show page. Cover flat on the shelf, no bubble: the pack is only for what’s playing. Play all is the one snow primary; the rest are frost rings. Downloaded reads as a filled snow disc (no pink fill). The playing row carries the 20pt row sticker and a faint sale wash, which is the Mac setlist rule brought to iPhone.
+
+ + +
+
+
+
+
9:41
+ +
+

Goose

+
+ 340 shows · 12 videos · 5 albums + +
+
ShowsVideosAlbums
+
Venue, city or date
+
June 2024
+
    +
  • 1Westville Music BowlNew Haven, CTJun 15
  • +
  • 2Mann CenterLivePhiladelphia, PAJun 14
  • +
  • 3Merriweather Post Pavilion4KColumbia, MDJun 12
  • +
+
May 2024
+
    +
  • 4Red Rocks AmphitheatreMorrison, COMay 31
  • +
+
+ +
+
+
Artist page. The LCD counter pill and the bouncing bars go; the name is the large title and the counts sit under it as plain type. Following is a frost ring with a check. Month headers in the barker’s heavy slush. Live is a label sticker (an offer); 4K is a plain tag (a fact).
+
+ + +
+
+
+
+
9:41
+
+

Downloads

+
FavoritesDownloads
+
    +
  • GOWestville Music BowlGoose · 24 tracks · 1.9 GB
  • +
  • BSThe AnthemBilly Strings · 22 tracks · 1.2 GB
  • +
  • PHMadison Square GardenPhish · 19 tracks · 1.6 GB
  • +
+

3 shows · 4.7 GB

+ +

Empty

+
+
+

Nothing boxed up yet

+

Download a show from its page to keep it for offline listening.

+
+
+ +
+
+
Library. The segmented control is shelf with a frost thumb. Download rows keep their initials tiles (downloads carry no art) printed on card stock; the trailing play is a snow glyph, not a pink one. Below, the empty state on the empty pack. Swipe-to-remove stays system red: destructive actions are the one place red is allowed.
+
+
+ + +
+ + +
+
+
+
+
9:41
+
+

Videos

+

Pick up where you left off

+
+
4K
Westville Music BowlGoose
+
The AnthemBilly Strings
+
+

Live and coming up

+
+
Live
Mann Center, night twoGoose · now
+
Free
Audio
Benefit streamBilly Strings · Sat 8 PM
+
+

Just streamed

+

Replays and free links.

+
+
PPV
New Year’s RunPhish
+
+
+
YetiGoose
+ +
+
+
Videos. Posters sit flat with a frost keyline. The centered accent play glyph goes: the poster is the button, and a pink disc on every poster would be a pink fill. Resume progress is foil, because it moves through time. The section names are proposals in the barker’s voice (see the copy table).
+
+ + +
+
+
+
+
9:41
+ +
Live
AVKit controls
+
+

Mann Center, night two

+

Goose

+

June 14, 2024 · Philadelphia, PA

+
+ + + + +
+

Proceeds from tonight’s stream go to the band’s local food bank partner.

+
About
+
Chapters
+
    +
  • Set 10:00
  • +
  • Set 21:31:07
  • +
+
+ +
+
+
Video, live. The player is AVKit’s, edge to edge; we only own the Live sticker over it (today an accent-filled badge). Go Live reads At the live edge when it has nothing to do. Benefit links are snow with a dotted underline, never pink. The current chapter takes the playing-row sale yellow.
+
+ + +
+
+
+
+
9:41
+ +
+ + Pay-per-view + Not part of your subscription +
+
+ +

New Year’s Run

+

Phish

+

December 31, 2025 · New York, NY

+ +

Can’t play

+
+ + Can’t play this video + This video isn’t included in your plan. +
+
+ +
+
+
Pay-per-view, and a failed stream. The locked panel is card stock with a slush lock. The buy button is snow with an outbound arrow, since it leaves the app. Below it, the player’s error overlay: snow title, slush reason, no red triangle. Errors in Yeti are said plainly, not colored.
+
+
+ + +
+ + +
+
+
+
+
9:41
+
+

Artists

+
G
+
  • Goose
  • Grateful Dead
  • Greensky Bluegrass
+
+
+
+
+
+ + YetiGoose · Westville Music Bowl + +
+
+
+
9:12-13:29
+
+
+ + + + + +
+
+ + Lossless24/96 + +
+
+
+
+
The panel (from the pill’s chevron, 250pt detent). Glass, like the pill it grew from. No bubble: the art is a chip, because this is the pill made bigger, not the window display. It takes the play sticker and the price sticker; Queue becomes a frost ring, since it was a pink-tinted button. Measured to fit the fixed 250pt detent: the play sticker drops to 46pt and the price sticker to 32pt here.
+
+ + +
+
+
+
+
9:41
+
+
+
+
+

Here it comes

+

Yeti

+

Goose · Westville Music Bowl

+

9:12 / 22:41

+

Says so on the box

Lossless24/96
+
+
Format
FLAC
+
Platform tier
ALAC 16
+
Sample rate
96 kHz
+
Bit depth
24-bit
+
Channels
Stereo
+
Buffered
4:10 ahead
+
+

Coming up next

+
    +
  1. Yeti22:41
  2. +
  3. 4Dripfield10:15
  4. +
  5. 5Empress of Organos13:33
  6. +
+
+
+
+
+
Dashboard as a sheet. The inspector on the shelf, with the same three barker headers. On iOS the first section stays a text block (no miniplayer). The Signal section keeps its constant six rows, in the app’s order and with its labels: the first sheet’s five-row version would break the constant-shape rule. Unknown values still dim to “—”.
+
+ + +
+
+
+
+
9:41
+
+

Small print

+
Toasts
+
+
Playing next
+
Added to queue
+
That show isn’t on nugs
+
+
Playback error, in the dashboard
+
+ + Go online to keep listening offlineOffline play needs nugs.net to confirm your subscription at least once every 30 days. +
+
Confirmation (system drawn)
+
+ Reset listening stats? +

This erases your artist totals permanently. There’s no history to restore them from.

+
CancelReset Stats
+
+
+ +
+
+
Small print. Toasts keep their six messages; a failure toast swaps the check for a slush warning mark. The offline-expiry error gets a second line that says why, which today it never does. Dialogs and alerts stay the system’s; dark mode makes them match, and destructive buttons stay red.
+
+
+ + +
+
+
+
+
+
+ + Track 3 of 24 + +
+
+
+
Lossless24/96
+
+

Yeti

+

Goose · Westville Music Bowl

+
+
+
+
+
9:12-13:29
+
+
+ + + + + +
+
+ + + +
+
+
+
+

Says so on the box

+
+
Format
FLAC
Platform tier
ALAC 16
Sample rate
96 kHz
+
Bit depth
24-bit
Channels
Stereo
Buffered
4:10 ahead
+
+

Coming up next

+
    +
  1. Yeti22:41
  2. +
  3. 4Dripfield10:15
  4. +
  5. 5Empress of Organos13:33
  6. +
  7. 6Arcadia9:47
  8. +
  9. 7Madhuvan16:52
  10. +
+
+
+
+
+
Duo, unfolded flat (951 × 669pt). The bubble yields first: it gives up width before the nameplate shrinks, since art must never be a rigid tile. Here it sits at 196pt and holds the price sticker on its corner. The queue column is the dashboard without its first section. The queue button’s slot stays empty, so the row doesn’t shift when the column goes away on fold. The sweep fires on the card’s title here as everywhere else.
+
+ +
+
+
+
+
9:41
+
+

Artists

+ +
G
+
  • Goose
  • Grateful Dead
  • Greensky Bluegrass
  • Gov't Mule
+
K
+
  • King Gizzard & the Lizard Wizard
+
+ +
YetiGoose
+ ducks 130pt on scroll down +
+
+
Duo, folded. The vertical tab bar is the system’s. The pill is our glass overlay (the duck) with the foil ring, shedding skips by width and keeping prev, play, next and the chevron. Nothing about the duck changes: it squashes on the way down and springs back. Reduce Motion fades it.
+
+
+ + +
+
+
+
+
+ + + + +
+ +
+

Good evening

+

Pick up where you left off, or dig through the crate.

+
+ + +

Yeti

+

Goose · Westville Music Bowl

+
+
+ +
+
+
+

The nice list

+

112hours listened9artists

+
    +
  1. Goose41 plays62 h
  2. +
  3. Billy Strings17 plays21 h
  4. +
  5. Phish9 plays14 h
  6. +
+
+
+

Favorites

See all
+
GooseBilly StringsPhishGreensky Bluegrass
+

From the crate

+
+ DCDead & CoDBDisco BiscuitsKGKing Gizzard + GMGov't MuleDPDogs in a PilePPPigeons +
+
+
+
+
+
+ + YetiGoose · Westville Music Bowl3 of 24 +
+
+
+ + + + + +
+
9:12
-13:29
+
+
+ + + + + Lossless24/96 +
+
+
Playing next
+
+
+
Mac Home, with the transport bar the first sheet left out. The bar is the well with a frost top rule: a flat art chip, the counter in lamp-off (3 of 24), a 34pt play sticker, foil scrub, and a slush volume track (volume isn’t time, so it isn’t foil). The price sticker replaces the format badge at the end. Home loses its eyebrows (YETI, CONTINUE LISTENING, LISTENING, FROM THE CRATE). The resume hero is card stock with a flat cover: the first sheet keeps the bubble for the miniplayer alone on the Mac. Sidebar icons are a proposal; today it’s text only. The bar spans the whole window, sidebar and inspector included, as it does today.
+
+
+ +
+
+
+
Now Playing
+
Track 3 of 24
+
+
+
Lossless24/96
+

Yeti

Goose · Westville Music Bowl

+
+
9:12-13:29
+
+ + + + + +
+
+
+
+
Mac Now Playing window (400 × 680). The iPhone card, unchanged, so it’s a second bubble on the Mac. That’s on purpose: this window is the now-playing screen, not the dashboard. The sweep fires here too. The chevron goes, since a window closes itself; the price sticker moves to the bubble’s corner, so the bottom row loses its format badge.
+
+ +
+
+ +
+ Clear the queue? +

Playback stops and the whole queue empties.

+
CancelClear Queue
+
+
+
Menus and dialogs are drawn by the system, so all we own is their words and order. The Theme picker leaves both the account menu and the View menu at stage (d). Log Out becomes Sign Out. The dashboard’s Clear and the Playback menu’s Clear Queue should ask the same question the ⋯ menu already asks; today two of the three clear without asking.
+
+
+ + +
+
+

How the system stretches

+

The first sheet’s rules hold everywhere. These are the only additions, and where each one stops.

+
    +
  • The pack goes where something is playing. Home hero, iPhone now playing (including the Duo split), the Mac Now Playing window, the Mac miniplayer. Never on a list, a search result, a download, a poster, the panel or the pill.
  • +
  • The empty pack goes where nothing is yet. Sign-in (with the nameplate), Couldn’t reach nugs.net, and the empty states: downloads, favorites, videos, search before you type. An empty bubble is never animated and never holds a glyph.
  • +
  • A fourth sticker: the label sticker. Same sale stock, night ink, 4° off true, cut square. Only for offers a poster or row makes: Live and Free. Facts (4K, Audio, PPV) are plain frost-outlined tags, never stock.
  • +
  • Foil is time. Scrub, ring, sweep, focus, and now video resume progress, download progress and the search caret. Volume, toggles and selection aren’t time and stay snow or slush. Still never a fill: today’s pink-filled LIVE/FREE badges, poster play discs, the panel’s Queue button and the downloads play glyphs all change.
  • +
  • One primary per screen, snow with night ink: Sign in, Try again, Play all, Pause, Buy on nugs.net. Everything else is a frost ring or bare type.
  • +
  • Errors are said, not colored. Snow sentence, slush reason, raw error last in quiet monospace. Red is kept for destructive buttons, which are system-drawn anyway.
  • +
  • No eyebrows, no caps. Section names are the barker’s, heavy rounded, sentence case. Every uppercased eyebrow and the Shoebox-era caps go.
  • +
  • Casing. Buttons, toasts, headers and empty states are sentence case (Sign out, Try again). Menu items and system dialog buttons follow the platform’s Title Case (Sign Out, Reset Stats). Same words, two casings, one rule.
  • +
  • Not drawn here, and how they go. The Library’s Favorites segment: artist chips are shelf with a frost keyline (as on Mac Home), ShowCard is a flat square cover with a frost keyline, title heavy 15 and artist slush (the poster rule, cut square), and saved videos reuse the poster. The Mac’s Artists, Favorites, Downloads and Search are the iPhone rows in the detail column. The Mac sign-in and connection screens are the iPhone column centered in the window; the Mac offline sheet is Downloads with the transport bar docked. The Click Wheel and Receiver players aren’t restyled; they’re deleted at stage (e).
  • +
  • System surfaces we leave alone: AVKit’s player controls, the tab bar and accessory glass, menus, context menus, confirmation dialogs, alerts, Sparkle’s update window, share sheets and the window’s traffic lights. Forced dark mode makes them sit right; we own their words.
  • +
+
+ +
+

Copy

+

Today’s line, then Yeti’s. Every new line is plain speech, not lyrics. Tim checks each one before it ships, as with the first sheet’s.

+ + + + + + + + + + + + + + + + + + +
WhereTodayYeti
ConnectionCouldn't reach nugs.net + raw errorCouldn’t reach nugs.net+ “Your downloads still play while you’re offline.” (only with playable downloads). Raw error moves under it.
ConnectionRetry · Sign OutTry again · Sign out
Account menuLog OutSign Outone phrase app-wide, matching “Sign in with nugs.net”; Title Case in menus, sentence case on buttons
Home greetingYETI · Good evening.Good eveningno eyebrow, no period; “Late set.” stays for late night
Home heroCONTINUE LISTENING(none)the card is the label
Home ledgerLISTENINGThe nice list
Home crateFROM THE CRATEFrom the crate
Downloads emptyNothing downloadedNothing boxed up yet
Search, no resultsNo Results for “q” (system)Nothing on the shelf for “q”
Videos sectionsContinue Watching · Live & Upcoming · Recent ExclusivesPick up where you left off · Live and coming up · Just streamed
Video, live edgeGo Live (disabled)At the live edgewhen disabled; “Go live” otherwise
Offline expiryGo online to keep listening offlinesame + “Offline play needs nugs.net to confirm your subscription at least once every 30 days.”
Clear queueClear the queue?+ “Playback stops and the whole queue empties.”what clear() does today; and asked from all three clear paths
Now-playing counterTRACK 3 OF 24Track 3 of 24
+
+
+ +
+
+

Reconciling the first sheet with the app

+

Walking the real screens turned up places where yeti-theme.html drew something the app doesn’t do. Each needs a ruling before the spec: recommend is my pick, question is yours to decide, keep means the first sheet yields to the app.

+
+ + + + + + + +
iPhone tabs keepThe sheet shows Home, Artists, Favorites, Downloads, Search. The app has Home, Artists, Search, Library (Favorites + Downloads), Videos. Following the sheet would drop Videos. This page uses the app’s tabs.
iPhone Home hero questionThe sheet puts the pack on iPhone Home, but the resume card is Mac-only today: iPhone Home is greeting, favorites, ledger, crate. Its label in the sheet reads “Newest show from artists you follow”, which is a feature the app doesn’t have. Is it (a) the resume card, brought to iPhone, or (b) a new “newest from who you follow” card?
Artist counts questionThe sheet’s Artists list shows “418 shows” per row. The catalog list gives a name and an id only, so counts would mean one request per artist. Drop the counts, or find a cheap source first?
Mac sidebar keepThe sheet shows Home, Artists, Favorites, Search plus a “Following” list of artists. The app has Home, Artists, Videos, Favorites, Downloads, Search. Keep the app’s items; a Following group would be a new feature and can wait.
+ + + + + + + +
Now-playing counter questionThe sheet says “Set 2, track 3 of 6”; the app says “Track 3 of 24” and the queue doesn’t know about sets. Set-aware means carrying set data into the queue. Worth it, or keep “Track 3 of 24”?
Signal rows keepThe sheet shows five rows, with “Source: Soundboard”. The app’s six constant rows (Format, Platform tier or Source, Sample rate, Bit depth, Channels, Buffered) are a layout invariant. Restyle only.
Frost-gray traffic lights recommendReal window buttons can’t be recolored without replacing them. Drop it; dark mode is enough.
Mac transport bar recommendMissing from the sheet’s Mac; drawn here. It also takes the play sticker, which makes four play-sticker sites, not three.
+
+
+
+ +
+ + + diff --git a/docs/design/yeti-theme/yeti-surfaces.png b/docs/design/yeti-theme/yeti-surfaces.png new file mode 100644 index 0000000..1f0340c Binary files /dev/null and b/docs/design/yeti-theme/yeti-surfaces.png differ diff --git a/project.yml b/project.yml index 27c9960..acf068d 100644 --- a/project.yml +++ b/project.yml @@ -129,9 +129,11 @@ targets: # iOS app (iPhone-first; personal install via Xcode — see DISTRIBUTION.md). # Shares all sources except AppleNugs/macOS. No Sparkle (macOS-only); no # entitlements (plain Keychain needs none, and there is no iOS sandbox opt-in). - # App icon: a dedicated full-bleed 1024 universal entry (icon_ios_1024.png), - # generated by scripts/generate-ios-icon.swift — the same waveform mark as - # the Mac icon, redrawn opaque and edge-to-edge for the iOS mask. + # App icon: AppleNugs/AppIcon.icon, an Icon Composer document picked up by + # both targets through the AppleNugs glob (regenerate it with + # scripts/render-app-icon.py). XcodeGen 2.45.4 types it `wrapper.icon`, not + # Xcode's `folder.iconcomposer.icon`; Xcode still hands it to actool, which + # also derives the pre-26 flat icons (AppIcon.icns, AppIcon60x60@2x.png). AppleNugs-iOS: type: application platform: iOS @@ -175,7 +177,7 @@ targets: settings: base: PRODUCT_BUNDLE_IDENTIFIER: com.timvbs.applenugs.ios - # Single-size 1024 full-bleed universal iOS icon (see AppIcon set). + # AppleNugs/AppIcon.icon (see the comment above this target). ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME: AccentColor # iPhone only for v1; iPad runs the iPhone layout in compatibility mode. @@ -228,18 +230,11 @@ targets: - path: AppleNugs/Views/ArtistLibrary/CrateSection.swift # Same deal: pure Foundation (no SwiftUI, no Route/NugsConstants), so # the crate's month-sectioning and filter logic tests stay host-free. - - path: AppleNugs/macOS/MiniPlayer/TapeGeometry.swift - # Pure Foundation math (pack radius, progress clamping); compiled in - # rather than @testable-imported to keep the test bundle host-free. - path: AppleNugs/iOS/NowPlayingPill/PillLayout.swift # Pure Foundation policy (control sets, width budgets) for the iOS # now-playing pill; compiled in rather than @testable-imported so the # test bundle stays host-free — and so macOS-hosted tests can cover # iOS-only layout logic. - - path: AppleNugs/iOS/NowPlayingPill/PillDuckPolicy.swift - # Pure Foundation scroll-direction policy for the pill's duck on a - # vertical tab bar (iPhone Duo): hysteresis, top zone, rubber bands. - # Compiled in for the same host-free reason as PillLayout above. - path: AppleNugs/Theme/Transport/NowPlayingMetaLine.swift # Pure Foundation policy for the now-playing meta line's linkable runs # (and their URL vocabulary). Compiled in so the segmentation rules — @@ -284,6 +279,12 @@ targets: # Pure Foundation presentation logic for the Home listening ledger — # totals, top-5 ranking, and the display copy. Compiled in so the # rendered strings are pinned by test rather than by eyeball. + - path: AppleNugs/Core/PriceStickerLabel.swift + # Pure Foundation policy for Yeti's lossless price sticker: what it + # prints, what VoiceOver reads, and when a known lossy pick hides it. + - path: AppleNugs/Core/FoilSweepTrigger.swift + # Pure Foundation policy for Yeti's one-shot title sweep: fires once per + # new track while playing, never on pause/resume or at launch. scheme: testTargets: - AppleNugsTests diff --git a/scripts/generate-ios-icon.swift b/scripts/generate-ios-icon.swift deleted file mode 100644 index a30ef61..0000000 --- a/scripts/generate-ios-icon.swift +++ /dev/null @@ -1,97 +0,0 @@ -// Regenerates the full-bleed iOS app icon -// (AppleNugs/Assets.xcassets/AppIcon.appiconset/icon_ios_1024.png). -// -// iOS icons are full-bleed and opaque — the system applies its own squircle -// mask, so unlike the Mac icon there are no built-in rounded corners or -// transparent margins. This redraws the SAME waveform mark as the Mac master -// (its 11-bar symmetric envelope was measured from icon_512x512@2x.png) on a -// full-canvas graphite gradient with a warm depth glow, so the two icons stay -// a matched pair. -// -// swift scripts/generate-ios-icon.swift \ -// AppleNugs/Assets.xcassets/AppIcon.appiconset/icon_ios_1024.png -// -// Run with no argument to preview into /tmp. -import AppKit - -let size = 1024 -let canvas = CGFloat(size) -let outPath = CommandLine.arguments.count > 1 ? CommandLine.arguments[1] : "/tmp/icon_ios_1024.png" - -guard let ctx = CGContext( - data: nil, width: size, height: size, bitsPerComponent: 8, - bytesPerRow: 0, space: CGColorSpace(name: CGColorSpace.sRGB)!, - bitmapInfo: CGImageAlphaInfo.noneSkipLast.rawValue) else { fatalError("ctx") } - -func rgb(_ hex: UInt32) -> CGColor { - CGColor(srgbRed: CGFloat((hex >> 16) & 0xFF) / 255, green: CGFloat((hex >> 8) & 0xFF) / 255, - blue: CGFloat(hex & 0xFF) / 255, alpha: 1) -} -func rgba(_ hex: UInt32, _ a: CGFloat) -> CGColor { - CGColor(srgbRed: CGFloat((hex >> 16) & 0xFF) / 255, green: CGFloat((hex >> 8) & 0xFF) / 255, - blue: CGFloat(hex & 0xFF) / 255, alpha: a) -} -let sp = CGColorSpace(name: CGColorSpace.sRGB)! - -// CoreGraphics origin is bottom-left; the mark is vertically symmetric so the -// waveform is orientation-agnostic, while the background gradient runs top→bottom. - -// 1) Background: full-canvas graphite gradient, a shade deeper than the Mac -// squircle so it reads as premium at full bleed (iOS applies its own mask). -let bg = CGGradient(colorsSpace: sp, - colors: [rgb(0x322F2A), rgb(0x201E1A), rgb(0x121110)] as CFArray, - locations: [0.0, 0.55, 1.0])! -ctx.drawLinearGradient(bg, start: CGPoint(x: 0, y: canvas), end: CGPoint(x: 0, y: 0), options: []) - -// 2) Soft warm glow behind the waveform for depth. -let glow = CGGradient(colorsSpace: sp, - colors: [rgba(0xFF8C16, 0.16), rgba(0xFF8C16, 0.0)] as CFArray, - locations: [0.0, 1.0])! -ctx.drawRadialGradient(glow, - startCenter: CGPoint(x: canvas * 0.5, y: canvas * 0.5), startRadius: 0, - endCenter: CGPoint(x: canvas * 0.5, y: canvas * 0.5), endRadius: canvas * 0.42, - options: []) - -// 3) The waveform mark — the exact 11-bar symmetric envelope recovered from the -// Mac master, uniformly scaled up (S) to fill the full-bleed canvas while -// keeping the same visual proportion the mark has inside the Mac squircle. -// Bars are one clip path filled by a single vertical gradient, so short -// center bars catch only the middle band of the amber→orange ramp — as in -// the master. -let heights: [CGFloat] = [0.1475, 0.2568, 0.3682, 0.4561, 0.3076, 0.1982, - 0.3076, 0.4561, 0.3682, 0.2568, 0.1475] -let firstX: CGFloat = 0.2388, lastX: CGFloat = 0.7603 -let barW: CGFloat = 0.0258 -let S: CGFloat = 1.24 // full-bleed upscale about the center -let cx0 = canvas * 0.5, cy0 = canvas * 0.5 - -let barPath = CGMutablePath() -let n = heights.count -var tallest: CGFloat = 0 -for (i, h) in heights.enumerated() { - let xNorm = firstX + (lastX - firstX) * CGFloat(i) / CGFloat(n - 1) - let xc = cx0 + (xNorm - 0.5) * canvas * S - let w = barW * canvas * S - let barH = h * canvas * S - tallest = max(tallest, barH) - let rect = CGRect(x: xc - w / 2, y: cy0 - barH / 2, width: w, height: barH) - barPath.addRoundedRect(in: rect, cornerWidth: w / 2, cornerHeight: w / 2) -} - -ctx.saveGState() -ctx.addPath(barPath) -ctx.clip() -let barGrad = CGGradient(colorsSpace: sp, - colors: [rgb(0xFFA533), rgb(0xFF8C16), rgb(0xFF7200)] as CFArray, - locations: [0.0, 0.5, 1.0])! -// Gradient spans the tallest bar's extent (top = high y). -ctx.drawLinearGradient(barGrad, - start: CGPoint(x: 0, y: cy0 + tallest / 2), - end: CGPoint(x: 0, y: cy0 - tallest / 2), options: []) -ctx.restoreGState() - -guard let out = ctx.makeImage() else { fatalError("makeImage") } -let rep = NSBitmapImageRep(cgImage: out) -guard let png = rep.representation(using: .png, properties: [:]) else { fatalError("png") } -try! png.write(to: URL(fileURLWithPath: outPath)) -print("wrote \(outPath)") diff --git a/scripts/render-app-icon.py b/scripts/render-app-icon.py new file mode 100644 index 0000000..180db4c --- /dev/null +++ b/scripts/render-app-icon.py @@ -0,0 +1,353 @@ +#!/usr/bin/env python3 +"""Builds Yeti's app icon: "The bubble" (concept 2 in docs/design/yeti-icon/yeti-icon-concepts.html). + +A clear blister bubble fills the tile; sealed inside is the playing-row sticker (a sale-yellow +stock disc with four night-ink bars stepping 100/75/50/25); one thin foil-pink glint rides the +rim; all on night navy. + +Writes, under --out (default: the repo root): + + AppleNugs/AppIcon.icon/ the shipping icon, an Icon Composer document. actool compiles + icon.json it for both targets (ASSETCATALOG_COMPILER_APPICON_NAME: + Assets/*.svg AppIcon) into the Liquid Glass icon stack for iOS/macOS 26+ + (default, dark, clear, tinted) plus a flat AppIcon.icns / + AppIcon60x60@2x.png for older systems. + docs/design/yeti-icon/ + yeti-icon-master.svg the flat 1024 art exactly as picked (painted glare included) + yeti-icon-small.svg the hand-tuned small-size version (no flange or glare, heavier + rim, bigger sticker, wider bars) -- reference only, see below + yeti-icon-dark.svg the dark appearance as designed (system dark backing) + yeti-icon-tinted.svg the tinted appearance, approximated from luminance + +Then, unless --no-previews, renders the real system look of the .icon with Icon Composer's own +`ictool` into --previews (default: build/app-icon-previews): every iOS rendition at 1024/180, +macOS at 1024/512/256/128/64/32/16, and a contact sheet. + +Why the layers differ from the flat master: iOS 26 / macOS 26 light the icon themselves (specular +edge, glass, group shadows). So the .icon leaves out the painted hot spot, glare streaks, and the +blurred drop shadows -- painting them too would light the plastic twice -- and the night field is +the document's fill so the dark/clear/tinted appearances can replace it. + +Why the small master doesn't ship: actool silently prefers a .icon over a same-named appiconset +(verified: identical output bytes with or without the appiconset beside it), and a .icon has no +per-size art, so the system draws 16/32 from these same layers. The layers are tuned to survive +that (no hairlines, a rim heavy enough to hold at 16). + +Usage: python3 scripts/render-app-icon.py [--out DIR] [--previews DIR] [--no-previews] +Needs only python3 and Xcode (for ictool / sips). No third-party packages. +""" +import argparse +import json +import math +import os +import shutil +import subprocess +import sys + +# ---------------------------------------------------------------- tokens (Yeti theme) +NIGHT, SNOW, FOIL, SALE, INK = "#0F1424", "#E9EEF7", "#FF8FC4", "#F2E27A", "#0F1424" +STOCK = ("#FBF1A6", SALE, "#E3D267") # sale-yellow stock disc, light to edge +NIGHT_TOP, NIGHT_BOTTOM = "#151C33", "#0B0F1C" # the field's vertical gradient ends +INTERIOR = "#1C2648" # the air inside the bubble + +# ---------------------------------------------------------------- geometry (1024 canvas) +BX, BY, BW, BH, BR = 112, 116, 800, 800, 214 # the bubble +FLANGE = 36 # heat-sealed lip around it +SX, SY, SR, SROT = 512, 524, 262, -9 # the sticker: center, radius, tilt (degrees) +RIM = 7 # concept rim width + + +def rrect(x, y, w, h, r, extra=""): + return f'' + + +def svg(body, defs=""): + return ('' + + (f"{defs}" if defs else "") + body + "\n") + + +def stair_rects(cx, cy, d, bw=0.10, gap=0.07, H=0.46): + """Four bars stepping down 100/75/50/25 (the row sticker's figure). Returns (x, y, w, h, r).""" + bw, gap, H = d * bw, d * gap, d * H + total = 4 * bw + 3 * gap + x0, base = cx - total / 2, cy + H / 2 + return [(x0 + i * (bw + gap), base - H * f, bw, H * f, d * 0.012) for i, f in enumerate((1, .75, .5, .25))] + + +def bars(cx, cy, d, color=INK, **kw): + return "".join(rrect(round(x, 1), round(y, 1), round(w, 1), round(h, 1), round(r, 1), f'fill="{color}"') + for x, y, w, h, r in stair_rects(cx, cy, d, **kw)) + + +def foil_glint_path(x, y, r): + """The one foil glint: rides the rim around the bubble's top-left corner.""" + k = 0.5523 + return (f"M{x} {y + r * 1.55:.0f} L{x} {y + r:.0f} C{x} {y + r * (1 - k):.0f} {x + r * (1 - k):.0f} {y} " + f"{x + r:.0f} {y} L{x + r * 1.55:.0f} {y}") + + +def stock_gradient(gid): + return (f'' + f'' + f'') + + +FILM = ('' + '' + '') +SHADE = ('' + '') +RIMTOP = ('' + '') +GLOW = ('' + f'') +NIGHTG = ('' + f'' + f'') + + +# ---------------------------------------------------------------- flat masters (docs) +def flat_master(field_fill='url(#night)', extra_defs=NIGHTG, glow=True): + """Concept 2 exactly as it appears on the concepts page (lifted from its generator).""" + defs = (extra_defs + GLOW + FILM + SHADE + RIMTOP + stock_gradient("stock") + + '' + '' + + '' + + '' + + f'{rrect(BX, BY, BW, BH, BR)}') + x, y, w, h, r, f = BX, BY, BW, BH, BR, FLANGE + hx, hy = x + w * 0.30, y + h * 0.17 + cx, cy, d = x + w / 2, y + h / 2, max(w, h) * 1.6 + b = [f''] + if glow: + b.append('') + b += [ + rrect(x + 6, y + 30, w, h, r, 'fill="#03060D" opacity=".6" filter="url(#blur)"'), + rrect(x - f, y - f, w + 2 * f, h + 2 * f, r + f, + f'fill="{SNOW}" fill-opacity=".045" stroke="{SNOW}" stroke-opacity=".17" stroke-width="5"'), + '', + rrect(x, y, w, h, r, f'fill="{INTERIOR}"'), + f'', + f'', + f'', + bars(SX, SY, 2 * SR), + '', + rrect(x, y, w, h, r, 'fill="url(#film)"'), + rrect(x, y, w, h, r, 'fill="url(#shade)"'), + f'', + f'' + f'' + f'', + '', + rrect(x, y, w, h, r, f'fill="none" stroke="{SNOW}" stroke-opacity=".40" stroke-width="{RIM}"'), + rrect(x + 7, y + 7, w - 14, h - 14, r - 7, 'fill="none" stroke="url(#rimtop)" stroke-width="6"'), + f'', + ] + return svg("".join(b), defs) + + +def small_master(): + """The hand-tuned small size from the concepts page ("My pick" panel).""" + sr, sx, sy = 336, 512, 512 + return svg( + f'' + + rrect(92, 92, 840, 840, 230, f'fill="{INTERIOR}"') + + f'' + + bars(sx, sy, 2 * sr, bw=0.13, gap=0.06, H=0.48) + '' + + rrect(92, 92, 840, 840, 230, f'fill="none" stroke="{SNOW}" stroke-opacity=".62" stroke-width="34"'), + stock_gradient("stock")) + + +def tinted_master(): + """Tinted mode, approximated: luminance mapped onto a warm user tint (as on the concepts page).""" + body = flat_master() + inner = body[body.index(">", body.index("")] + return svg(f'{inner}', + '' + '' + '' + '' + '') + + +# ---------------------------------------------------------------- .icon layers +# Plain shapes, flat or gradient fills, no filters or clip paths: the system draws the shadows and +# the glass. Every layer is the full 1024 canvas, so no layer needs a position offset. +def layer_glow(): + return svg('', GLOW) + + +def layer_flange(): + x, y, w, h, r, f = BX, BY, BW, BH, BR, FLANGE + return svg(rrect(x - f, y - f, w + 2 * f, h + 2 * f, r + f, + f'fill="{SNOW}" fill-opacity=".045" stroke="{SNOW}" stroke-opacity=".17" stroke-width="5"')) + + +def layer_interior(): + return svg(rrect(BX, BY, BW, BH, BR, f'fill="{INTERIOR}"')) + + +def layer_disc(): + return svg(f'', + stock_gradient("stock")) + + +def layer_bars(): + return svg(f'{bars(SX, SY, 2 * SR)}') + + +def layer_film(): + x, y, w, h, r = BX, BY, BW, BH, BR + return svg(rrect(x, y, w, h, r, 'fill="url(#film)"') + rrect(x, y, w, h, r, 'fill="url(#shade)"'), FILM + SHADE) + + +def layer_rim(): + x, y, w, h, r = BX, BY, BW, BH, BR + return svg(rrect(x, y, w, h, r, f'fill="none" stroke="{SNOW}" stroke-opacity=".40" stroke-width="{RIM}"') + + rrect(x + 7, y + 7, w - 14, h - 14, r - 7, 'fill="none" stroke="url(#rimtop)" stroke-width="6"'), + RIMTOP) + + +def layer_foil(): + return svg(f'') + + +LAYERS = { + "glow.svg": layer_glow, "flange.svg": layer_flange, "interior.svg": layer_interior, + "disc.svg": layer_disc, "bars.svg": layer_bars, + "film.svg": layer_film, "rim.svg": layer_rim, "foil.svg": layer_foil, +} + + +def srgb(hex_color, alpha=1.0): + h = hex_color.lstrip("#") + r, g, b = (int(h[i:i + 2], 16) / 255 for i in (0, 2, 4)) + return f"srgb:{r:.5f},{g:.5f},{b:.5f},{alpha:.5f}" + + +def layer(name, image, glass=False, **extra): + d = {"name": name, "image-name": image, "glass": glass, "hidden": False} + d.update(extra) + return d + + +def group(name, layers, shadow=("neutral", 0.5), specular=True, translucency=None): + g = {"name": name, "layers": layers, + "shadow": {"kind": shadow[0], "opacity": shadow[1]}, + "specular": specular, + "translucency": {"enabled": translucency is not None, "value": translucency or 0.5}} + return g + + +def icon_json(): + """Groups are listed FRONT FIRST (the JSON's z-order), layers likewise within a group.""" + return { + # Night field. Dark lets the system's own dark backing show (the art is already night); + # tinted/clear are drawn by the system from luminance. + "fill": {"linear-gradient": [srgb(NIGHT_TOP), srgb(NIGHT_BOTTOM)]}, + "fill-specializations": [ + {"value": {"linear-gradient": [srgb(NIGHT_TOP), srgb(NIGHT_BOTTOM)]}}, + {"appearance": "dark", "value": "automatic"}, + ], + "groups": [ + # The glint gets its own group with no specular: inside the Bubble group the glass + # rim's highlight flecked it white where the two overlap. + group("Foil", [layer("Foil glint", "foil.svg")], shadow=("none", 0.5), specular=False), + # The rim is real Liquid Glass (a lit bevel), which is what replaces the painted + # hot spot and glare streaks of the flat master. + group("Bubble", [ + layer("Rim", "rim.svg", glass=True), + layer("Film", "film.svg"), + ], shadow=("none", 0.5), specular=True), + group("Sticker", [ + layer("Bars", "bars.svg"), + layer("Disc", "disc.svg", + **{"fill-specializations": [{"appearance": "tinted", + "value": {"solid": "srgb:1.00000,1.00000,1.00000,1.00000"}}]}), + ], shadow=("neutral", 0.5), specular=False), + group("Card", [ + layer("Interior", "interior.svg"), + layer("Flange", "flange.svg"), + layer("Glow", "glow.svg", + **{"hidden-specializations": [{"appearance": "dark", "value": True}, + {"appearance": "tinted", "value": True}]}), + ], shadow=("neutral", 0.6), specular=False), + ], + "supported-platforms": {"squares": "shared"}, + } + + +# ---------------------------------------------------------------- previews +def _developer_dir(): + try: + return subprocess.run(["xcode-select", "-p"], capture_output=True, text=True, check=True).stdout.strip() + except (OSError, subprocess.CalledProcessError): + return "/Applications/Xcode.app/Contents/Developer" + + +# Icon Composer ships inside the selected Xcode (…/Xcode.app/Contents/Applications). +ICTOOL = os.path.join(os.path.dirname(_developer_dir()), "Applications", "Icon Composer.app", + "Contents", "Executables", "ictool") + + +def ictool(doc, out, platform, rendition, px): + subprocess.run([ICTOOL, doc, "--export-image", "--output-file", out, "--platform", platform, + "--rendition", rendition, "--width", str(px), "--height", str(px), "--scale", "1"], + check=True, stdout=subprocess.DEVNULL) + + +def main(): + here = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + ap = argparse.ArgumentParser() + ap.add_argument("--out", default=here) + ap.add_argument("--previews", default=os.path.join(here, "build", "app-icon-previews")) + ap.add_argument("--no-previews", action="store_true") + a = ap.parse_args() + + icon = os.path.join(a.out, "AppleNugs", "AppIcon.icon") + assets = os.path.join(icon, "Assets") + if os.path.isdir(assets): + shutil.rmtree(assets) + os.makedirs(assets) + for name, fn in LAYERS.items(): + with open(os.path.join(assets, name), "w") as fh: + fh.write(fn()) + with open(os.path.join(icon, "icon.json"), "w") as fh: + json.dump(icon_json(), fh, indent=2, sort_keys=True) + fh.write("\n") + print("wrote", icon) + + docs = os.path.join(a.out, "docs", "design", "yeti-icon") + os.makedirs(docs, exist_ok=True) + masters = { + "yeti-icon-master.svg": flat_master(), + "yeti-icon-small.svg": small_master(), + "yeti-icon-dark.svg": flat_master(field_fill="url(#sys)", glow=False, extra_defs=( + '' + '')), + "yeti-icon-tinted.svg": tinted_master(), + } + for name, text in masters.items(): + with open(os.path.join(docs, name), "w") as fh: + fh.write(text) + print("wrote", ", ".join(masters), "in", docs) + + if a.no_previews: + return + if not os.path.exists(ICTOOL): + sys.exit(f"error: ictool not found at {ICTOOL} (Xcode 26+ ships it inside Icon Composer.app)") + p = a.previews + os.makedirs(p, exist_ok=True) + for rendition in ("Default", "Dark", "ClearLight", "ClearDark", "TintedLight", "TintedDark"): + for px in (1024, 180): + ictool(icon, os.path.join(p, f"ios-{rendition}-{px}.png"), "iOS", rendition, px) + for px in (1024, 512, 256, 128, 64, 32, 16): + ictool(icon, os.path.join(p, f"macos-Default-{px}.png"), "macOS", "Default", px) + for px in (64, 32, 16): + ictool(icon, os.path.join(p, f"ios-Default-{px}.png"), "iOS", "Default", px) + print("previews in", p) + + +if __name__ == "__main__": + main()