From 323ff81430670f4a58cf6b76008dc97f6466a6c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Za=C2=A1d?= Date: Sat, 24 Oct 2026 17:17:17 +0300 Subject: [PATCH 1/9] feat(ui): Material 3 theme foundation (Poppins, seed-derived color roles) - bundle Poppins (copied from android/app/src/main/res/font) via UIAppFonts - M3Roles: 19 roles derived from the live accent seed, light + dark - Color.carmen family moved Components.swift -> Theme/Theme.swift - M3 type scale, shape/dimension tokens, press-scale modifier --- ios/Carmen/Fonts/poppins.ttf | Bin 0 -> 158240 bytes ios/Carmen/Views/Components.swift | 57 ----- ios/Carmen/Views/Theme/Theme.swift | 367 +++++++++++++++++++++++++++++ ios/project.yml | 5 + 4 files changed, 372 insertions(+), 57 deletions(-) create mode 100644 ios/Carmen/Fonts/poppins.ttf create mode 100644 ios/Carmen/Views/Theme/Theme.swift diff --git a/ios/Carmen/Fonts/poppins.ttf b/ios/Carmen/Fonts/poppins.ttf new file mode 100644 index 0000000000000000000000000000000000000000..9f0c71b70a49664ced448c63edc9c4ff2bf8cf4a GIT binary patch literal 158240 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 16) & 0xFF) / 255, - green: Double((value >> 8) & 0xFF) / 255, - blue: Double(value & 0xFF) / 255, - opacity: 1 - ) - } - - /// The hex the whole app currently themes by: the playing cover's own - /// colour when Settings → Appearance → Adaptive Accent is on (frontend - /// parity), else the chosen palette accent. - @MainActor private static var effectiveHex: String { - let settings = SettingsStore.shared - if settings.adaptiveAccentEnabled, let hex = Player.shared.adaptiveColorHex, !hex.isEmpty { - return hex - } - return settings.accent.hex - } - - /// The app-wide accent. Read through the observable stores so every - /// surface re-renders live — including when the cover's colour lands - /// mid-song. - @MainActor static var carmen: Color { Color(hex: effectiveHex) } - - /// Ink for filled-accent surfaces (play button, FABs): stays readable on - /// the near-white accents — the palette's "no colour here" answer too. - /// The hex reaches us as "#FFFFFF", so compare the *stripped* value. - @MainActor static var carmenOnAccent: Color { - isNearWhite(effectiveHex) ? .black.opacity(0.85) : .white - } - - /// Tint for system controls (switches, sliders, ProgressView): a white - /// accent would draw them in white-on-white, so near-white accents flip - /// to black ink there — same rule as the frontend gives white. - @MainActor static var carmenControlTint: Color { - isNearWhite(effectiveHex) ? .black : carmen - } - - /// "#FFFFFF", "ffffff", " #fefefe " … — all "close enough to white". - private static func isNearWhite(_ hex: String) -> Bool { - var value: UInt64 = 0 - Scanner(string: hex.trimmingCharacters(in: CharacterSet(charactersIn: "#"))).scanHexInt64(&value) - let red = (value >> 16) & 0xFF - let green = (value >> 8) & 0xFF - let blue = value & 0xFF - return red >= 235 && green >= 235 && blue >= 235 - } -} - // MARK: - Navigation /// Push destinations, built from feed/search cards. Songs and stations are diff --git a/ios/Carmen/Views/Theme/Theme.swift b/ios/Carmen/Views/Theme/Theme.swift new file mode 100644 index 0000000..f7e008c --- /dev/null +++ b/ios/Carmen/Views/Theme/Theme.swift @@ -0,0 +1,367 @@ +// +// Theme.swift +// Carmen — Material 3 design tokens. +// +// The Android app seeds its scheme from Carmen's red-pink (0xFFED5564) but +// lets the artwork-extracted accent win when Adaptive Accent is on. This +// file keeps that same precedence and derives the full Material 3 role set +// (light + dark) from whatever seed is live, so every surface re-tints the +// moment a new cover's colour lands — exactly like Android's materialkolor +// dynamic scheme, but hand-rolled (no dependencies). +// +// Usage in views: +// @Environment(\.m3) private var m3 // color roles +// Text("Title").font(.m3TitleLarge) // Poppins type scale +// .background(m3.surfaceContainer, in: M3Shape.card) +// +// The root applies `.m3Theme()` (RootView) — nothing else injects roles. +// + +import SwiftUI + +// MARK: - Accent (moved verbatim from the old Components.swift) + +extension Color { + /// Hex string ("#RRGGBB") → Color. + init(hex: String) { + var value: UInt64 = 0 + Scanner(string: hex.trimmingCharacters(in: CharacterSet(charactersIn: "#"))).scanHexInt64(&value) + self.init( + .sRGB, + red: Double((value >> 16) & 0xFF) / 255, + green: Double((value >> 8) & 0xFF) / 255, + blue: Double(value & 0xFF) / 255, + opacity: 1 + ) + } + + /// The hex the whole app currently themes by: the playing cover's own + /// colour when Settings → Appearance → Adaptive Accent is on (frontend + /// parity), else the chosen palette accent. + @MainActor fileprivate static var effectiveHex: String { + let settings = SettingsStore.shared + if settings.adaptiveAccentEnabled, let hex = Player.shared.adaptiveColorHex, !hex.isEmpty { + return hex + } + return settings.accent.hex + } + + /// The app-wide accent seed. Read through the observable stores so every + /// surface re-renders live — including when the cover's colour lands + /// mid-song. + @MainActor static var carmen: Color { Color(hex: effectiveHex) } + + /// Ink for filled-accent surfaces (play button, FABs): stays readable on + /// the near-white accents — the palette's "no colour here" answer too. + @MainActor static var carmenOnAccent: Color { + isNearWhite(effectiveHex) ? .black.opacity(0.85) : .white + } + + /// Tint for system controls (switches, sliders, ProgressView): a white + /// accent would draw them in white-on-white, so near-white accents flip + /// to black ink there — same rule as the frontend gives white. + @MainActor static var carmenControlTint: Color { + isNearWhite(effectiveHex) ? .black : carmen + } + + /// "#FFFFFF", "ffffff", " #fefefe " … — all "close enough to white". + private static func isNearWhite(_ hex: String) -> Bool { + var value: UInt64 = 0 + Scanner(string: hex.trimmingCharacters(in: CharacterSet(charactersIn: "#"))).scanHexInt64(&value) + let red = (value >> 16) & 0xFF + let green = (value >> 8) & 0xFF + let blue = value & 0xFF + return red >= 235 && green >= 235 && blue >= 235 + } +} + +// MARK: - Seed + +/// The live scheme seed. Mirrors `Color.carmen` precedence; kept separate so +/// non-view code (roles derivation) has one obvious entry point. +@MainActor enum M3 { + /// Android's `DefaultThemeColor = 0xFFED5564` — used only when no accent + /// can be resolved at all. + static let fallbackSeedHex = "ED5564" + + static var seedHex: String { Color.effectiveHex } +} + +// MARK: - HSL helpers + +private struct HSL { + var h: Double // 0...360 + var s: Double // 0...1 + var l: Double // 0...1 +} + +private func hsl(_ hex: String) -> HSL { + var value: UInt64 = 0 + Scanner(string: hex.trimmingCharacters(in: CharacterSet(charactersIn: "#"))).scanHexInt64(&value) + let r = Double((value >> 16) & 0xFF) / 255 + let g = Double((value >> 8) & 0xFF) / 255 + let b = Double(value & 0xFF) / 255 + let maxChannel = max(r, g, b) + let minChannel = min(r, g, b) + let lightness = (maxChannel + minChannel) / 2 + let delta = maxChannel - minChannel + guard delta > 0.000_01 else { return HSL(h: 0, s: 0, l: lightness) } + let saturation = delta / (1 - abs(2 * lightness - 1)) + let hue: Double + if maxChannel == r { + hue = 60 * ((g - b) / delta).truncatingRemainder(dividingBy: 6) + } else if maxChannel == g { + hue = 60 * ((b - r) / delta + 2) + } else { + hue = 60 * ((r - g) / delta + 4) + } + return HSL(h: hue < 0 ? hue + 360 : hue, s: saturation, l: lightness) +} + +private func shifted(_ color: HSL, by degrees: Double) -> HSL { + HSL(h: (color.h + degrees).truncatingRemainder(dividingBy: 360), s: color.s, l: color.l) +} + +/// HSL → SwiftUI color (`opacity` reserved for the scrim-style roles). +private func tone(_ base: HSL, s: Double? = nil, l: Double? = nil, opacity: Double = 1) -> Color { + let hue = base.h.truncatingRemainder(dividingBy: 360) + let saturation = min(max(s ?? base.s, 0), 1) + let lightness = min(max(l ?? base.l, 0), 1) + let chroma = (1 - abs(2 * lightness - 1)) * saturation + let x = chroma * (1 - abs((hue / 60).truncatingRemainder(dividingBy: 2) - 1)) + let m = lightness - chroma / 2 + var red = 0.0, green = 0.0, blue = 0.0 + switch hue { + case ..<60: red = chroma; green = x + case ..<120: red = x; green = chroma + case ..<180: green = chroma; blue = x + case ..<240: green = x; blue = chroma + case ..<300: red = x; blue = chroma + default: red = chroma; blue = x + } + return Color( + .sRGB, + red: red + m, + green: green + m, + blue: blue + m, + opacity: opacity + ) +} + +// MARK: - Color roles + +/// The 19 Material 3 roles this app uses, derived from one seed for light +/// and dark. Naming follows the Material docs (surfaceContainerHigh …), +/// never raw hexes in screens. +struct M3Roles: Equatable { + let seed: String + + let primary: Color + let onPrimary: Color + let primaryContainer: Color + let onPrimaryContainer: Color + + let secondary: Color + let onSecondary: Color + let secondaryContainer: Color + let onSecondaryContainer: Color + + let tertiary: Color + let onTertiary: Color + let tertiaryContainer: Color + let onTertiaryContainer: Color + + let background: Color + let surface: Color + let surfaceContainer: Color + let surfaceContainerHigh: Color + let surfaceContainerHighest: Color + + let onSurface: Color + let onSurfaceVariant: Color + let outline: Color + let outlineVariant: Color + + let error: Color + let onError: Color + let scrim: Color + + init(seedHex: String, dark: Bool) { + let base = hsl(seedHex.isEmpty ? M3.fallbackSeedHex : seedHex) + // A near-black seed would collapse the scheme to black-on-black; a + // zero-saturation one (white/grey accents) falls back to Carmen pink. + let seed = base.s < 0.08 && base.l < 0.12 ? hsl(M3.fallbackSeedHex) : base + let strong = min(seed.s, 0.68) // Material "primary" saturation cap + self.seed = seedHex + + if dark { + primary = tone(seed, s: strong, l: 0.70) + onPrimary = tone(seed, s: strong, l: 0.18) + primaryContainer = tone(seed, s: strong * 0.45, l: 0.28) + onPrimaryContainer = tone(seed, s: strong * 0.60, l: 0.86) + + secondary = tone(seed, s: 0.22, l: 0.70) + onSecondary = tone(seed, s: 0.22, l: 0.18) + secondaryContainer = tone(seed, s: 0.22, l: 0.28) + onSecondaryContainer = tone(seed, s: 0.26, l: 0.86) + + let ter = shifted(seed, by: 60) + tertiary = tone(ter, s: 0.45, l: 0.70) + onTertiary = tone(ter, s: 0.45, l: 0.18) + tertiaryContainer = tone(ter, s: 0.30, l: 0.28) + onTertiaryContainer = tone(ter, s: 0.45, l: 0.86) + + background = tone(seed, s: 0.05, l: 0.06) + surface = tone(seed, s: 0.06, l: 0.10) + surfaceContainer = tone(seed, s: 0.06, l: 0.14) + surfaceContainerHigh = tone(seed, s: 0.06, l: 0.17) + surfaceContainerHighest = tone(seed, s: 0.06, l: 0.20) + + onSurface = tone(seed, s: 0.07, l: 0.90) + onSurfaceVariant = tone(seed, s: 0.06, l: 0.72) + outline = tone(seed, s: 0.05, l: 0.52) + outlineVariant = tone(seed, s: 0.05, l: 0.30) + + error = Color(hex: "F2B8B5") + onError = Color(hex: "601410") + scrim = .black + } else { + primary = tone(seed, s: strong, l: 0.45) + onPrimary = .white + primaryContainer = tone(seed, s: strong * 0.45, l: 0.90) + onPrimaryContainer = tone(seed, s: strong, l: 0.15) + + secondary = tone(seed, s: 0.16, l: 0.42) + onSecondary = .white + secondaryContainer = tone(seed, s: 0.20, l: 0.88) + onSecondaryContainer = tone(seed, s: 0.30, l: 0.15) + + let ter = shifted(seed, by: 60) + tertiary = tone(ter, s: 0.45, l: 0.42) + onTertiary = .white + tertiaryContainer = tone(ter, s: 0.30, l: 0.90) + onTertiaryContainer = tone(ter, s: 0.45, l: 0.15) + + background = tone(seed, s: 0.03, l: 0.99) + surface = tone(seed, s: 0.03, l: 0.97) + surfaceContainer = tone(seed, s: 0.04, l: 0.94) + surfaceContainerHigh = tone(seed, s: 0.04, l: 0.92) + surfaceContainerHighest = tone(seed, s: 0.04, l: 0.90) + + onSurface = tone(seed, s: 0.06, l: 0.11) + onSurfaceVariant = tone(seed, s: 0.05, l: 0.32) + outline = tone(seed, s: 0.05, l: 0.55) + outlineVariant = tone(seed, s: 0.04, l: 0.82) + + error = Color(hex: "B3261E") + onError = .white + scrim = .black + } + } +} + +// MARK: - Environment plumbing + +private struct M3RolesKey: EnvironmentKey { + static let defaultValue = M3Roles(seedHex: M3.fallbackSeedHex, dark: false) +} + +extension EnvironmentValues { + /// Current Material 3 color roles. Injected once by `.m3Theme()` in + /// RootView; re-derived whenever the seed (live accent) or the system + /// color scheme changes. + var m3: M3Roles { + get { self[M3RolesKey.self] } + set { self[M3RolesKey.self] = newValue } + } +} + +struct M3ThemeModifier: ViewModifier { + @Environment(\.colorScheme) private var colorScheme + + func body(content: Content) -> some View { + content.environment(\.m3, M3Roles(seedHex: M3.seedHex, dark: colorScheme == .dark)) + } +} + +extension View { + /// Applies the Material scheme at the app root. Everything below reads + /// `@Environment(\.m3)`. + func m3Theme() -> some View { modifier(M3ThemeModifier()) } +} + +// MARK: - Shapes & dimensions + +/// The M3 expressive shape scale (Android uses 8/12/16/24/32 dp). +enum M3Shape { + static let small = RoundedRectangle(cornerRadius: 8, style: .continuous) + static let card = RoundedRectangle(cornerRadius: 12, style: .continuous) + static let sheet = RoundedRectangle(cornerRadius: 16, style: .continuous) + static let dialog = RoundedRectangle(cornerRadius: 28, style: .continuous) + static let large = RoundedRectangle(cornerRadius: 24, style: .continuous) + static let pill = RoundedRectangle(cornerRadius: 32, style: .continuous) + static let capsule = Capsule(style: .continuous) +} + +/// Shared layout numbers — Android's `constants/Dimensions.kt`, in points. +enum M3Dim { + static let listHeight: CGFloat = 72 + static let listThumb: CGFloat = 56 + static let gridThumb: CGFloat = 128 + static let appBar: CGFloat = 64 + static let navPill: CGFloat = 78 + static let miniPlayer: CGFloat = 70 + static let playerPad: CGFloat = 32 + static let chipHeight: CGFloat = 32 + static let fab: CGFloat = 56 +} + +// MARK: - Typography (Poppins) + +/// Material's type scale mapped onto the bundled Poppins family (copied from +/// the Android app). `relativeTo` keeps Dynamic Type working — sizes grow +/// from the listed step with the user's text size. +extension Font { + static func m3(_ size: CGFloat, weight: Font.Weight = .regular, relativeTo textStyle: Font.TextStyle = .body) -> Font { + Font.custom("Poppins", size: size, relativeTo: textStyle).weight(weight) + } + + static var m3DisplayLarge: Font { m3(57, relativeTo: .largeTitle) } + static var m3HeadlineLarge: Font { m3(32, relativeTo: .title) } + static var m3HeadlineMedium: Font { m3(28, relativeTo: .title) } + static var m3TitleLarge: Font { m3(22, weight: .bold, relativeTo: .title2) } + static var m3TitleMedium: Font { m3(16, weight: .medium, relativeTo: .headline) } + static var m3TitleSmall: Font { m3(14, weight: .medium, relativeTo: .subheadline) } + static var m3BodyLarge: Font { m3(16, relativeTo: .body) } + static var m3BodyMedium: Font { m3(14, relativeTo: .subheadline) } + static var m3BodySmall: Font { m3(12, relativeTo: .footnote) } + static var m3LabelLarge: Font { m3(14, weight: .medium, relativeTo: .subheadline) } + static var m3LabelMedium: Font { m3(12, weight: .medium, relativeTo: .footnote) } + static var m3LabelSmall: Font { m3(11, weight: .medium, relativeTo: .caption2) } +} + +// MARK: - Press feedback (chips & cards scale on touch, like Android's +// expressive spring) + +private struct PressScaleModifier: ViewModifier { + var scale: CGFloat = 0.96 + @State private var pressed = false + + func body(content: Content) -> some View { + content + .scaleEffect(pressed ? scale : 1) + .animation(.spring(response: 0.28, dampingFraction: 0.62), value: pressed) + .simultaneousGesture( + DragGesture(minimumDistance: 0) + .onChanged { _ in pressed = true } + .onEnded { _ in pressed = false } + ) + } +} + +extension View { + /// Android's item press-scale (expressive spring, ~4% shrink). + func pressScale(_ scale: CGFloat = 0.96) -> some View { + modifier(PressScaleModifier(scale: scale)) + } +} diff --git a/ios/project.yml b/ios/project.yml index 97069c9..205369d 100644 --- a/ios/project.yml +++ b/ios/project.yml @@ -47,6 +47,11 @@ targets: CFBundleShortVersionString: $(MARKETING_VERSION) CFBundleVersion: $(CURRENT_PROJECT_VERSION) UILaunchScreen: {} + # Poppins (copied from android/app/src/main/res/font/) — the Android + # app's default family; Font.m3* in Views/Theme/Theme.swift maps the + # Material type scale onto it. + UIAppFonts: + - Fonts/poppins.ttf UISupportedInterfaceOrientations: - UIInterfaceOrientationPortrait # Keep playing when the screen locks — also what lets the app keep From ef09b95e02ce9466f2d8b25a87ca9f1111782f28 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Za=C2=A1d?= Date: Sat, 24 Oct 2026 17:44:44 +0300 Subject: [PATCH 2/9] feat(ui): Material 3 component kit (buttons, chips, rows, cards, sheet menus) - Buttons: filled/tonal/text/icon styles + FAB, 40pt capsules & circles - Chips: FilterChip (selected = secondaryContainer + checkmark), TagChip - Rows: ListRow (72pt/56pt r10 + kebab sheet menu), GridTile (old CardTile routing incl. play-context/route/station), SectionHeader w/ Play all, PlayingIndicator (4 animated bars), TrackSheetMenu - Surfaces: M3Card, shimmer (block/list/grid skeletons), loading/error/empty - Chrome: TopAppBar, M3SearchDock/M3SearchField, .bottomSheetMenu (own NavigationStack so Go to Album/Artist push), SheetMenuRow(Label) - Actions: Favorite/Download/PlaylistPicker ported verbatim (same stores) - ArtworkView + ExplicitBadge moved verbatim; old copies removed from Components.swift (no duplicate declarations) --- ios/Carmen/Views/Components.swift | 264 ------------- ios/Carmen/Views/Components/Actions.swift | 183 +++++++++ ios/Carmen/Views/Components/Artwork.swift | 84 ++++ ios/Carmen/Views/Components/Buttons.swift | 138 +++++++ ios/Carmen/Views/Components/Chips.swift | 90 +++++ ios/Carmen/Views/Components/Chrome.swift | 237 ++++++++++++ ios/Carmen/Views/Components/Rows.swift | 424 +++++++++++++++++++++ ios/Carmen/Views/Components/Surfaces.swift | 240 ++++++++++++ 8 files changed, 1396 insertions(+), 264 deletions(-) create mode 100644 ios/Carmen/Views/Components/Actions.swift create mode 100644 ios/Carmen/Views/Components/Artwork.swift create mode 100644 ios/Carmen/Views/Components/Buttons.swift create mode 100644 ios/Carmen/Views/Components/Chips.swift create mode 100644 ios/Carmen/Views/Components/Chrome.swift create mode 100644 ios/Carmen/Views/Components/Rows.swift create mode 100644 ios/Carmen/Views/Components/Surfaces.swift diff --git a/ios/Carmen/Views/Components.swift b/ios/Carmen/Views/Components.swift index b5a1618..35ae864 100644 --- a/ios/Carmen/Views/Components.swift +++ b/ios/Carmen/Views/Components.swift @@ -52,96 +52,6 @@ func playableTracks(_ cards: [MediaCard]) -> [Track] { cards.compactMap { $0.kind == .song ? $0.track : nil } } -// MARK: - Artwork - -/// Square artwork with a JPEG-first fallback chain and a neutral placeholder — -/// never a broken image. -struct ArtworkView: View { - let url: String? - var fallbackURL: String? - var cornerRadius: CGFloat = 6 - var placeholderSymbol: String = "music.note" - - var body: some View { - Group { - if let urlString = url, let url = URL(string: urlString) { - AsyncImage(url: url, transaction: Transaction(animation: .easeInOut(duration: 0.15))) { phase in - switch phase { - case .success(let image): - image.resizable().scaledToFill() - case .failure: - fallbackOrPlaceholder - case .empty: - placeholder - @unknown default: - placeholder - } - } - } else { - fallbackOrPlaceholder - } - } - // A failed (offline) load is remembered by the AsyncImage forever — - // re-key it whenever connectivity flips so covers reload themselves. - .id(NetworkMonitor.shared.generation) - .frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity) - .clipped() - .clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)) - .overlay( - RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) - .strokeBorder(.primary.opacity(0.06), lineWidth: 0.5) - ) - } - - @ViewBuilder private var fallbackOrPlaceholder: some View { - if let fallbackURL, fallbackURL != url, let url = URL(string: fallbackURL) { - AsyncImage(url: url) { phase in - if case .success(let image) = phase { - image.resizable().scaledToFill() - } else { - placeholder - } - } - } else { - placeholder - } - } - - private var placeholder: some View { - ZStack { - Rectangle().fill(Color(.secondarySystemFill)) - Image(systemName: placeholderSymbol) - .font(.system(size: 17, weight: .semibold)) - .foregroundStyle(.secondary) - } - } -} - -// MARK: - Small bits - -/// Apple Music's ℮ marker. -struct ExplicitBadge: View { - var body: some View { - Text("E") - .font(.system(size: 9, weight: .bold)) - .foregroundStyle(.secondary) - .frame(width: 13, height: 13) - .background(Color(.secondarySystemFill), in: RoundedRectangle(cornerRadius: 3, style: .continuous)) - } -} - -struct SectionHeader: View { - let title: String - - var body: some View { - Text(title) - .font(.title3.weight(.bold)) - .lineLimit(1) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.horizontal) - } -} - // MARK: - State views struct LoadingState: View { @@ -334,180 +244,6 @@ struct TrackRow: View { } } -// MARK: - Favorite / download controls - -/// Trailing heart that toggles a song as a favorite (Apple Music placement). -struct FavoriteButton: View { - let track: Track - - @Environment(LibraryStore.self) private var library - - private var isFavorite: Bool { library.isFavorite(track.videoId) } - - var body: some View { - Button { - library.toggleFavorite(track) - } label: { - Image(systemName: isFavorite ? "heart.fill" : "heart") - .font(.footnote) - .foregroundStyle(isFavorite ? Color.carmen : Color.secondary) - .frame(width: 32, height: 32) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .accessibilityLabel(isFavorite ? "Remove Favorite" : "Favorite") - } -} - -/// The circular download control: idle → progress ring (with % when the -/// server declared a size) → check, tap again removes. Mirrors the web -/// client's DownloadButton, including the failure alert. -struct DownloadButton: View { - let track: Track - var diameter: CGFloat = 24 - - @Environment(DownloadsStore.self) private var downloads - - private var state: DownloadsStore.State? { downloads.state(for: track.videoId) } - - var body: some View { - Button { - if downloads.isDownloaded(track.videoId) || state != nil { - downloads.remove(videoId: track.videoId) - } else { - downloads.download(track) - } - } label: { - label - .frame(width: diameter + 12, height: diameter + 12) - .contentShape(Circle()) - } - .buttonStyle(.plain) - .alert( - "Download Failed", - isPresented: Binding( - get: { downloads.failedIds.contains(track.videoId) }, - set: { presented in - if !presented { downloads.acknowledgeFailure(track.videoId) } - } - ) - ) { - Button("OK", role: .cancel) {} - } message: { - Text("The download for “\(track.title)” could not be saved.") - } - .accessibilityLabel(labelDescription) - } - - @ViewBuilder private var label: some View { - switch state { - case .running(let progress): - ZStack { - Circle() - .stroke(Color.secondary.opacity(0.25), lineWidth: 2) - Circle() - .trim(from: 0, to: max(progress, 0.02)) - .stroke(Color.carmen, style: StrokeStyle(lineWidth: 2, lineCap: .round)) - .rotationEffect(.degrees(-90)) - if progress > 0.05 { - Text("\(Int((progress * 100).rounded()))") - .font(.system(size: diameter * 0.4, weight: .semibold, design: .rounded)) - .monospacedDigit() - .foregroundStyle(Color.carmen) - } - } - .frame(width: diameter, height: diameter) - case .done: - Image(systemName: "checkmark.circle.fill") - .font(.system(size: diameter)) - .foregroundStyle(Color.carmen) - case nil: - Image(systemName: "arrow.down.circle") - .font(.system(size: diameter + 2)) - .foregroundStyle(Color.secondary) - } - } - - private var labelDescription: String { - switch state { - case .running: return "Downloading" - case .done: return "Downloaded — tap to remove" - case nil: return downloads.isDownloaded(track.videoId) ? "Downloaded" : "Download" - } - } -} - -/// Context-menu rows for a track's download state. -struct DownloadMenuItems: View { - let track: Track - - @Environment(DownloadsStore.self) private var downloads - - var body: some View { - if downloads.isDownloaded(track.videoId) { - Button(role: .destructive) { - downloads.remove(videoId: track.videoId) - } label: { - Label("Remove Download", systemImage: "trash") - } - } else { - Button { - downloads.download(track) - } label: { - Label("Download", systemImage: "arrow.down.circle") - } - } - } -} - -/// Sub-menu of the current local playlists + "New Playlist…". -struct PlaylistPickerMenu: View { - let track: Track - - @Environment(LibraryStore.self) private var library - @State private var showingNewPlaylist = false - @State private var newName = "" - - var body: some View { - Menu { - if library.playlists.isEmpty { - Text("No playlists yet") - } else { - ForEach(library.playlists) { playlist in - Button { - library.toggleTrack(track, inPlaylist: playlist.id) - } label: { - if library.isSaved(inPlaylist: playlist.id, videoId: track.videoId) { - Label(playlist.name, systemImage: "checkmark") - } else { - Text(playlist.name) - } - } - } - } - Divider() - Button { - showingNewPlaylist = true - } label: { - Label("New Playlist…", systemImage: "plus") - } - } label: { - Label("Add to Playlist…", systemImage: "music.note.list") - } - .alert("New Playlist", isPresented: $showingNewPlaylist) { - TextField("Name", text: $newName) - Button("Create") { - let name = newName.trimmingCharacters(in: .whitespacesAndNewlines) - guard !name.isEmpty else { return } - let created = library.createPlaylist(named: name) - library.toggleTrack(track, inPlaylist: created.id) - newName = "" - } - Button("Cancel", role: .cancel) { newName = "" } - } - } -} - // MARK: - Feed tile /// Card used by home rows and search grids: big artwork, title, subtitle. diff --git a/ios/Carmen/Views/Components/Actions.swift b/ios/Carmen/Views/Components/Actions.swift new file mode 100644 index 0000000..51cd1a6 --- /dev/null +++ b/ios/Carmen/Views/Components/Actions.swift @@ -0,0 +1,183 @@ +// +// Actions.swift +// Carmen — per-track actions shared by rows, tiles and menus. +// +// Ported verbatim from the old shared Components.swift: same signatures, +// same LibraryStore/DownloadsStore calls, same live-accent (Color.carmen) +// coloring — only the surrounding chrome changed. +// + +import SwiftUI + +/// Trailing heart that toggles a song as a favorite (Apple Music placement). +struct FavoriteButton: View { + let track: Track + + @Environment(LibraryStore.self) private var library + + private var isFavorite: Bool { library.isFavorite(track.videoId) } + + var body: some View { + Button { + library.toggleFavorite(track) + } label: { + Image(systemName: isFavorite ? "heart.fill" : "heart") + .font(.footnote) + .foregroundStyle(isFavorite ? Color.carmen : Color.secondary) + .frame(width: 32, height: 32) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .accessibilityLabel(isFavorite ? "Remove Favorite" : "Favorite") + } +} + +/// The circular download control: idle → progress ring (with % when the +/// server declared a size) → check, tap again removes. Mirrors the web +/// client's DownloadButton, including the failure alert. +struct DownloadButton: View { + let track: Track + var diameter: CGFloat = 24 + + @Environment(DownloadsStore.self) private var downloads + + private var state: DownloadsStore.State? { downloads.state(for: track.videoId) } + + var body: some View { + Button { + if downloads.isDownloaded(track.videoId) || state != nil { + downloads.remove(videoId: track.videoId) + } else { + downloads.download(track) + } + } label: { + label + .frame(width: diameter + 12, height: diameter + 12) + .contentShape(Circle()) + } + .buttonStyle(.plain) + .alert( + "Download Failed", + isPresented: Binding( + get: { downloads.failedIds.contains(track.videoId) }, + set: { presented in + if !presented { downloads.acknowledgeFailure(track.videoId) } + } + ) + ) { + Button("OK", role: .cancel) {} + } message: { + Text("The download for “\(track.title)” could not be saved.") + } + .accessibilityLabel(labelDescription) + } + + @ViewBuilder private var label: some View { + switch state { + case .running(let progress): + ZStack { + Circle() + .stroke(Color.secondary.opacity(0.25), lineWidth: 2) + Circle() + .trim(from: 0, to: max(progress, 0.02)) + .stroke(Color.carmen, style: StrokeStyle(lineWidth: 2, lineCap: .round)) + .rotationEffect(.degrees(-90)) + if progress > 0.05 { + Text("\(Int((progress * 100).rounded()))") + .font(.system(size: diameter * 0.4, weight: .semibold, design: .rounded)) + .monospacedDigit() + .foregroundStyle(Color.carmen) + } + } + .frame(width: diameter, height: diameter) + case .done: + Image(systemName: "checkmark.circle.fill") + .font(.system(size: diameter)) + .foregroundStyle(Color.carmen) + case nil: + Image(systemName: "arrow.down.circle") + .font(.system(size: diameter + 2)) + .foregroundStyle(Color.secondary) + } + } + + private var labelDescription: String { + switch state { + case .running: return "Downloading" + case .done: return "Downloaded — tap to remove" + case nil: return downloads.isDownloaded(track.videoId) ? "Downloaded" : "Download" + } + } +} + +/// Context-menu rows for a track's download state (used inside `Menu`s and +/// sheet menus alike). +struct DownloadMenuItems: View { + let track: Track + + @Environment(DownloadsStore.self) private var downloads + + var body: some View { + if downloads.isDownloaded(track.videoId) { + Button(role: .destructive) { + downloads.remove(videoId: track.videoId) + } label: { + Label("Remove Download", systemImage: "trash") + } + } else { + Button { + downloads.download(track) + } label: { + Label("Download", systemImage: "arrow.down.circle") + } + } + } +} + +/// Sub-menu of the current local playlists + "New Playlist…". +struct PlaylistPickerMenu: View { + let track: Track + + @Environment(LibraryStore.self) private var library + @State private var showingNewPlaylist = false + @State private var newName = "" + + var body: some View { + Menu { + if library.playlists.isEmpty { + Text("No playlists yet") + } else { + ForEach(library.playlists) { playlist in + Button { + library.toggleTrack(track, inPlaylist: playlist.id) + } label: { + if library.isSaved(inPlaylist: playlist.id, videoId: track.videoId) { + Label(playlist.name, systemImage: "checkmark") + } else { + Text(playlist.name) + } + } + } + } + Divider() + Button { + showingNewPlaylist = true + } label: { + Label("New Playlist…", systemImage: "plus") + } + } label: { + Label("Add to Playlist…", systemImage: "music.note.list") + } + .alert("New Playlist", isPresented: $showingNewPlaylist) { + TextField("Name", text: $newName) + Button("Create") { + let name = newName.trimmingCharacters(in: .whitespacesAndNewlines) + guard !name.isEmpty else { return } + let created = library.createPlaylist(named: name) + library.toggleTrack(track, inPlaylist: created.id) + newName = "" + } + Button("Cancel", role: .cancel) { newName = "" } + } + } +} diff --git a/ios/Carmen/Views/Components/Artwork.swift b/ios/Carmen/Views/Components/Artwork.swift new file mode 100644 index 0000000..2eba767 --- /dev/null +++ b/ios/Carmen/Views/Components/Artwork.swift @@ -0,0 +1,84 @@ +// +// Artwork.swift +// Carmen — square artwork + the explicit marker. +// +// Moved verbatim from the old shared Components.swift: the JPEG-first +// fallback chain, the network-generation re-key and the neutral placeholder +// are behavior, not style — they stay exactly as they were. +// + +import SwiftUI + +/// Square artwork with a JPEG-first fallback chain and a neutral placeholder — +/// never a broken image. +struct ArtworkView: View { + let url: String? + var fallbackURL: String? + var cornerRadius: CGFloat = 6 + var placeholderSymbol: String = "music.note" + + var body: some View { + Group { + if let urlString = url, let url = URL(string: urlString) { + AsyncImage(url: url, transaction: Transaction(animation: .easeInOut(duration: 0.15))) { phase in + switch phase { + case .success(let image): + image.resizable().scaledToFill() + case .failure: + fallbackOrPlaceholder + case .empty: + placeholder + @unknown default: + placeholder + } + } + } else { + fallbackOrPlaceholder + } + } + // A failed (offline) load is remembered by the AsyncImage forever — + // re-key it whenever connectivity flips so covers reload themselves. + .id(NetworkMonitor.shared.generation) + .frame(minWidth: 0, maxWidth: .infinity, minHeight: 0, maxHeight: .infinity) + .clipped() + .clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)) + .overlay( + RoundedRectangle(cornerRadius: cornerRadius, style: .continuous) + .strokeBorder(.primary.opacity(0.06), lineWidth: 0.5) + ) + } + + @ViewBuilder private var fallbackOrPlaceholder: some View { + if let fallbackURL, fallbackURL != url, let url = URL(string: fallbackURL) { + AsyncImage(url: url) { phase in + if case .success(let image) = phase { + image.resizable().scaledToFill() + } else { + placeholder + } + } + } else { + placeholder + } + } + + private var placeholder: some View { + ZStack { + Rectangle().fill(Color(.secondarySystemFill)) + Image(systemName: placeholderSymbol) + .font(.system(size: 17, weight: .semibold)) + .foregroundStyle(.secondary) + } + } +} + +/// The "E" marker next to explicit titles. +struct ExplicitBadge: View { + var body: some View { + Text("E") + .font(.system(size: 9, weight: .bold)) + .foregroundStyle(.secondary) + .frame(width: 13, height: 13) + .background(Color(.secondarySystemFill), in: RoundedRectangle(cornerRadius: 3, style: .continuous)) + } +} diff --git a/ios/Carmen/Views/Components/Buttons.swift b/ios/Carmen/Views/Components/Buttons.swift new file mode 100644 index 0000000..2e4ed7a --- /dev/null +++ b/ios/Carmen/Views/Components/Buttons.swift @@ -0,0 +1,138 @@ +// +// Buttons.swift +// Carmen — Material 3 button styles. +// +// M3 spec: filled / tonal / text buttons are 40pt-tall capsules with a 24pt +// horizontal inset; icon buttons are 40pt circles with a 14% primary ripple +// when pressed. Filled defaults to the scheme's primary, but every style +// takes an optional `fill` so screens can paint the *live accent* (the +// artwork-derived Color.carmen) instead — Android lets its seed flow into +// every filled surface, this is the iOS equivalent. +// + +import SwiftUI + +// MARK: - Filled (primary) — the "Play" button, primary actions + +struct M3FilledStyle: ButtonStyle { + @Environment(\.m3) private var m3 + + /// nil → scheme primary; pass `.carmen` to follow the live accent. + var fill: Color? = nil + var foreground: Color? = nil + var compact: Bool = false + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .font(.m3LabelLarge) + .padding(.horizontal, compact ? 14 : 24) + .frame(minHeight: 40) + .frame(maxWidth: .infinity) + .background(fill ?? m3.primary, in: M3Shape.capsule) + .foregroundStyle(foreground ?? m3.onPrimary) + .opacity(configuration.isPressed ? 0.86 : 1) + .scaleEffect(configuration.isPressed ? 0.97 : 1) + .animation(.spring(response: 0.25, dampingFraction: 0.7), value: configuration.isPressed) + } +} + +// MARK: - Tonal (secondary container) — the second-choice action + +struct M3TonalStyle: ButtonStyle { + @Environment(\.m3) private var m3 + + var fill: Color? = nil + var foreground: Color? = nil + var compact: Bool = false + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .font(.m3LabelLarge) + .padding(.horizontal, compact ? 14 : 24) + .frame(minHeight: 40) + .frame(maxWidth: .infinity) + .background(fill ?? m3.secondaryContainer, in: M3Shape.capsule) + .foregroundStyle(foreground ?? m3.onSecondaryContainer) + .opacity(configuration.isPressed ? 0.86 : 1) + .scaleEffect(configuration.isPressed ? 0.97 : 1) + .animation(.spring(response: 0.25, dampingFraction: 0.7), value: configuration.isPressed) + } +} + +// MARK: - Text — quiet actions ("Play all", "See all") + +struct M3TextStyle: ButtonStyle { + @Environment(\.m3) private var m3 + + var fill: Color? = nil + var compact: Bool = false + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .font(.m3LabelLarge) + .padding(.horizontal, compact ? 8 : 12) + .frame(minHeight: 40) + .background( + configuration.isPressed ? (fill ?? m3.primary).opacity(0.14) : .clear, + in: M3Shape.capsule + ) + .foregroundStyle(fill ?? m3.primary) + .scaleEffect(configuration.isPressed ? 0.97 : 1) + .animation(.spring(response: 0.25, dampingFraction: 0.7), value: configuration.isPressed) + } +} + +// MARK: - Icon — 40pt circle (kebab, close, transport) + +struct M3IconButtonStyle: ButtonStyle { + @Environment(\.m3) private var m3 + + var fill: Color? = nil + var foreground: Color? = nil + + func makeBody(configuration: Configuration) -> some View { + configuration.label + .frame(width: 40, height: 40) + .background( + configuration.isPressed ? (fill ?? m3.primary).opacity(0.14) : .clear, + in: Circle() + ) + .foregroundStyle(foreground ?? m3.onSurfaceVariant) + .scaleEffect(configuration.isPressed ? 0.92 : 1) + .animation(.spring(response: 0.24, dampingFraction: 0.65), value: configuration.isPressed) + } +} + +// MARK: - Floating action button (Android's FAB / extended FAB) + +/// 56pt filled FAB. Pass `title` for the extended variant ("Shuffle"). +struct M3Fab: View { + var systemImage: String = "shuffle" + var title: String? = nil + var fill: Color? = nil + var foreground: Color? = nil + var action: () -> Void + + @Environment(\.m3) private var m3 + + var body: some View { + Button(action: action) { + HStack(spacing: 12) { + Image(systemName: systemImage) + .font(.system(size: 20, weight: .semibold)) + if let title { + Text(title) + .font(.m3LabelLarge) + } + } + .foregroundStyle(foreground ?? m3.onPrimary) + .padding(.horizontal, title == nil ? 16 : 20) + .frame(height: M3Dim.fab) + .frame(minWidth: title == nil ? M3Dim.fab : nil) + .background(fill ?? m3.primary, in: RoundedRectangle(cornerRadius: 16, style: .continuous)) + .shadow(color: m3.scrim.opacity(0.28), radius: 6, y: 3) + } + .buttonStyle(.plain) + .pressScale(0.95) + } +} diff --git a/ios/Carmen/Views/Components/Chips.swift b/ios/Carmen/Views/Components/Chips.swift new file mode 100644 index 0000000..3fa9743 --- /dev/null +++ b/ios/Carmen/Views/Components/Chips.swift @@ -0,0 +1,90 @@ +// +// Chips.swift +// Carmen — Material 3 chips. +// +// Android's search/library filter chips: 32pt capsules, outline at rest, +// secondary-container fill + checkmark when selected, expressive spring +// press-scale. +// + +import SwiftUI + +/// Selectable filter chip (search filters, sort options, home categories). +struct FilterChip: View { + var isSelected: Bool + var title: String + var systemImage: String? = nil + var action: () -> Void + + @Environment(\.m3) private var m3 + + var body: some View { + Button(action: action) { + HStack(spacing: 6) { + if isSelected { + Image(systemName: "checkmark") + .font(.system(size: 14, weight: .semibold)) + } + if let systemImage { + Image(systemName: systemImage) + .font(.system(size: 14, weight: .medium)) + } + Text(title) + .font(.m3LabelLarge) + .lineLimit(1) + } + .padding(.horizontal, 12) + .frame(height: M3Dim.chipHeight) + .foregroundStyle(isSelected ? m3.onSecondaryContainer : m3.onSurfaceVariant) + .background( + isSelected ? m3.secondaryContainer : Color.clear, + in: M3Shape.capsule + ) + .overlay { + M3Shape.capsule + .strokeBorder(isSelected ? .clear : m3.outlineVariant, lineWidth: 1) + } + } + .buttonStyle(.plain) + .pressScale() + } +} + +/// Static pill used for grouping/labels (library filter tags, category rows) +/// — same silhouette as FilterChip, no selection state. +struct TagChip: View { + var title: String + var systemImage: String? = nil + var tint: Color? + var action: (() -> Void)? = nil + + @Environment(\.m3) private var m3 + + var body: some View { + let content = HStack(spacing: 6) { + if let systemImage { + Image(systemName: systemImage) + .font(.system(size: 14, weight: .medium)) + } + Text(title) + .font(.m3LabelLarge) + .lineLimit(1) + } + .padding(.horizontal, 12) + .frame(height: M3Dim.chipHeight) + .foregroundStyle(tint ?? m3.onSurfaceVariant) + .background((tint ?? m3.surfaceContainerHighest).opacity(tint == nil ? 1 : 0.16), in: M3Shape.capsule) + .overlay { + M3Shape.capsule + .strokeBorder(tint == nil ? m3.outlineVariant : .clear, lineWidth: 1) + } + + if let action { + Button(action: action) { content } + .buttonStyle(.plain) + .pressScale() + } else { + content + } + } +} diff --git a/ios/Carmen/Views/Components/Chrome.swift b/ios/Carmen/Views/Components/Chrome.swift new file mode 100644 index 0000000..ff75c73 --- /dev/null +++ b/ios/Carmen/Views/Components/Chrome.swift @@ -0,0 +1,237 @@ +// +// Chrome.swift +// Carmen — Material app chrome: the floating top app bar, the search +// field, and the bottom-sheet menu Android uses everywhere instead of +// context menus. +// + +import SwiftUI + +// MARK: - Top app bar + +/// Transparent-feeling floating header: bold title, optional trailing +/// actions, optional overflow (⋮). Screens place it with +/// `.safeAreaInset(edge: .top)` so scroll content stops beneath it. +struct TopAppBar: View { + var title: String + /// Android's LargeFlexibleTopAppBar variant (Settings, detail heroes). + var large: Bool = false + var showOverflow: Bool = true + var onOverflow: () -> Void = {} + + private let trailingContent: AnyView + + @Environment(\.m3) private var m3 + + init( + title: String, + large: Bool = false, + showOverflow: Bool = true, + onOverflow: @escaping () -> Void = {}, + @ViewBuilder trailing: () -> Trailing = { EmptyView() } + ) { + self.title = title + self.large = large + self.showOverflow = showOverflow + self.onOverflow = onOverflow + self.trailingContent = AnyView(trailing()) + } + + var body: some View { + HStack(spacing: 8) { + VStack(alignment: .leading, spacing: 0) { + Text(title) + .font(large ? .m3HeadlineMedium : .m3TitleLarge) + .foregroundStyle(m3.onSurface) + .lineLimit(1) + .minimumScaleFactor(0.8) + } + Spacer(minLength: 8) + trailingContent + if showOverflow { + Button(action: onOverflow) { + Image(systemName: "ellipsis") + .font(.system(size: 20, weight: .semibold)) + } + .buttonStyle(M3IconButtonStyle()) + .accessibilityLabel("More options") + } + } + .padding(.horizontal, 16) + .padding(.vertical, large ? 16 : 8) + .frame(minHeight: large ? 104 : M3Dim.appBar, alignment: .leading) + .frame(maxWidth: .infinity, alignment: .leading) + .background(m3.background) + } +} + +// MARK: - Search + +/// The idle-state fake field (Android's non-editing search bar): tapping +/// anywhere pushes the results screen. +struct M3SearchDock: View { + var placeholder: String = "Search songs, albums, artists…" + var action: () -> Void + + @Environment(\.m3) private var m3 + + var body: some View { + Button(action: action) { + HStack(spacing: 10) { + Image(systemName: "magnifyingglass") + .font(.system(size: 17, weight: .medium)) + .foregroundStyle(m3.onSurfaceVariant) + Text(placeholder) + .font(.m3BodyLarge) + .foregroundStyle(m3.onSurfaceVariant) + .lineLimit(1) + Spacer(minLength: 0) + } + .padding(.horizontal, 16) + .frame(height: 52) + .background(m3.surfaceContainerHigh, in: M3Shape.pill) + } + .buttonStyle(.plain) + .pressScale(0.99) + } +} + +/// Editable Material search field (results header): leading magnifier, +/// clear button, 28pt pill. +struct M3SearchField: View { + @Binding var text: String + var placeholder: String = "Search" + var autoFocus: Bool = false + var onSubmit: () -> Void = {} + + @FocusState private var focused: Bool + @Environment(\.m3) private var m3 + + var body: some View { + HStack(spacing: 10) { + Image(systemName: "magnifyingglass") + .font(.system(size: 17, weight: .medium)) + .foregroundStyle(m3.onSurfaceVariant) + + TextField(placeholder, text: $text) + .font(.m3BodyLarge) + .foregroundStyle(m3.onSurface) + .tint(m3.primary) + .submitLabel(.search) + .focused($focused) + .onSubmit(onSubmit) + + if !text.isEmpty { + Button { + text = "" + } label: { + Image(systemName: "xmark.circle.fill") + .font(.system(size: 17)) + .foregroundStyle(m3.onSurfaceVariant) + } + .buttonStyle(.plain) + .accessibilityLabel("Clear") + } + } + .padding(.horizontal, 16) + .frame(height: 52) + .background(m3.surfaceContainerHigh, in: M3Shape.pill) + .onAppear { + if autoFocus { focused = true } + } + } +} + +// MARK: - Bottom-sheet menu + +extension View { + /// Presents `content` as Android's BottomSheetMenu: a grabber-topped + /// medium sheet with the title as header. The content is wrapped in its + /// own NavigationStack + `carmenDestinations()` so `NavigationLink`s + /// inside (Go to Album, Go to Artist) push a working screen. + func bottomSheetMenu( + isPresented: Binding, + title: String? = nil, + @ViewBuilder content: @escaping () -> Content + ) -> some View { + sheet(isPresented: isPresented) { + SheetMenuView(title: title, content: content) + .presentationDetents([.medium, .large]) + .presentationGrabber(true) + .presentationCornerRadius(28) + } + } +} + +private struct SheetMenuView: View { + var title: String? + let content: () -> Content + + @Environment(\.m3) private var m3 + + var body: some View { + NavigationStack { + ScrollView { + VStack(alignment: .leading, spacing: 0) { + if let title { + Text(title) + .font(.m3TitleMedium) + .foregroundStyle(m3.onSurfaceVariant) + .lineLimit(2) + .padding(.horizontal, 20) + .padding(.top, 10) + .padding(.bottom, 6) + } + content() + .padding(.bottom, 24) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + .background(m3.background) + .carmenDestinations() + } + } +} + +/// Display-only menu row — also the label for `NavigationLink`/`ShareLink` +/// rows inside a sheet. +struct SheetMenuRowLabel: View { + var title: String + var systemImage: String + /// `m3.error` for destructive rows. + var tint: Color? = nil + + @Environment(\.m3) private var m3 + + var body: some View { + HStack(spacing: 16) { + Image(systemName: systemImage) + .font(.system(size: 18, weight: .regular)) + .frame(width: 28) + Text(title) + .font(.m3BodyLarge) + .lineLimit(1) + .minimumScaleFactor(0.8) + Spacer(minLength: 0) + } + .foregroundStyle(tint ?? m3.onSurface) + .padding(.horizontal, 20) + .frame(height: 56) + .contentShape(Rectangle()) + } +} + +/// Tappable menu row (56pt, icon + title) used to build sheet menus. +struct SheetMenuRow: View { + var title: String + var systemImage: String + var tint: Color? = nil + var action: () -> Void + + var body: some View { + Button(action: action) { + SheetMenuRowLabel(title: title, systemImage: systemImage, tint: tint) + } + .buttonStyle(.plain) + } +} diff --git a/ios/Carmen/Views/Components/Rows.swift b/ios/Carmen/Views/Components/Rows.swift new file mode 100644 index 0000000..dc75ba6 --- /dev/null +++ b/ios/Carmen/Views/Components/Rows.swift @@ -0,0 +1,424 @@ +// +// Rows.swift +// Carmen — the Material list/grid vocabulary. +// +// Android's `Items.kt` equivalents: ListRow is the 72pt list item (56pt +// rounded-square thumb, two text lines, trailing actions), GridTile is the +// 128pt grid card (bold title, secondary subtitle, optional play overlay, +// circular variant for artists), SectionHeader carries the "Play all" +// action, and PlayingIndicator is the animated bars badge. +// + +import SwiftUI + +// MARK: - Now-playing bars + +/// Four animated bars shown on the currently playing row. Pauses with the +/// player; renders static when playback is stopped. +struct PlayingIndicator: View { + var tint: Color? + + @Environment(Player.self) private var player + @Environment(\.m3) private var m3 + + var body: some View { + TimelineView(.animation(minimumInterval: 1.0 / 30.0)) { context in + let time = context.date.timeIntervalSinceReferenceDate + HStack(spacing: 2.5) { + ForEach(0..<4, id: \.self) { index in + let wave = (sin(time * 6.5 + Double(index) * 1.2) + 1) / 2 + let height: CGFloat = player.isPlaying ? 5 + CGFloat(wave) * 11 : 5 + Capsule() + .fill(tint ?? m3.primary) + .frame(width: 3, height: height) + } + } + } + .frame(width: 22, height: 20) + .accessibilityLabel("Now playing") + } +} + +// MARK: - List row + +/// One row of a song list — Android's `ListItem` (72pt / 56pt thumb / r10). +/// Tapping runs `onTap` (screens decide the play context); the kebab opens +/// the Material bottom-sheet menu, and the trailing download control is the +/// same store-driven ring as before. +struct ListRow: View { + var track: Track + /// Marks the row as the current track (secondary-container wash + bars). + var isPlaying: Bool = false + /// Index shown instead of artwork when `showArtwork` is false. + var index: Int? = nil + var showArtwork: Bool = true + var showDuration: Bool = true + var showDownload: Bool = true + var showMenu: Bool = true + var onTap: (() -> Void)? = nil + + @Environment(\.m3) private var m3 + @State private var showSheet = false + + var body: some View { + HStack(spacing: 12) { + leading + .frame(width: M3Dim.listThumb, height: M3Dim.listThumb) + + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 6) { + Text(track.title) + .font(.m3BodyLarge.weight(.medium)) + .lineLimit(1) + .foregroundStyle(isPlaying ? m3.primary : m3.onSurface) + if track.isExplicit { ExplicitBadge() } + } + Text(track.artistLine) + .font(.m3BodySmall) + .foregroundStyle(m3.onSurfaceVariant) + .lineLimit(1) + } + .frame(maxWidth: .infinity, alignment: .leading) + + if showDuration, let duration = track.durationLabel, !duration.isEmpty { + Text(duration) + .font(.m3BodySmall) + .monospacedDigit() + .foregroundStyle(m3.onSurfaceVariant) + } + + if showDownload { + DownloadButton(track: track, diameter: 20) + } + + if showMenu { + Button { + showSheet = true + } label: { + Image(systemName: "ellipsis") + .font(.system(size: 17, weight: .semibold)) + .frame(width: 32, height: 44) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .foregroundStyle(m3.onSurfaceVariant) + .accessibilityLabel("More options") + } + } + .padding(.horizontal, 16) + .frame(height: M3Dim.listHeight) + .background( + isPlaying ? m3.secondaryContainer.opacity(0.55) : Color.clear, + in: M3Shape.card + ) + .contentShape(Rectangle()) + .onTapGesture { onTap?() } + .bottomSheetMenu(isPresented: $showSheet, title: track.title) { + TrackSheetMenu(track: track) + } + } + + @ViewBuilder private var leading: some View { + if showArtwork { + ZStack { + ArtworkView(url: track.artwork, fallbackURL: track.artworkAlt, cornerRadius: 10) + if isPlaying { + Rectangle() + .fill(m3.scrim.opacity(0.45)) + PlayingIndicator() + } + } + } else if let index { + Text("\(index)") + .font(.m3BodyLarge) + .foregroundStyle(m3.onSurfaceVariant) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } + } +} + +// MARK: - Bottom-sheet track menu + +/// The song menu Android shows in its BottomSheetMenu — same entries as the +/// old context menu, now Material rows. Presented inside a sheet's own +/// NavigationStack, so "Go to …" pushes work from wherever it opens. +struct TrackSheetMenu: View { + var track: Track + + @Environment(Player.self) private var player + @Environment(LibraryStore.self) private var library + + var body: some View { + SheetMenuRow(title: "Play Next", systemImage: "text.insert") { + player.playNext(track) + } + SheetMenuRow(title: "Add to Queue", systemImage: "list.bullet.plus") { + player.enqueue(track) + } + SheetMenuRow( + title: library.isFavorite(track.videoId) ? "Remove Favorite" : "Favorite", + systemImage: library.isFavorite(track.videoId) ? "heart.slash" : "heart" + ) { + library.toggleFavorite(track) + } + SheetMenuRow(title: "Play Radio", systemImage: "dot.radiowaves.left.and.right") { + player.playStation(seed: track) + } + // The picker is itself a Menu — nested exactly like the old + // context-menu did, so it becomes a submenu of this sheet. + Menu { + PlaylistPickerMenu(track: track) + } label: { + SheetMenuRowLabel(title: "Add to Playlist…", systemImage: "music.note.list") + } + Divider() + DownloadMenuItems(track: track) + if let albumId = track.album?.id, !albumId.isEmpty { + NavigationLink(value: Route.album(browseId: albumId, title: track.albumName)) { + SheetMenuRowLabel(title: "Go to Album", systemImage: "square.stack") + } + } + if let artistId = track.artists.first(where: { !($0.id ?? "").isEmpty })?.id { + NavigationLink(value: Route.artist(channelId: artistId, title: track.artists.first?.name ?? "")) { + SheetMenuRowLabel(title: "Go to Artist", systemImage: "person.crop.circle") + } + } + ShareLink(item: "https://carmen.z44d.com/?song=\(track.videoId)") { + SheetMenuRowLabel(title: "Share", systemImage: "square.and.arrow.up") + } + } +} + +// MARK: - Grid tile + +/// Android's `GridItem`: 128pt thumbnail (r8, or a circle for artists), bold +/// two-line title, secondary subtitle, optional play overlay. The card and +/// track conveniences carry the old CardTile routing (play in context / +/// push route / start station) so screens just drop them in a LazyVGrid. +struct GridTile: View { + enum Source { + case plain + case card(MediaCard, siblings: [MediaCard]) + case track(Track, context: [Track]) + } + + var title: String = "" + var subtitle: String = "" + var artwork: String? = nil + var artworkAlt: String? = nil + var isCircular: Bool = false + var showPlayOverlay: Bool = false + var isExplicit: Bool = false + var width: CGFloat = M3Dim.gridThumb + var source: Source = .plain + var action: () -> Void = {} + + @Environment(\.m3) private var m3 + @Environment(Player.self) private var player + + /// Display-only tile (categories, playlists built by hand). + init( + title: String, + subtitle: String, + artwork: String? = nil, + artworkAlt: String? = nil, + isCircular: Bool = false, + showPlayOverlay: Bool = false, + isExplicit: Bool = false, + width: CGFloat = M3Dim.gridThumb, + action: @escaping () -> Void = {} + ) { + self.title = title + self.subtitle = subtitle + self.artwork = artwork + self.artworkAlt = artworkAlt + self.isCircular = isCircular + self.showPlayOverlay = showPlayOverlay + self.isExplicit = isExplicit + self.width = width + self.source = .plain + self.action = action + } + + /// Feed tile: songs play in their row context, albums/artists/playlists + /// push their screen, stations start a radio queue (old CardTile rules). + init(card: MediaCard, siblings: [MediaCard] = [], width: CGFloat = M3Dim.gridThumb) { + self.title = card.title + self.subtitle = card.subtitle + self.artwork = card.artwork + self.artworkAlt = card.artworkAlt + self.isCircular = card.kind == .artist + self.isExplicit = card.isExplicit && card.kind == .song + self.width = width + self.source = .card(card, siblings: siblings) + self.action = {} + } + + /// A song tile that plays inside its context. + init(track: Track, context: [Track] = [], showPlayOverlay: Bool = true, width: CGFloat = M3Dim.gridThumb) { + self.title = track.title + self.subtitle = track.artistLine + self.artwork = track.artwork + self.artworkAlt = track.artworkAlt + self.showPlayOverlay = showPlayOverlay + self.isExplicit = track.isExplicit + self.width = width + self.source = .track(track, context: context) + self.action = {} + } + + var body: some View { + switch source { + case .plain: + Button(action: action) { content } + .buttonStyle(.plain) + case .card(let card, let siblings): + cardRouting(card, siblings: siblings) + case .track(let track, let context): + Button { + var queue = context + if queue.isEmpty { queue = [track] } + let startAt = queue.firstIndex { $0.videoId == track.videoId } ?? 0 + player.play(queue, startAt: startAt) + } label: { content } + .buttonStyle(.plain) + } + } + + @ViewBuilder private func cardRouting(_ card: MediaCard, siblings: [MediaCard]) -> some View { + if let track = card.track, card.kind == .song { + Button { + var context = playableTracks(siblings) + if context.isEmpty { context = [track] } + let startAt = context.firstIndex { $0.videoId == track.videoId } ?? 0 + player.play(context, startAt: startAt) + } label: { content } + .buttonStyle(.plain) + } else if let route = Route(card: card) { + NavigationLink(value: route) { content } + .buttonStyle(.plain) + } else { + Button { + if card.kind == .station, !card.targetId.isEmpty { + player.playWatch(playlistId: card.targetId) + } + } label: { content } + .buttonStyle(.plain) + } + } + + /// Kind-aware placeholder (old CardTile's rule: artists show a person + /// square, playlists/stations a list, albums a stack). + private var placeholderSymbol: String { + if case .card(let card, _) = source { + switch card.kind { + case .artist: return "person.crop.square" + case .playlist, .station: return "music.note.list" + case .album: return "square.stack" + case .song, .unsupported: return "music.note" + } + } + return isCircular ? "person.crop.square" : "music.note" + } + + private var content: some View { + VStack(alignment: .leading, spacing: 6) { + ZStack(alignment: .bottomTrailing) { + ArtworkView( + url: artwork, + fallbackURL: artworkAlt, + cornerRadius: isCircular ? width / 2 : 8, + placeholderSymbol: placeholderSymbol + ) + .frame(width: width, height: width) + // ArtworkView already rounds its own corners; this extra pass + // is only here to force a true circle for the artist variant + // (radius = half the side reads as a squircle otherwise). + .clipShape(isCircular ? AnyShape(Circle()) : AnyShape(Rectangle())) + + if showPlayOverlay { + Image(systemName: "play.fill") + .font(.system(size: 15, weight: .bold)) + .foregroundStyle(m3.onPrimary) + .frame(width: 34, height: 34) + .background(m3.primary.opacity(0.92), in: Circle()) + .padding(8) + .shadow(color: m3.scrim.opacity(0.3), radius: 4, y: 2) + } + } + + HStack(alignment: .top, spacing: 4) { + Text(title) + .font(.m3BodyMedium.weight(.semibold)) + .foregroundStyle(m3.onSurface) + .lineLimit(2) + .multilineTextAlignment(.leading) + .frame(maxWidth: .infinity, alignment: .leading) + if isExplicit { ExplicitBadge() } + } + if !subtitle.isEmpty { + Text(subtitle) + .font(.m3BodySmall) + .foregroundStyle(m3.onSurfaceVariant) + .lineLimit(1) + } + } + .frame(width: width, alignment: .leading) + .contentShape(Rectangle()) + } +} + +// MARK: - Section header + +/// Section title with an optional trailing action ("Play all") and kebab +/// menu. The `SectionHeader(title:)` call shape of the old shared component +/// still compiles — everything else defaulted. +struct SectionHeader: View { + var title: String + var actionTitle: String? = nil + var action: (() -> Void)? = nil + private var menuContent: AnyView? = nil + + @Environment(\.m3) private var m3 + + init(title: String, actionTitle: String? = nil, action: (() -> Void)? = nil) { + self.title = title + self.actionTitle = actionTitle + self.action = action + self.menuContent = nil + } + + init(title: String, menuItems: @ViewBuilder () -> Menu) { + self.title = title + self.actionTitle = nil + self.action = nil + self.menuContent = AnyView(menuItems()) + } + + var body: some View { + HStack(spacing: 4) { + Text(title) + .font(.m3TitleLarge) + .foregroundStyle(m3.onSurface) + .lineLimit(1) + Spacer(minLength: 8) + if let action, let actionTitle { + Button(actionTitle, action: action) + .buttonStyle(M3TextStyle(compact: true)) + } + if let menuContent { + Menu { + menuContent + } label: { + Image(systemName: "ellipsis") + .font(.system(size: 17, weight: .semibold)) + .frame(width: 36, height: 36) + } + .labelStyle(.iconOnly) + .foregroundStyle(m3.onSurfaceVariant) + } + } + .padding(.horizontal, 16) + .padding(.vertical, 6) + } +} diff --git a/ios/Carmen/Views/Components/Surfaces.swift b/ios/Carmen/Views/Components/Surfaces.swift new file mode 100644 index 0000000..fc12a09 --- /dev/null +++ b/ios/Carmen/Views/Components/Surfaces.swift @@ -0,0 +1,240 @@ +// +// Surfaces.swift +// Carmen — Material cards, shimmer skeletons and the loading/error/empty +// panes every screen composes its states from. +// + +import SwiftUI + +// MARK: - Card + +/// Material card: surface-container fill, 12dp radius, optional tap. +/// Content provides its own padding so dense rows can opt out. +struct M3Card: View { + var padding: CGFloat = 16 + var fill: Color? = nil + var action: (() -> Void)? = nil + private let content: () -> Content + + @Environment(\.m3) private var m3 + + init( + padding: CGFloat = 16, + fill: Color? = nil, + action: (() -> Void)? = nil, + @ViewBuilder content: @escaping () -> Content + ) { + self.padding = padding + self.fill = fill + self.action = action + self.content = content + } + + var body: some View { + Group { + if let action { + Button(action: action) { inner } + .buttonStyle(.plain) + } else { + inner + } + } + } + + private var inner: some View { + content() + .padding(padding) + .frame(maxWidth: .infinity, alignment: .leading) + .background(fill ?? m3.surfaceContainer, in: M3Shape.card) + .contentShape(M3Shape.card) + } +} + +// MARK: - Shimmer + +/// The Android shimmer sweep — a soft highlight crossing the placeholder. +private struct ShimmerModifier: ViewModifier { + @State private var move = false + + func body(content: Content) -> some View { + content + .overlay { + GeometryReader { proxy in + let width = max(proxy.size.width, 1) + LinearGradient( + colors: [.clear, Color.white.opacity(0.38), .clear], + startPoint: .leading, + endPoint: .trailing + ) + .frame(width: width * 0.55) + .offset(x: move ? width * 1.1 : -width * 0.6) + } + } + .mask { Rectangle() } + .onAppear { + withAnimation(.linear(duration: 1.1).repeatForever(autoreverses: false)) { + move = true + } + } + } +} + +extension View { + /// Adds the sweeping shimmer to any placeholder shape. + func shimmering() -> some View { modifier(ShimmerModifier()) } +} + +/// A single shimmering placeholder block. +struct ShimmerBlock: View { + var height: CGFloat = 12 + var radius: CGFloat = 6 + + @Environment(\.m3) private var m3 + + var body: some View { + RoundedRectangle(cornerRadius: radius, style: .continuous) + .fill(m3.surfaceContainerHighest) + .frame(height: height) + .shimmering() + } +} + +/// Skeleton shaped like a run of `ListRow`s — shown while a list loads. +struct ShimmerList: View { + var rows: Int = 6 + + @Environment(\.m3) private var m3 + + var body: some View { + VStack(spacing: 0) { + ForEach(0.. Void)? = nil + + @Environment(\.m3) private var m3 + + var body: some View { + VStack(spacing: 14) { + ZStack { + Circle() + .fill(m3.error.opacity(0.12)) + .frame(width: 64, height: 64) + Image(systemName: "wifi.exclamationmark") + .font(.system(size: 26, weight: .medium)) + .foregroundStyle(m3.error) + } + Text("Something went wrong") + .font(.m3TitleMedium) + .foregroundStyle(m3.onSurface) + Text(message) + .font(.m3BodyMedium) + .foregroundStyle(m3.onSurfaceVariant) + .multilineTextAlignment(.center) + .padding(.horizontal, 40) + if let retry { + Button("Try Again", action: retry) + .buttonStyle(M3FilledStyle(compact: true)) + } + } + .frame(maxWidth: .infinity) + .padding(.vertical, 48) + } +} + +/// Empty pane with an icon badge (replaces the old `EmptyState`). +struct M3EmptyState: View { + var title: String + var hint: String? = nil + var systemImage: String = "tray" + + @Environment(\.m3) private var m3 + + var body: some View { + VStack(spacing: 14) { + ZStack { + Circle() + .fill(m3.primaryContainer) + .frame(width: 64, height: 64) + Image(systemName: systemImage) + .font(.system(size: 26, weight: .medium)) + .foregroundStyle(m3.onPrimaryContainer) + } + Text(title) + .font(.m3TitleMedium) + .foregroundStyle(m3.onSurface) + .multilineTextAlignment(.center) + if let hint { + Text(hint) + .font(.m3BodyMedium) + .foregroundStyle(m3.onSurfaceVariant) + .multilineTextAlignment(.center) + .padding(.horizontal, 40) + } + } + .frame(maxWidth: .infinity) + .padding(.vertical, 48) + } +} From 64a9b1ab57afe8e247428cd73838cf42bed2fff8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Za=C2=A1d?= Date: Sat, 24 Oct 2026 17:49:17 +0300 Subject: [PATCH 3/9] refactor(ui): move navigation contract to Theme/Navigation - Route/CarmenDestinations/carmenDestinations/playableTracks move verbatim - new Route.searchResults(query:) + destination -> SearchResultsView - Components.swift header notes it is legacy-only now --- ios/Carmen/Views/Components.swift | 52 ++-------------------- ios/Carmen/Views/Theme/Navigation.swift | 58 +++++++++++++++++++++++++ 2 files changed, 62 insertions(+), 48 deletions(-) create mode 100644 ios/Carmen/Views/Theme/Navigation.swift diff --git a/ios/Carmen/Views/Components.swift b/ios/Carmen/Views/Components.swift index 35ae864..e868320 100644 --- a/ios/Carmen/Views/Components.swift +++ b/ios/Carmen/Views/Components.swift @@ -1,57 +1,13 @@ // // Components.swift -// Carmen — the shared building blocks every screen composes from: -// navigation routes, artwork, track rows, feed tiles and state views. +// Carmen — legacy shared blocks still used by the pre-Material screens +// (state views, track rows, feed tiles). Every declaration here dies with +// its last consumer during the Android-parity rewrite; the Material +// vocabulary lives in Views/Components/ and Views/Theme/. // import SwiftUI -// MARK: - Navigation - -/// Push destinations, built from feed/search cards. Songs and stations are -/// *played*, not pushed, so they never produce a route. -enum Route: Hashable { - case album(browseId: String, title: String) - case artist(channelId: String, title: String) - case playlist(playlistId: String, title: String) - - init?(card: MediaCard) { - guard !card.targetId.isEmpty else { return nil } - switch card.kind { - case .album: self = .album(browseId: card.targetId, title: card.title) - case .artist: self = .artist(channelId: card.targetId, title: card.title) - case .playlist: self = .playlist(playlistId: card.targetId, title: card.title) - case .song, .station, .unsupported: return nil - } - } -} - -/// The single place card taps and `NavigationLink`s resolve to screens. -/// Applied to every tab's NavigationStack via `.carmenDestinations()`. -struct CarmenDestinations: ViewModifier { - func body(content: Content) -> some View { - content.navigationDestination(for: Route.self) { route in - switch route { - case .album(let browseId, let title): - AlbumView(browseId: browseId, title: title) - case .artist(let channelId, let title): - ArtistView(channelId: channelId, title: title) - case .playlist(let playlistId, let title): - PlaylistView(playlistId: playlistId, title: title) - } - } - } -} - -extension View { - func carmenDestinations() -> some View { modifier(CarmenDestinations()) } -} - -/// Playable tracks out of a mixed card row (home sections, search results). -func playableTracks(_ cards: [MediaCard]) -> [Track] { - cards.compactMap { $0.kind == .song ? $0.track : nil } -} - // MARK: - State views struct LoadingState: View { diff --git a/ios/Carmen/Views/Theme/Navigation.swift b/ios/Carmen/Views/Theme/Navigation.swift new file mode 100644 index 0000000..27368f9 --- /dev/null +++ b/ios/Carmen/Views/Theme/Navigation.swift @@ -0,0 +1,58 @@ +// +// Navigation.swift +// Carmen — the navigation contract: routes, their destinations, and the +// card→track helper every feed row plays through. +// +// Moved verbatim from the old shared Components.swift, plus the search +// results route the Material search screen pushes. +// + +import SwiftUI + +/// Push destinations, built from feed/search cards. Songs and stations are +/// *played*, not pushed, so they never produce a route. +enum Route: Hashable { + case album(browseId: String, title: String) + case artist(channelId: String, title: String) + case playlist(playlistId: String, title: String) + /// A submitted search query — Android's `search_results/{query}` route. + case searchResults(query: String) + + init?(card: MediaCard) { + guard !card.targetId.isEmpty else { return nil } + switch card.kind { + case .album: self = .album(browseId: card.targetId, title: card.title) + case .artist: self = .artist(channelId: card.targetId, title: card.title) + case .playlist: self = .playlist(playlistId: card.targetId, title: card.title) + case .song, .station, .unsupported: return nil + } + } +} + +/// The single place card taps and `NavigationLink`s resolve to screens. +/// Applied to every tab's NavigationStack via `.carmenDestinations()`. +struct CarmenDestinations: ViewModifier { + func body(content: Content) -> some View { + content.navigationDestination(for: Route.self) { route in + switch route { + case .album(let browseId, let title): + AlbumView(browseId: browseId, title: title) + case .artist(let channelId, let title): + ArtistView(channelId: channelId, title: title) + case .playlist(let playlistId, let title): + PlaylistView(playlistId: playlistId, title: title) + case .searchResults(let query): + SearchResultsView(query: query) + } + } + } +} + +extension View { + func carmenDestinations() -> some View { modifier(CarmenDestinations()) } +} + +/// Playable tracks out of a mixed card row (home sections, search results). +func playableTracks(_ cards: [MediaCard]) -> [Track] { + cards.compactMap { $0.kind == .song ? $0.track : nil } +} From 6d179e7803a951c6958ba4fed967e98cc77103fe Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Za=C2=A1d?= Date: Sat, 24 Oct 2026 20:09:05 +0300 Subject: [PATCH 4/9] feat(ios): Material 3 rebuild of every screen MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Home, Search (+ results), Library, Album/Artist/Playlist details, the mini bar, the full player (lyrics/queue/related panels) and Settings, restyled as a faithful clone of the Android app's Material 3 UI on top of the new Theme/ kit. Data layer, playback, downloads and every setting's behavior are unchanged — this is a visual/port parity pass. --- ios/Carmen/Views/DetailViews.swift | 586 +++++++++++++---- ios/Carmen/Views/HomeView.swift | 263 ++++---- ios/Carmen/Views/LibraryView.swift | 862 +++++++++++++++++++------- ios/Carmen/Views/LyricsView.swift | 105 ++-- ios/Carmen/Views/MiniPlayer.swift | 62 +- ios/Carmen/Views/NowPlayingView.swift | 321 +++++----- ios/Carmen/Views/QueuePanel.swift | 177 ++++++ ios/Carmen/Views/RelatedPanel.swift | 62 +- ios/Carmen/Views/SearchView.swift | 833 ++++++++++++++++--------- ios/Carmen/Views/SettingsView.swift | 506 ++++++++++++--- 10 files changed, 2690 insertions(+), 1087 deletions(-) create mode 100644 ios/Carmen/Views/QueuePanel.swift diff --git a/ios/Carmen/Views/DetailViews.swift b/ios/Carmen/Views/DetailViews.swift index 257ebd8..f7bd103 100644 --- a/ios/Carmen/Views/DetailViews.swift +++ b/ios/Carmen/Views/DetailViews.swift @@ -2,104 +2,294 @@ // DetailViews.swift // Carmen — the pushed detail screens: album, artist and playlist. // -// All three share `DetailHeader` (artwork + title + play/shuffle/…) and the -// same load-in-`.task` shape: LoadingState → detail, or ErrorState with a -// retry. The big title lives in the header; the navigation bar stays inline. +// Material 3 rebuild of the old DetailHeader screens. All three share +// `DetailHero` (artwork + title + tappable artist line + metadata bullets +// + the Shuffle/Play/Add action row) and the same load-in-`.task` shape: +// ShimmerList → detail, or M3ErrorState with a retry. The native inline +// navigation bar keeps the back button; the kebab lives in the toolbar and +// the hero sits in the scroll content under a primaryContainer wash — +// the same anatomy as Android's MediaDetailHero. // import SwiftUI +import UIKit -// MARK: - Shared header +// MARK: - Shared hero -/// Big artwork, title, subtitle and the Play / Shuffle / “…” row every -/// detail screen opens with. -private struct DetailHeader: View { +/// Big artwork, title, tappable artist names, bullet-joined metadata line +/// and the Shuffle / Play / “Add” row every detail screen opens with. +/// +/// `trailing` is the per-screen icon next to Add — the header download ring +/// on the album screen, a ShareLink on playlists, nothing on artists. +private struct DetailHero: View { let title: String - var subtitle: String = "" - let artworkURL: String? + var artworkURL: String? var placeholderSymbol: String = "music.note" + /// Artist pages paint their art full-width (Android's banner hero); + /// albums/playlists get the 180pt r16 square. + var bannerArtwork: Bool = false + /// Artist refs rendered as links — non-empty ids push the artist screen. + var artists: [ArtistRef] = [] + /// “2015 · 12 songs · 48:12” — only the fields the payload gave us. + var metaParts: [String] = [] let tracks: [Track] + /// The Add icon adds `tracks.first`; artists don't offer it. + var showAddAction: Bool = true + private let trailingContent: AnyView + + @Environment(\.m3) private var m3 @Environment(Player.self) private var player + init( + title: String, + artworkURL: String?, + placeholderSymbol: String = "music.note", + bannerArtwork: Bool = false, + artists: [ArtistRef] = [], + metaParts: [String] = [], + tracks: [Track], + showAddAction: Bool = true, + @ViewBuilder trailing: () -> Trailing = { EmptyView() } + ) { + self.title = title + self.artworkURL = artworkURL + self.placeholderSymbol = placeholderSymbol + self.bannerArtwork = bannerArtwork + self.artists = artists + self.metaParts = metaParts + self.tracks = tracks + self.showAddAction = showAddAction + self.trailingContent = AnyView(trailing()) + } + var body: some View { VStack(spacing: 14) { - ArtworkView(url: artworkURL, fallbackURL: nil, cornerRadius: 8, placeholderSymbol: placeholderSymbol) - .frame(width: 180, height: 180) - VStack(spacing: 4) { + artwork + VStack(spacing: 8) { Text(title) - .font(.largeTitle.bold()) + .font(.m3HeadlineMedium) + .foregroundStyle(m3.onSurface) .multilineTextAlignment(.center) - .lineLimit(3) - if !subtitle.isEmpty { - Text(subtitle) - .font(.subheadline) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - .lineLimit(2) + .lineLimit(2) + .minimumScaleFactor(0.8) + .frame(maxWidth: .infinity) + if !artists.isEmpty || !metaParts.isEmpty { + metaLine + } + if !tracks.isEmpty { + actionBar + .padding(.top, 6) } } - if !tracks.isEmpty { - HStack(spacing: 12) { - GlassPrimaryButton { - player.play(tracks) - } label: { - Label("Play", systemImage: "play.fill") - .font(.body.weight(.semibold)) - } - GlassPlainButton { - player.shuffle = true - player.play(tracks.shuffled()) - } label: { - Image(systemName: "shuffle") - .font(.body.weight(.semibold)) - } - Menu { - Button { - player.playNext(tracks) - } label: { - Label("Play Next", systemImage: "text.insert") - } - Button { - for track in tracks { player.enqueue(track) } - } label: { - Label("Add to Queue", systemImage: "list.bullet.plus") - } - } label: { - Image(systemName: "ellipsis") - .font(.body.weight(.semibold)) - .frame(width: 44, height: 40) - } + .padding(.horizontal, 16) + } + .padding(.top, bannerArtwork ? 0 : 8) + } + + @ViewBuilder private var artwork: some View { + if bannerArtwork { + ArtworkView( + url: artworkURL, + fallbackURL: nil, + cornerRadius: 0, + placeholderSymbol: placeholderSymbol + ) + .frame(height: 220) + .frame(maxWidth: .infinity) + .overlay(alignment: .bottom) { + LinearGradient( + colors: [.clear, m3.background], + startPoint: .top, + endPoint: .bottom + ) + } + } else { + ArtworkView( + url: artworkURL, + fallbackURL: nil, + cornerRadius: 16, + placeholderSymbol: placeholderSymbol + ) + .frame(width: 180, height: 180) + .shadow(color: m3.scrim.opacity(0.18), radius: 12, y: 6) + } + } + + // MARK: Metadata line — “Artist1, Artist2 · 2015 · 12 songs · 48:12” + + private var metaLine: some View { + HStack(spacing: 4) { + artistRun + ForEach(Array(metaParts.enumerated()), id: \.offset) { pair in + if !artists.isEmpty || pair.offset > 0 { + bullet + } + Text(pair.element) + .lineLimit(1) + .minimumScaleFactor(0.75) + } + } + .font(.m3BodySmall) + .foregroundStyle(m3.onSurfaceVariant) + .lineLimit(1) + .minimumScaleFactor(0.75) + .frame(maxWidth: .infinity) + } + + private var bullet: some View { + Text("·") + .foregroundStyle(m3.onSurfaceVariant.opacity(0.7)) + } + + @ViewBuilder private var artistRun: some View { + ForEach(Array(artists.enumerated()), id: \.offset) { pair in + if pair.offset > 0 { + Text(",") + } + if let id = pair.element.id, !id.isEmpty { + NavigationLink(value: Route.artist(channelId: id, title: pair.element.name)) { + Text(pair.element.name) + .font(.m3BodySmall.weight(.medium)) + .foregroundStyle(m3.primary) + .lineLimit(1) + .minimumScaleFactor(0.75) } + .buttonStyle(.plain) + } else { + Text(pair.element.name) + .lineLimit(1) + .minimumScaleFactor(0.75) } } - .padding(.horizontal) - .padding(.top, 8) + } + + // MARK: Actions — Shuffle (tonal) + Play (filled accent) + Add + trailing + + private var actionBar: some View { + HStack(spacing: 12) { + Button { + player.shuffle = true + player.play(tracks.shuffled()) + } label: { + Label("Shuffle", systemImage: "shuffle") + } + .buttonStyle(M3TonalStyle(compact: true)) + + Button { + player.play(tracks) + } label: { + Label("Play", systemImage: "play.fill") + } + .buttonStyle(M3FilledStyle(fill: .carmen, compact: true)) + + if showAddAction, let first = tracks.first { + // Same nesting the sheet menu uses: the picker is itself a + // Menu, so it becomes a submenu of this one. + Menu { + PlaylistPickerMenu(track: first) + } label: { + Image(systemName: "music.note.list") + .font(.m3(18, weight: .medium)) + .frame(width: 40, height: 40) + .contentShape(Circle()) + } + .accessibilityLabel("Add to Playlist") + } + + trailingContent + } + } +} + +// MARK: - Hero download (album header) + +/// Aggregate download control for a whole album — Android's header download +/// action. One tap queues every track that isn't saved yet; when the whole +/// header is saved, a tap removes them all. The ring reports the share of +/// tracks already on disk. +private struct HeroDownloadButton: View { + let tracks: [Track] + + @Environment(DownloadsStore.self) private var downloads + + private var downloadedCount: Int { + tracks.filter { downloads.isDownloaded($0.videoId) }.count + } + + private var isRunning: Bool { + tracks.contains { downloads.state(for: $0.videoId) != nil } + } + + var body: some View { + Button { + if !tracks.isEmpty, downloadedCount == tracks.count { + for track in tracks { downloads.remove(videoId: track.videoId) } + } else { + for track in tracks where !downloads.isDownloaded(track.videoId) { + downloads.download(track) + } + } + } label: { + label + .frame(width: 44, height: 44) + .contentShape(Circle()) + } + .buttonStyle(.plain) + .accessibilityLabel(downloadedCount == tracks.count && !tracks.isEmpty ? "Remove Download" : "Download") + } + + @ViewBuilder private var label: some View { + let progress = tracks.isEmpty ? 0 : Double(downloadedCount) / Double(tracks.count) + if !tracks.isEmpty, progress >= 1 { + Image(systemName: "checkmark.circle.fill") + .font(.m3(24, weight: .medium)) + .foregroundStyle(Color.carmen) + } else if isRunning || progress > 0 { + ZStack { + Circle() + .stroke(Color.secondary.opacity(0.25), lineWidth: 2) + Circle() + .trim(from: 0, to: max(progress, 0.02)) + .stroke(Color.carmen, style: StrokeStyle(lineWidth: 2, lineCap: .round)) + .rotationEffect(.degrees(-90)) + } + .frame(width: 24, height: 24) + } else { + Image(systemName: "arrow.down.circle") + .font(.m3(24, weight: .medium)) + .foregroundStyle(Color.secondary) + } } } +// MARK: - Shared description + /// Secondary description text, clamped to `lines` with a More/Less toggle -/// once it actually gets long. +/// once it actually gets long (behavior ported from the old ExpandableText). private struct ExpandableText: View { let text: String var lines: Int = 4 @State private var expanded = false + @Environment(\.m3) private var m3 var body: some View { VStack(alignment: .leading, spacing: 6) { Text(text) - .font(.footnote) - .foregroundStyle(.secondary) + .font(.m3BodyMedium) + .foregroundStyle(m3.onSurfaceVariant) .lineLimit(expanded ? nil : lines) + .frame(maxWidth: .infinity, alignment: .leading) if text.count > 160 { Button(expanded ? "Less" : "More") { withAnimation(.easeInOut(duration: 0.2)) { expanded.toggle() } } - .font(.footnote.weight(.semibold)) + .font(.m3LabelLarge) + .foregroundStyle(m3.primary) } } - .padding(.horizontal) + .padding(.horizontal, 16) } } @@ -112,10 +302,40 @@ struct AlbumView: View { @State private var detail: AlbumDetail? @State private var errorMessage: String? + @Environment(\.m3) private var m3 + @Environment(Player.self) private var player + + private var shareLink: String { "https://carmen.z44d.com/?album=\(browseId)" } + var body: some View { content - .navigationTitle(title) + .navigationTitle("") .navigationBarTitleDisplayMode(.inline) + .toolbar { + if let detail { + ToolbarItem(placement: .topBarTrailing) { + Menu { + Button { + player.play(detail.tracks) + } label: { + Label("Play All", systemImage: "play.fill") + } + Button { + player.shuffle = true + player.play(detail.tracks.shuffled()) + } label: { + Label("Shuffle", systemImage: "shuffle") + } + ShareLink(item: shareLink) { + Label("Share", systemImage: "square.and.arrow.up") + } + } label: { + Image(systemName: "ellipsis") + .font(.m3(20, weight: .semibold)) + } + } + } + } .task(id: browseId) { await load() } @@ -124,37 +344,48 @@ struct AlbumView: View { @ViewBuilder private var content: some View { if let detail { ScrollView { - VStack(alignment: .leading, spacing: 18) { - DetailHeader( + VStack(alignment: .leading, spacing: 16) { + DetailHero( title: detail.title, - subtitle: detail.artistLine, artworkURL: detail.artworkURL, placeholderSymbol: "square.stack", + artists: albumArtists(detail), + metaParts: metaParts(detail), tracks: detail.tracks - ) - if let meta = metaLine(detail) { - Text(meta) - .font(.footnote) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - .frame(maxWidth: .infinity) + ) { + HeroDownloadButton(tracks: detail.tracks) } if let description = detail.description, !description.isEmpty { ExpandableText(text: description) } trackList(album: detail) } - .padding(.bottom, 24) + .padding(.bottom, 32) + .frame(maxWidth: .infinity) + .background(alignment: .top) { HeroWash() } } + .background(m3.background) } else if let errorMessage { - ErrorState(message: errorMessage) { - Task { await load() } + ScrollView { + M3ErrorState(message: errorMessage) { + Task { await load() } + } } } else { - LoadingState() + ScrollView { + ShimmerList(rows: 6) + .padding(.top, 12) + } } } + /// Android's hero subtitle: linked artists, falling back to the author. + private func albumArtists(_ album: AlbumDetail) -> [ArtistRef] { + if !album.artists.isEmpty { return album.artists } + if let author = album.author, !author.isEmpty { return [ArtistRef(name: author)] } + return [] + } + private func load() async { errorMessage = nil do { @@ -167,26 +398,31 @@ struct AlbumView: View { private func trackList(album: AlbumDetail) -> some View { LazyVStack(spacing: 0) { ForEach(Array(album.tracks.enumerated()), id: \.offset) { pair in - TrackRow( + ListRow( track: pair.element, - context: album.tracks, + isPlaying: player.current?.videoId == pair.element.videoId, index: pair.offset + 1, - showArtwork: false + showArtwork: false, + onTap: { player.play(album.tracks, startAt: pair.offset) } ) - .padding(.horizontal) - .padding(.vertical, 6) + .padding(.horizontal, 8) } } } /// “2015 · 12 songs · 48:12” — whatever the payload gave us. - private func metaLine(_ album: AlbumDetail) -> String? { + private func metaParts(_ album: AlbumDetail) -> [String] { var parts: [String] = [] if let year = album.year, !year.isEmpty { parts.append(year) } let count = album.trackCount ?? album.tracks.count if count > 0 { parts.append("\(count) songs") } - if let duration = album.duration, !duration.isEmpty { parts.append(duration) } - return parts.isEmpty ? nil : parts.joined(separator: " · ") + if let duration = album.duration, !duration.isEmpty { + parts.append(duration) + } else { + let total = album.tracks.reduce(0) { $0 + ($1.durationSeconds ?? 0) } + if total > 0, let label = formatDuration(total) { parts.append(label) } + } + return parts } } @@ -199,10 +435,32 @@ struct ArtistView: View { @State private var detail: ArtistDetail? @State private var errorMessage: String? + @Environment(\.m3) private var m3 + @Environment(Player.self) private var player + + private var shareLink: String { "https://carmen.z44d.com/?artist=\(channelId)" } + var body: some View { content - .navigationTitle(title) + .navigationTitle("") .navigationBarTitleDisplayMode(.inline) + .toolbar { + ToolbarItem(placement: .topBarTrailing) { + Menu { + ShareLink(item: shareLink) { + Label("Share", systemImage: "square.and.arrow.up") + } + Button { + UIPasteboard.general.string = shareLink + } label: { + Label("Copy Link", systemImage: "link") + } + } label: { + Image(systemName: "ellipsis") + .font(.m3(20, weight: .semibold)) + } + } + } .task(id: channelId) { await load() } @@ -211,13 +469,15 @@ struct ArtistView: View { @ViewBuilder private var content: some View { if let detail { ScrollView { - VStack(alignment: .leading, spacing: 20) { - DetailHeader( + VStack(alignment: .leading, spacing: 18) { + DetailHero( title: detail.name, - subtitle: statsLine(detail), artworkURL: detail.artworkURL, placeholderSymbol: "person.crop.square", - tracks: detail.songs + bannerArtwork: true, + metaParts: statsParts(detail), + tracks: detail.songs, + showAddAction: false ) if let description = detail.description, !description.isEmpty { ExpandableText(text: description) @@ -229,14 +489,22 @@ struct ArtistView: View { cardShelf("Singles", detail.singles) cardShelf("Related Artists", detail.related) } - .padding(.bottom, 24) + .padding(.bottom, 32) + .frame(maxWidth: .infinity) + .background(alignment: .top) { HeroWash() } } + .background(m3.background) } else if let errorMessage { - ErrorState(message: errorMessage) { - Task { await load() } + ScrollView { + M3ErrorState(message: errorMessage) { + Task { await load() } + } } } else { - LoadingState() + ScrollView { + ShimmerList(rows: 6) + .padding(.top, 12) + } } } @@ -249,40 +517,38 @@ struct ArtistView: View { } } - private func statsLine(_ artist: ArtistDetail) -> String { + /// “1.2M subscribers · 8.4M monthly listeners” — whatever the payload gave. + private func statsParts(_ artist: ArtistDetail) -> [String] { [artist.subscribers, artist.monthlyListeners] .compactMap { $0 } .filter { !$0.isEmpty } - .joined(separator: " · ") } private func popularSection(artist: ArtistDetail) -> some View { - VStack(alignment: .leading, spacing: 8) { + VStack(alignment: .leading, spacing: 4) { SectionHeader(title: "Popular") - ForEach(Array(artist.songs.prefix(6).enumerated()), id: \.offset) { pair in - TrackRow( + ForEach(Array(artist.songs.enumerated()), id: \.offset) { pair in + ListRow( track: pair.element, - context: artist.songs, - index: pair.offset + 1, - showArtwork: false + isPlaying: player.current?.videoId == pair.element.videoId, + onTap: { player.play(artist.songs, startAt: pair.offset) } ) - .padding(.horizontal) - .padding(.vertical, 6) + .padding(.horizontal, 8) } } } @ViewBuilder private func cardShelf(_ title: String, _ cards: [MediaCard]) -> some View { if !cards.isEmpty { - VStack(alignment: .leading, spacing: 10) { + VStack(alignment: .leading, spacing: 8) { SectionHeader(title: title) ScrollView(.horizontal, showsIndicators: false) { HStack(alignment: .top, spacing: 14) { ForEach(cards) { card in - CardTile(card: card, siblings: cards, width: 150) + GridTile(card: card, siblings: cards, width: 140) } } - .padding(.horizontal) + .padding(.horizontal, 16) } } } @@ -298,10 +564,46 @@ struct PlaylistView: View { @State private var detail: PlaylistDetail? @State private var errorMessage: String? + @Environment(\.m3) private var m3 + @Environment(Player.self) private var player + + /// Playlist browse ids may arrive VL-prefixed — the API (and the web + /// share links) want the bare id. + private var bareId: String { + playlistId.hasPrefix("VL") ? String(playlistId.dropFirst(2)) : playlistId + } + + private var shareLink: String { "https://carmen.z44d.com/?playlist=\(bareId)" } + var body: some View { content - .navigationTitle(title) + .navigationTitle("") .navigationBarTitleDisplayMode(.inline) + .toolbar { + if let detail { + ToolbarItem(placement: .topBarTrailing) { + Menu { + Button { + player.play(detail.tracks) + } label: { + Label("Play All", systemImage: "play.fill") + } + Button { + player.shuffle = true + player.play(detail.tracks.shuffled()) + } label: { + Label("Shuffle", systemImage: "shuffle") + } + ShareLink(item: shareLink) { + Label("Share", systemImage: "square.and.arrow.up") + } + } label: { + Image(systemName: "ellipsis") + .font(.m3(20, weight: .semibold)) + } + } + } + } .task(id: playlistId) { await load() } @@ -310,60 +612,92 @@ struct PlaylistView: View { @ViewBuilder private var content: some View { if let detail { ScrollView { - VStack(alignment: .leading, spacing: 18) { - DetailHeader( + VStack(alignment: .leading, spacing: 16) { + DetailHero( title: detail.title, - subtitle: detail.author ?? "", artworkURL: detail.artworkURL, placeholderSymbol: "music.note.list", + metaParts: metaParts(detail), tracks: detail.tracks - ) - if let count = trackCount(detail) { - Text(count) - .font(.footnote) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - .frame(maxWidth: .infinity) + ) { + ShareLink(item: shareLink) { + Image(systemName: "square.and.arrow.up") + .font(.m3(18, weight: .medium)) + .frame(width: 40, height: 40) + .foregroundStyle(m3.onSurfaceVariant) + .contentShape(Circle()) + } } if let description = detail.description, !description.isEmpty { ExpandableText(text: description) } trackList(playlist: detail) } - .padding(.bottom, 24) + .padding(.bottom, 32) + .frame(maxWidth: .infinity) + .background(alignment: .top) { HeroWash() } } + .background(m3.background) } else if let errorMessage { - ErrorState(message: errorMessage) { - Task { await load() } + ScrollView { + M3ErrorState(message: errorMessage) { + Task { await load() } + } } } else { - LoadingState() + ScrollView { + ShimmerList(rows: 6) + .padding(.top, 12) + } } } private func load() async { errorMessage = nil - // Playlist browse ids may arrive VL-prefixed — the API wants the bare id. - let id = playlistId.hasPrefix("VL") ? String(playlistId.dropFirst(2)) : playlistId do { - detail = try await API(baseURL: SettingsStore.shared.apiBaseURL).playlist(id) + detail = try await API(baseURL: SettingsStore.shared.apiBaseURL).playlist(bareId) } catch { errorMessage = error.localizedDescription } } - private func trackCount(_ playlist: PlaylistDetail) -> String? { + /// “YouTube Music · 42 songs” — owner and count, only when present. + private func metaParts(_ playlist: PlaylistDetail) -> [String] { + var parts: [String] = [] + if let author = playlist.author, !author.isEmpty { parts.append(author) } let count = playlist.trackCount ?? playlist.tracks.count - return count > 0 ? "\(count) songs" : nil + if count > 0 { parts.append("\(count) songs") } + return parts } private func trackList(playlist: PlaylistDetail) -> some View { LazyVStack(spacing: 0) { ForEach(Array(playlist.tracks.enumerated()), id: \.offset) { pair in - TrackRow(track: pair.element, context: playlist.tracks) - .padding(.horizontal) - .padding(.vertical, 6) + ListRow( + track: pair.element, + isPlaying: player.current?.videoId == pair.element.videoId, + onTap: { player.play(playlist.tracks, startAt: pair.offset) } + ) + .padding(.horizontal, 8) } } } } + +// MARK: - Shared hero wash + +/// Android's hero background: a wash of the primary container fading into +/// the page background behind the artwork, title and actions. +private struct HeroWash: View { + @Environment(\.m3) private var m3 + + var body: some View { + LinearGradient( + colors: [m3.primaryContainer.opacity(0.3), m3.background], + startPoint: .top, + endPoint: .bottom + ) + .frame(height: 520) + .ignoresSafeArea(edges: .top) + } +} diff --git a/ios/Carmen/Views/HomeView.swift b/ios/Carmen/Views/HomeView.swift index 33da7c3..08a5aba 100644 --- a/ios/Carmen/Views/HomeView.swift +++ b/ios/Carmen/Views/HomeView.swift @@ -1,12 +1,13 @@ // // HomeView.swift -// Carmen — the Home tab, mirroring the web client's personal feed. +// Carmen — the Home tab in Material 3 dress (Android HomeScreen parity). // -// Entirely local, zero network: the 10 most recently played songs plus up +// Two local sections come first — the 10 most recently played songs plus up // to 100 suggestions harvested from the related lists of everything the -// user plays (shuffled on every refresh). The curated YouTube Music rows -// the server offers are deliberately not shown — the frontend dropped them -// for the same reason: the personal feed is all this page shows. +// user plays — followed by the curated YouTube Music rows the Carmen +// server offers via /browsing/home. Remote rows are a bonus: if the +// server is unreachable they are simply omitted and the personal feed +// stays on screen. // import SwiftUI @@ -14,168 +15,192 @@ import SwiftUI struct HomeView: View { @Environment(LibraryStore.self) private var library @Environment(Player.self) private var player + @Environment(SettingsStore.self) private var settings + @Environment(\.m3) private var m3 /// Snapshots — captured on appear and refresh (frontend `raw` /// semantics): what's on screen keeps its order until the user refreshes. @State private var recentSnapshot: [Track] = [] @State private var suggestionSnapshot: [Track] = [] @State private var visibleSuggestions = 12 - @State private var refreshing = false - private let columns = [GridItem(.adaptive(minimum: 150), spacing: 16)] + /// Curated rows from `/browsing/home` — loaded on appear, reloaded by + /// pull-to-refresh. Never fatal: local sections stay visible on failure. + @State private var remoteSections: [HomeSection] = [] + @State private var remoteLoading = true + + @State private var showSettings = false + + private let columns = [ + GridItem(.flexible(), spacing: 16), + GridItem(.flexible(), spacing: 16), + ] var body: some View { ScrollView { - VStack(alignment: .leading, spacing: 24) { - if refreshing { - ProgressView() - .padding(.horizontal) - } - - if !recentSnapshot.isEmpty { - recentlyPlayed - } - if !suggestionSnapshot.isEmpty { - suggestedForYou - } - if !refreshing, recentSnapshot.isEmpty, suggestionSnapshot.isEmpty { - EmptyState( + VStack(alignment: .leading, spacing: 12) { + keepListening + suggestions + remote + if !remoteLoading, + recentSnapshot.isEmpty, + suggestionSnapshot.isEmpty, + remoteSections.isEmpty { + M3EmptyState( title: "Nothing here yet", - hint: "Songs you play show up here: your last 10 plays, plus up to 100 suggestions gathered from their related songs." + hint: "Songs you play show up here: your last 10 plays, plus up to 100 suggestions gathered from their related songs.", + systemImage: "music.note" ) } } .padding(.vertical, 8) } - .navigationTitle("Home") - .navigationBarTitleDisplayMode(.large) - .toolbar { - ToolbarItem(placement: .topBarTrailing) { - Button { - Task { await refresh() } - } label: { - Image(systemName: "arrow.triangle.2.circlepath") - } - .accessibilityLabel("Refresh") - } + .background(backdrop) + .toolbar(.hidden, for: .navigationBar) + .safeAreaInset(edge: .top) { + TopAppBar(title: "Carmen", onOverflow: { showSettings = true }) } .refreshable { await refresh() } + .overlay(alignment: .bottomTrailing) { + // Floating mini player + nav pill live at the bottom — pad the + // FAB above them when a queue is up. + M3Fab(systemImage: "shuffle", title: "Shuffle") { + shuffleAll() + } + .padding(.trailing, 16) + .padding(.bottom, player.hasQueue ? 96 : 24) + } + .sheet(isPresented: $showSettings) { + SettingsView() + } .task { snapshot() + await loadRemote() } } - // MARK: Data - - /// Frontend snapshot(): recents as-is, suggestions shuffled. - private func snapshot() { - recentSnapshot = Array(library.recents.prefix(10)) - suggestionSnapshot = library.suggestions.shuffled() - visibleSuggestions = 12 + // MARK: Backdrop + + /// Subtle tonal wash behind the feed — the seed's primary container + /// fading into the background, like Android's home surface. + private var backdrop: some View { + LinearGradient( + colors: [m3.primaryContainer.opacity(0.35), m3.background], + startPoint: .top, + endPoint: .bottom + ) + .ignoresSafeArea() } - private func refresh() async { - refreshing = true - snapshot() - // Minimum spinner so the control feels like it actually worked. - try? await Task.sleep(for: .milliseconds(400)) - refreshing = false - } - - // MARK: Recently played - - private var recentlyPlayed: some View { - VStack(alignment: .leading, spacing: 12) { - HStack(spacing: 8) { - VStack(alignment: .leading, spacing: 2) { - Text("Recently Played") - .font(.title3.weight(.bold)) - Text("Last \(recentSnapshot.count) of 10 songs") - .font(.caption) - .foregroundStyle(.secondary) - } - Spacer() - Button { - player.play(recentSnapshot) - } label: { - Image(systemName: "play.fill") - .font(.subheadline) - .frame(width: 32, height: 32) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .accessibilityLabel("Play recent songs") - } - .padding(.horizontal) + // MARK: Keep listening + @ViewBuilder private var keepListening: some View { + if !recentSnapshot.isEmpty { + SectionHeader(title: "Keep listening") ScrollView(.horizontal, showsIndicators: false) { - HStack(alignment: .top, spacing: 14) { + HStack(alignment: .top, spacing: 16) { ForEach(recentSnapshot) { track in - TrackTile(track: track, context: recentSnapshot, width: 132) + GridTile(track: track, context: recentSnapshot) } } - .padding(.horizontal) + .padding(.horizontal, 16) } } } - // MARK: Suggested for you - - private var suggestedForYou: some View { - VStack(alignment: .leading, spacing: 12) { - HStack(alignment: .top, spacing: 8) { - VStack(alignment: .leading, spacing: 2) { - Text("Suggested for you") - .font(.title3.weight(.bold)) - Text("\(suggestionSnapshot.count) songs gathered from songs you play") - .font(.caption) - .foregroundStyle(.secondary) - } - Spacer() - Button { - player.shuffle = true - player.play(suggestionSnapshot.shuffled()) - } label: { - Image(systemName: "shuffle") - .font(.subheadline) - .frame(width: 32, height: 32) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .accessibilityLabel("Shuffle all") + // MARK: Carmen suggestions - Button { + @ViewBuilder private var suggestions: some View { + if !suggestionSnapshot.isEmpty { + SectionHeader(title: "Carmen suggestions", menuItems: { + Button("Reshuffle") { library.reshuffleSuggestions() snapshot() - } label: { - Image(systemName: "arrow.triangle.2.circlepath") - .font(.subheadline) - .frame(width: 32, height: 32) - .contentShape(Rectangle()) } - .buttonStyle(.plain) - .accessibilityLabel("Re-shuffle") - } - .padding(.horizontal) - - LazyVGrid(columns: columns, spacing: 16) { + }) + LazyVGrid(columns: columns, spacing: 20) { ForEach( Array(suggestionSnapshot.prefix(visibleSuggestions).enumerated()), id: \.offset ) { index, track in - TrackTile(track: track, context: suggestionContext(for: track)) + GridTile(track: track, context: suggestionContext(for: track)) .onAppear { // Prefetch the next page before the user hits it. if index >= visibleSuggestions - 6 { visibleSuggestions += 12 } } } } - .padding(.horizontal) + .padding(.horizontal, 16) } } + // MARK: Remote home sections + + @ViewBuilder private var remote: some View { + if remoteLoading, remoteSections.isEmpty { + // First load: skeleton shaped like the grid above while the + // curated rows arrive. + ShimmerGrid(items: 4, columns: 2) + .padding(.horizontal, 16) + .padding(.top, 4) + } else { + ForEach(remoteSections.filter { !$0.contents.isEmpty }) { section in + remoteSection(section) + } + } + } + + private func remoteSection(_ section: HomeSection) -> some View { + let playable = playableTracks(section.contents) + let playAll: (() -> Void)? = playable.isEmpty ? nil : { player.play(playable) } + return VStack(alignment: .leading, spacing: 4) { + SectionHeader( + title: section.title, + actionTitle: playAll == nil ? nil : "Play all", + action: playAll + ) + ScrollView(.horizontal, showsIndicators: false) { + HStack(alignment: .top, spacing: 16) { + ForEach(section.contents) { card in + GridTile(card: card, siblings: section.contents) + } + } + .padding(.horizontal, 16) + } + } + } + + // MARK: Data + + /// Frontend snapshot(): recents as-is, suggestions shuffled. + private func snapshot() { + recentSnapshot = Array(library.recents.prefix(10)) + suggestionSnapshot = library.suggestions.shuffled() + visibleSuggestions = 12 + } + + private func refresh() async { + snapshot() + await loadRemote() + // Minimum spinner so the control feels like it actually worked. + try? await Task.sleep(for: .milliseconds(400)) + } + + /// Loads (or reloads) the curated `/browsing/home` rows. A failure only + /// costs the remote sections — local content always stays on screen. + private func loadRemote() async { + remoteLoading = true + do { + remoteSections = try await API(baseURL: settings.apiBaseURL).home() + } catch { + DebugLog.log("home", "sections failed: \(error.localizedDescription)") + remoteSections = [] + } + remoteLoading = false + } + /// Frontend playSuggestion(): `[tapped] + (recents + shuffled suggestions /// minus the tapped track)`. private func suggestionContext(for tapped: Track) -> [Track] { @@ -183,4 +208,14 @@ struct HomeView: View { rest.removeAll { $0.videoId == tapped.videoId } return [tapped] + rest } + + /// The FAB: shuffle-play everything the local feed knows about. + private func shuffleAll() { + var seen = Set() + let candidates = (recentSnapshot + suggestionSnapshot) + .filter { seen.insert($0.videoId).inserted } + guard !candidates.isEmpty else { return } + player.shuffle = true + player.play(candidates.shuffled()) + } } diff --git a/ios/Carmen/Views/LibraryView.swift b/ios/Carmen/Views/LibraryView.swift index 5b3acc7..a13dc61 100644 --- a/ios/Carmen/Views/LibraryView.swift +++ b/ios/Carmen/Views/LibraryView.swift @@ -1,8 +1,11 @@ // // LibraryView.swift -// Carmen — the Library tab: favorites, recently played and on-device -// playlists. Everything here is local state (LibraryStore) — nothing is -// fetched, so no loading/error states are needed. +// Carmen — the Library tab as the Material 3 clone of Android's library +// screen: a pinned top app bar and a row of filter-chip tabs over a +// swipeable pager (Mix / Playlists / Songs / Artists / Albums / +// Downloads). Everything here is local state (LibraryStore + +// DownloadsStore) — nothing is fetched, so no loading/error states are +// needed. // import SwiftUI @@ -10,77 +13,56 @@ import SwiftUI struct LibraryView: View { @Environment(LibraryStore.self) private var library @Environment(DownloadsStore.self) private var downloads + @Environment(Player.self) private var player + @Environment(\.m3) private var m3 + + /// Selected tab — the chip row and the pager read and write the same + /// persisted value (Android remembers its chip the same way). + @AppStorage("carmen.library.tab") private var storedTab = LibraryTab.mix.rawValue + @State private var showSettings = false @State private var showingNewPlaylist = false @State private var newName = "" - @State private var showingSettings = false @State private var showingRename = false @State private var renameTarget: LocalPlaylist? @State private var renameText = "" @State private var showingDelete = false @State private var deleteTarget: LocalPlaylist? @State private var openedPlaylist: LocalPlaylist? - @State private var tracksSheet: TracksSheetItem? + @State private var playlistMenuTarget: LocalPlaylist? + @State private var showRemoveAll = false + @State private var favoritesSort: LibrarySongSort = .recent + @State private var pushedRoute: LibraryRoutePush? + + private let gridColumns = [ + GridItem(.flexible(), spacing: 16), + GridItem(.flexible(), spacing: 16), + ] var body: some View { - ScrollView { - ScrollViewReader { proxy in - VStack(alignment: .leading, spacing: 26) { - HStack(spacing: 10) { - chip(title: "Favorite Songs", symbol: "heart.fill", count: library.favorites.count) { - tracksSheet = TracksSheetItem(title: "Favorite Songs", tracks: library.favorites) - } - chip(title: "Downloads", symbol: "arrow.down.circle.fill", count: downloads.downloadedIds.count) { - withAnimation { proxy.scrollTo("downloads", anchor: .top) } - } - chip(title: "Recently Played", symbol: "clock.arrow.circlepath", count: library.recents.count) { - tracksSheet = TracksSheetItem(title: "Recently Played", tracks: library.recents) - } - chip(title: "Playlists", symbol: "music.note.list", count: library.playlists.count) { - withAnimation { proxy.scrollTo("playlists", anchor: .top) } - } - } - .padding(.horizontal) - - favoritesSection - downloadsSection - .id("downloads") - recentsSection - playlistsSection - .id("playlists") - } - .padding(.vertical, 12) - } - } - .navigationTitle("Library") - .navigationBarTitleDisplayMode(.large) - .toolbar { - ToolbarItem(placement: .topBarTrailing) { - Button { - showingNewPlaylist = true - } label: { - Image(systemName: "plus") - } - .accessibilityLabel("New Playlist") - } - ToolbarItem(placement: .topBarTrailing) { - Button { - showingSettings = true - } label: { - Image(systemName: "gearshape") - } - .accessibilityLabel("Settings") - } + TabView(selection: tabSelection) { + mixPage.tag(LibraryTab.mix.rawValue) + playlistsPage.tag(LibraryTab.playlists.rawValue) + songsPage.tag(LibraryTab.songs.rawValue) + artistsPage.tag(LibraryTab.artists.rawValue) + albumsPage.tag(LibraryTab.albums.rawValue) + downloadsPage.tag(LibraryTab.downloads.rawValue) } - .sheet(item: $tracksSheet) { item in - AllTracksSheet(title: item.title, tracks: item.tracks) + .tabViewStyle(.page(indexDisplayMode: .never)) + .background { m3.background.ignoresSafeArea() } + .toolbar(.hidden, for: .navigationBar) + .safeAreaInset(edge: .top, spacing: 0) { header } + // GridTile's label is a Button, so it can't nest inside a + // NavigationLink — taps route through this item-based push instead. + // The wrapper type keeps it distinct from `carmenDestinations()`' + // `for: Route.self` destination on the same stack. + .navigationDestination(item: $pushedRoute) { pushedDestination($0) } + .sheet(isPresented: $showSettings) { + SettingsView() } .sheet(item: $openedPlaylist) { playlist in LocalPlaylistSheet(playlist: playlist) } - .sheet(isPresented: $showingSettings) { - NavigationStack { SettingsView() } - } .alert("New Playlist", isPresented: $showingNewPlaylist) { TextField("Name", text: $newName) Button("Create") { @@ -91,12 +73,246 @@ struct LibraryView: View { } Button("Cancel", role: .cancel) { newName = "" } } + } + + // MARK: - Tab plumbing + + private var selectedTab: LibraryTab { + LibraryTab(rawValue: storedTab) ?? .mix + } + + /// One source of truth: the chips write it, the pager reads and writes + /// it. Unrecognized persisted values fall back to Mix. + private var tabSelection: Binding { + Binding( + get: { selectedTab.rawValue }, + set: { storedTab = $0 } + ) + } + + private func select(_ tab: LibraryTab) { + withAnimation(.snappy(duration: 0.3)) { + storedTab = tab.rawValue + } + } + + /// Pinned header: the floating top bar with the overflow, then the + /// horizontally scrolling pill-chip tabs (Android's ExpressiveTabChip). + private var header: some View { + VStack(spacing: 0) { + TopAppBar(title: "Library", onOverflow: { showSettings = true }) + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + ForEach(LibraryTab.allCases, id: \.self) { tab in + FilterChip( + isSelected: selectedTab == tab, + title: tab.rawValue, + systemImage: tab.systemImage + ) { + select(tab) + } + } + } + .padding(.horizontal, 16) + .padding(.top, 2) + .padding(.bottom, 10) + } + } + .background { m3.background.ignoresSafeArea() } + } + + // MARK: - Mix + + private var mixPage: some View { + ScrollView { + VStack(spacing: 12) { + mixCard( + title: "Favorites", + subtitle: library.favorites.isEmpty + ? "Songs you heart show up here" + : "\(library.favorites.count) songs", + systemImage: "heart.fill" + ) { + select(.songs) + } + + mixCard( + title: "Downloads", + subtitle: downloads.downloadedIds.isEmpty + ? "Download songs to listen offline" + : "\(downloads.downloadedIds.count) songs · \(downloads.totalSizeLabel)", + systemImage: "arrow.down.circle.fill" + ) { + select(.downloads) + } + + // No "Recents" tab — this card plays what it lists. + mixCard( + title: "Recent activity", + subtitle: library.recents.isEmpty + ? "Songs you play show up here" + : Array(library.recents.prefix(3)).map(\.title).joined(separator: " · "), + systemImage: "clock.arrow.circlepath" + ) { + player.play(library.recents) + } + + mixCard( + title: "Playlists", + subtitle: library.playlists.isEmpty + ? "Create one in the Playlists tab" + : Array(library.playlists.prefix(3)).map(\.name).joined(separator: " · "), + systemImage: "music.note.list" + ) { + select(.playlists) + } + } + .padding(.horizontal, 16) + .padding(.top, 8) + .padding(.bottom, 24) + } + } + + /// One Mix shortcut row: 40pt primary-container circle, title/subtitle, + /// trailing chevron. Cards with nothing to count show a hint subtitle + /// instead of hiding (Android keeps its shortcut grid visible too). + private func mixCard( + title: String, + subtitle: String, + systemImage: String, + action: @escaping () -> Void + ) -> some View { + M3Card(padding: 14, action: action) { + HStack(spacing: 12) { + ZStack { + Circle() + .fill(m3.primaryContainer) + .frame(width: 40, height: 40) + Image(systemName: systemImage) + .font(.m3BodyLarge) + .foregroundStyle(m3.primary) + } + VStack(alignment: .leading, spacing: 2) { + Text(title) + .font(.m3TitleSmall) + .foregroundStyle(m3.onSurface) + .lineLimit(1) + Text(subtitle) + .font(.m3BodySmall) + .foregroundStyle(m3.onSurfaceVariant) + .lineLimit(1) + .minimumScaleFactor(0.85) + } + .frame(maxWidth: .infinity, alignment: .leading) + Image(systemName: "chevron.right") + .font(.m3BodySmall) + .foregroundStyle(m3.outline) + } + } + .pressScale(0.99) + } + + // MARK: - Playlists + + private var playlistsPage: some View { + ScrollView { + VStack(alignment: .leading, spacing: 8) { + SectionHeader(title: "Playlists", actionTitle: "+") { + showingNewPlaylist = true + } + + if library.playlists.isEmpty { + M3EmptyState( + title: "No playlists yet", + hint: "Create one with + above", + systemImage: "music.note.list" + ) + } else { + LazyVGrid(columns: gridColumns, spacing: 20) { + ForEach(library.playlists) { playlist in + playlistTile(playlist) + } + } + .padding(.horizontal, 16) + .padding(.top, 4) + } + } + .padding(.top, 4) + .padding(.bottom, 24) + } + .bottomSheetMenu(isPresented: playlistMenuBinding, title: playlistMenuTarget?.name) { + if let playlist = playlistMenuTarget { + playlistMenu(playlist) + } + } + } + + private func playlistTile(_ playlist: LocalPlaylist) -> some View { + ZStack(alignment: .topTrailing) { + GridTile( + title: playlist.name, + subtitle: "\(playlist.tracks.count) songs", + artwork: playlist.tracks.first?.artwork, + artworkAlt: playlist.tracks.first?.artworkAlt, + action: { openedPlaylist = playlist } + ) + Button { + playlistMenuTarget = playlist + } label: { + Image(systemName: "ellipsis") + .font(.m3BodyLarge) + .foregroundStyle(m3.onSurfaceVariant) + .frame(width: 32, height: 32) + .background(m3.surfaceContainerHighest.opacity(0.92), in: Circle()) + .padding(6) + } + .buttonStyle(.plain) + .accessibilityLabel("Options for \(playlist.name)") + } + } + + private var playlistMenuBinding: Binding { + Binding( + get: { playlistMenuTarget != nil }, + set: { presented in + if !presented { playlistMenuTarget = nil } + } + ) + } + + /// Play / Shuffle / Rename / Delete — Android's playlist bottom-sheet + /// menu. The dialogs live inside the sheet content (same pattern as + /// `PlaylistPickerMenu`) so they always land on top of it. + @ViewBuilder + private func playlistMenu(_ playlist: LocalPlaylist) -> some View { + VStack(alignment: .leading, spacing: 0) { + SheetMenuRow(title: "Play", systemImage: "play.fill") { + playlistMenuTarget = nil + player.play(playlist.tracks) + } + SheetMenuRow(title: "Shuffle", systemImage: "shuffle") { + playlistMenuTarget = nil + player.shuffle = true + player.play(playlist.tracks.shuffled()) + } + Divider() + SheetMenuRow(title: "Rename", systemImage: "pencil") { + renameText = playlist.name + renameTarget = playlist + showingRename = true + } + SheetMenuRow(title: "Delete", systemImage: "trash", tint: m3.error) { + deleteTarget = playlist + showingDelete = true + } + } .alert("Rename Playlist", isPresented: $showingRename, presenting: renameTarget) { playlist in TextField("Name", text: $renameText) Button("Rename") { let name = renameText.trimmingCharacters(in: .whitespacesAndNewlines) guard !name.isEmpty else { return } library.renamePlaylist(id: playlist.id, to: name) + playlistMenuTarget = nil } Button("Cancel", role: .cancel) {} } message: { playlist in @@ -106,6 +322,7 @@ struct LibraryView: View { Button("Delete Playlist", role: .destructive) { if openedPlaylist?.id == playlist.id { openedPlaylist = nil } library.deletePlaylist(id: playlist.id) + playlistMenuTarget = nil } Button("Cancel", role: .cancel) {} } message: { playlist in @@ -113,219 +330,398 @@ struct LibraryView: View { } } - // MARK: - Sections + // MARK: - Songs - private var favoritesSection: some View { - VStack(alignment: .leading, spacing: 8) { - sectionHeader("Favorites", count: library.favorites.count) { - tracksSheet = TracksSheetItem(title: "Favorite Songs", tracks: library.favorites) - } - if library.favorites.isEmpty { - EmptyState(title: "No Favorites", hint: "Songs you heart show up here.") - } else { - trackRows(library.favorites) + private var songsPage: some View { + ScrollView { + VStack(alignment: .leading, spacing: 4) { + SectionHeader(title: "Favorites", menuItems: { + Button { + player.play(sortedFavorites) + } label: { + Label("Play all", systemImage: "play.fill") + } + Button { + player.shuffle = true + player.play(sortedFavorites.shuffled()) + } label: { + Label("Shuffle", systemImage: "shuffle") + } + Divider() + Menu { + ForEach(LibrarySongSort.allCases, id: \.self) { mode in + Button { + favoritesSort = mode + } label: { + if mode == favoritesSort { + Label(mode.rawValue, systemImage: "checkmark") + } else { + Text(mode.rawValue) + } + } + } + } label: { + Label("Sort by", systemImage: "arrow.up.arrow.down") + } + }) + + if sortedFavorites.isEmpty { + M3EmptyState( + title: "No favorites yet", + hint: "Heart any song and it shows up here.", + systemImage: "heart" + ) + } else { + VStack(spacing: 0) { + ForEach(Array(sortedFavorites.enumerated()), id: \.offset) { pair in + ListRow( + track: pair.element, + isPlaying: player.current?.videoId == pair.element.videoId, + onTap: { player.play(sortedFavorites, startAt: pair.offset) } + ) + } + } + .padding(.top, 4) + } } + .padding(.top, 4) + .padding(.bottom, 24) } } - private var downloadsSection: some View { - VStack(alignment: .leading, spacing: 8) { - sectionHeader("Downloads", count: downloads.downloadedIds.count) { - tracksSheet = TracksSheetItem(title: "Downloads", tracks: downloads.downloadedTracks()) + /// Client-side favorites sort (Android's LibrarySongsScreen menu). + /// "Recent" is the store's own order — most recently hearted first. + private var sortedFavorites: [Track] { + switch favoritesSort { + case .recent: + return library.favorites + case .title: + return library.favorites.sorted { + $0.title.localizedCaseInsensitiveCompare($1.title) == .orderedAscending + } + case .artist: + return library.favorites.sorted { + let lhs = $0.artistLine.localizedCaseInsensitiveCompare($1.artistLine) + if lhs == .orderedSame { + return $0.title.localizedCaseInsensitiveCompare($1.title) == .orderedAscending + } + return lhs == .orderedAscending } - if downloads.downloadedIds.isEmpty { - EmptyState( - title: "No Downloads", - hint: "Use the download button on any song to keep its audio, artwork and lyrics on this device — it then plays fully offline." + } + } + + // MARK: - Artists + + private var artistsPage: some View { + ScrollView { + if uniqueArtists.isEmpty { + M3EmptyState( + title: "No artists yet", + hint: "Artists from your favorites and recent plays show up here.", + systemImage: "person.crop.circle" ) } else { - trackRows(downloads.downloadedTracks()) + LazyVGrid(columns: gridColumns, spacing: 20) { + ForEach(uniqueArtists) { artist in + GridTile( + title: artist.name, + subtitle: "", + artwork: artist.artwork, + artworkAlt: artist.artworkAlt, + isCircular: true, + action: { + // No channel id (the ArtistRef was a bare + // name) → nothing to push, so the tile does + // nothing instead of navigating blind. + guard !artist.channelId.isEmpty else { return } + pushedRoute = LibraryRoutePush( + route: .artist(channelId: artist.channelId, title: artist.name) + ) + } + ) + } + } + .padding(.horizontal, 16) + .padding(.top, 8) } } + .padding(.bottom, 24) } - private var recentsSection: some View { - VStack(alignment: .leading, spacing: 8) { - sectionHeader("Recently Played", count: library.recents.count) { - tracksSheet = TracksSheetItem(title: "Recently Played", tracks: library.recents) - } - if library.recents.isEmpty { - EmptyState(title: "No Recent Plays", hint: "Songs you play show up here.") - } else { - trackRows(library.recents) + /// Unique artists across favorites + recents: keyed by lower-cased name, + /// first artwork wins, channel id lifted from whichever `ArtistRef` + /// carried one. Sorted alphabetically, like Android's grid. + private var uniqueArtists: [LibraryArtistItem] { + var items: [String: LibraryArtistItem] = [:] + for track in library.favorites + library.recents { + for artist in track.artists where !artist.name.isEmpty { + let key = artist.name.lowercased() + if var existing = items[key] { + if existing.channelId.isEmpty, let id = artist.id, !id.isEmpty { + existing.channelId = id + items[key] = existing + } + } else { + items[key] = LibraryArtistItem( + id: key, + channelId: artist.id ?? "", + name: artist.name, + artwork: track.artwork, + artworkAlt: track.artworkAlt + ) + } } } + return items.values.sorted { + $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending + } } - private var playlistsSection: some View { - VStack(alignment: .leading, spacing: 8) { - SectionHeader(title: "Playlists") - if library.playlists.isEmpty { - EmptyState(title: "No Playlists", hint: "Tap + to make one.") + // MARK: - Albums + + private var albumsPage: some View { + ScrollView { + if uniqueAlbums.isEmpty { + M3EmptyState( + title: "No albums yet", + hint: "Albums from your favorites and recent plays show up here.", + systemImage: "square.stack" + ) } else { - VStack(spacing: 2) { - ForEach(library.playlists) { playlist in - Button { - openedPlaylist = playlist - } label: { - PlaylistRow(playlist: playlist) - } - .buttonStyle(.plain) - .padding(.horizontal) - .padding(.vertical, 6) - .contextMenu { - Button { - renameText = playlist.name - renameTarget = playlist - showingRename = true - } label: { - Label("Rename", systemImage: "pencil") - } - Divider() - Button(role: .destructive) { - deleteTarget = playlist - showingDelete = true - } label: { - Label("Delete", systemImage: "trash") + LazyVGrid(columns: gridColumns, spacing: 20) { + ForEach(uniqueAlbums) { album in + GridTile( + title: album.name, + subtitle: album.artist, + artwork: album.artwork, + artworkAlt: album.artworkAlt, + action: { + if album.browseId.isEmpty { + // AlbumRef never carried an id — play the + // copies this device actually knows. + let tracks = (library.favorites + library.recents).filter { + $0.album?.name.lowercased() == album.id + } + player.play(tracks) + } else { + pushedRoute = LibraryRoutePush( + route: .album(browseId: album.browseId, title: album.name) + ) + } } - } + ) } } + .padding(.horizontal, 16) + .padding(.top, 8) } } + .padding(.bottom, 24) } - // MARK: - Pieces - - @ViewBuilder - private func chip(title: String, symbol: String, count: Int, action: @escaping () -> Void) -> some View { - Button(action: action) { - VStack(spacing: 6) { - Image(systemName: symbol) - .font(.title3) - .foregroundStyle(Color.carmen) - Text("\(count)") - .font(.title3.weight(.bold)) - .monospacedDigit() - Text(title) - .font(.caption.weight(.medium)) - .foregroundStyle(.secondary) - .lineLimit(1) + /// Unique albums across favorites + recents, same rules as the artists + /// grid (name-keyed, browse id hoisted from any AlbumRef that had one). + private var uniqueAlbums: [LibraryAlbumItem] { + var items: [String: LibraryAlbumItem] = [:] + for track in library.favorites + library.recents { + guard let album = track.album, !album.name.isEmpty else { continue } + let key = album.name.lowercased() + if var existing = items[key] { + if existing.browseId.isEmpty, let id = album.id, !id.isEmpty { + existing.browseId = id + items[key] = existing + } + } else { + items[key] = LibraryAlbumItem( + id: key, + browseId: album.id ?? "", + name: album.name, + artist: track.artistLine, + artwork: track.artwork, + artworkAlt: track.artworkAlt + ) } - .frame(maxWidth: .infinity) - .padding(.vertical, 14) - .padding(.horizontal, 6) - .glassCard(cornerRadius: 16) - .contentShape(Rectangle()) } - .buttonStyle(.plain) + return items.values.sorted { + $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending + } } - private func sectionHeader(_ title: String, count: Int, seeAll: @escaping () -> Void) -> some View { - HStack(spacing: 8) { - Text(title) - .font(.title3.weight(.bold)) - Spacer() - if count > 5 { - Button("See All", action: seeAll) - .font(.subheadline.weight(.semibold)) + // MARK: - Downloads + + private var downloadsPage: some View { + let downloaded = downloads.downloadedTracks() + return ScrollView { + VStack(alignment: .leading, spacing: 8) { + storageCard + + if downloaded.isEmpty { + M3EmptyState( + title: "No downloads", + hint: "Use the download button on any song to keep its audio, artwork and lyrics on this device — it then plays fully offline.", + systemImage: "arrow.down.circle" + ) + } else { + SectionHeader(title: "Downloaded") + VStack(spacing: 0) { + ForEach(downloaded) { track in + ListRow( + track: track, + isPlaying: player.current?.videoId == track.videoId, + onTap: { + let index = downloaded.firstIndex { $0.videoId == track.videoId } ?? 0 + player.play(downloaded, startAt: index) + } + ) + } + } + } } + .padding(.top, 8) + .padding(.bottom, 24) + } + .confirmationDialog("Remove all downloads?", isPresented: $showRemoveAll) { + Button("Remove All", role: .destructive) { + downloads.removeAll() + } + Button("Cancel", role: .cancel) {} + } message: { + Text("Every downloaded song's audio, artwork and lyrics will be deleted from this device.") } - .padding(.horizontal) } - /// The first five rows of a local list, played inside the full context. - private func trackRows(_ tracks: [Track]) -> some View { - VStack(spacing: 2) { - ForEach(Array(tracks.prefix(5).enumerated()), id: \.offset) { pair in - TrackRow(track: pair.element, context: tracks) - .padding(.horizontal) - .padding(.vertical, 6) + private var storageCard: some View { + let count = downloads.downloadedIds.count + return M3Card(padding: 14) { + HStack(spacing: 12) { + ZStack { + Circle() + .fill(m3.primaryContainer) + .frame(width: 40, height: 40) + Image(systemName: "arrow.down.circle.fill") + .font(.m3BodyLarge) + .foregroundStyle(m3.primary) + } + VStack(alignment: .leading, spacing: 2) { + Text("Storage") + .font(.m3TitleSmall) + .foregroundStyle(m3.onSurface) + .lineLimit(1) + Text( + count == 0 + ? "Nothing saved yet — download any song for offline playback" + : "\(count) songs · \(downloads.totalSizeLabel)" + ) + .font(.m3BodySmall) + .foregroundStyle(m3.onSurfaceVariant) + .lineLimit(1) + .minimumScaleFactor(0.85) + } + .frame(maxWidth: .infinity, alignment: .leading) + + if count > 0 { + Button { + showRemoveAll = true + } label: { + Text("Remove all") + .font(.m3LabelLarge) + .foregroundStyle(m3.error) + .padding(.horizontal, 8) + .frame(minHeight: 40) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .pressScale(0.96) + .accessibilityLabel("Remove all downloads") + } } } + .padding(.horizontal, 16) } -} -/// Payload for the “See All” sheets. -private struct TracksSheetItem: Identifiable { - let id = UUID() - let title: String - let tracks: [Track] + // MARK: - Route pushes + + @ViewBuilder + private func pushedDestination(_ push: LibraryRoutePush) -> some View { + switch push.route { + case .album(let browseId, let title): + AlbumView(browseId: browseId, title: title) + case .artist(let channelId, let title): + ArtistView(channelId: channelId, title: title) + case .playlist(let playlistId, let title): + PlaylistView(playlistId: playlistId, title: title) + case .searchResults(let query): + SearchResultsView(query: query) + } + } } -/// One playlist row in the Library list. -private struct PlaylistRow: View { - let playlist: LocalPlaylist +// MARK: - Tab vocabulary - var body: some View { - HStack(spacing: 12) { - ArtworkView( - url: playlist.tracks.first?.artwork, - fallbackURL: playlist.tracks.first?.artworkAlt, - cornerRadius: 6, - placeholderSymbol: "music.note.list" - ) - .frame(width: 44, height: 44) - VStack(alignment: .leading, spacing: 2) { - Text(playlist.name) - .font(.body) - .lineLimit(1) - Text("\(playlist.tracks.count) songs") - .font(.footnote) - .foregroundStyle(.secondary) - } - Spacer(minLength: 8) - Image(systemName: "chevron.right") - .font(.footnote.weight(.semibold)) - .foregroundStyle(.tertiary) +/// The six library tabs, in the Android app's order. The raw value doubles +/// as the chip label and the persisted `carmen.library.tab` value. +private enum LibraryTab: String, CaseIterable { + case mix = "Mix" + case playlists = "Playlists" + case songs = "Songs" + case artists = "Artists" + case albums = "Albums" + case downloads = "Downloads" + + var systemImage: String { + switch self { + case .mix: return "waveform" + case .playlists: return "music.note.list" + case .songs: return "music.note" + case .artists: return "person" + case .albums: return "square.stack" + case .downloads: return "arrow.down.circle" } - .contentShape(Rectangle()) } } -// MARK: - Full list sheets +/// Client-side favorites ordering for the Songs tab's sort menu. +private enum LibrarySongSort: String, CaseIterable { + case title = "Title" + case recent = "Recent" + case artist = "Artist" +} -/// “See All” sheet for a local track list (favorites, recents). -private struct AllTracksSheet: View { - let title: String - let tracks: [Track] +/// One unique artist derived from favorites + recents. +private struct LibraryArtistItem: Identifiable { + let id: String // lower-cased name — the unique key + var channelId: String // "" when no ArtistRef carried one + let name: String + let artwork: String? + let artworkAlt: String? +} - @Environment(\.dismiss) private var dismiss +/// One unique album derived from favorites + recents. +private struct LibraryAlbumItem: Identifiable { + let id: String // lower-cased name — the unique key + var browseId: String // "" when no AlbumRef carried one + let name: String + let artist: String + let artwork: String? + let artworkAlt: String? +} - var body: some View { - NavigationStack { - List { - if tracks.isEmpty { - EmptyState(title: "Nothing Here Yet", hint: "Songs you add will show up here.") - .listRowBackground(Color.clear) - } else { - ForEach(Array(tracks.enumerated()), id: \.offset) { pair in - TrackRow(track: pair.element, context: tracks) - } - } - } - .listStyle(.plain) - .navigationTitle(title) - .navigationBarTitleDisplayMode(.inline) - .toolbar { - ToolbarItem(placement: .topBarTrailing) { - Button("Done") { dismiss() } - } - } - } - .carmenDestinations() - } +/// Payload for the item-based navigation push (see the note on +/// `navigationDestination` in `LibraryView.body`). +private struct LibraryRoutePush: Hashable { + let route: Route } // MARK: - Local playlist sheet /// One on-device playlist: artwork header with Play/Shuffle, an editable -/// track list (EditButton + onDelete) and TrackRow's context menus. +/// track list (EditButton + onDelete) and every track's Material menu. struct LocalPlaylistSheet: View { let playlist: LocalPlaylist @Environment(LibraryStore.self) private var library @Environment(Player.self) private var player + @Environment(\.m3) private var m3 @Environment(\.dismiss) private var dismiss /// Re-read the store on every render so edits show up immediately. @@ -344,20 +740,32 @@ struct LocalPlaylistSheet: View { } if current.tracks.isEmpty { Section { - EmptyState(title: "No Songs Yet", hint: "Add songs from the menu on any track.") - .listRowBackground(Color.clear) - .listRowSeparator(.hidden) + M3EmptyState( + title: "No Songs Yet", + hint: "Add songs from the menu on any track.", + systemImage: "music.note.list" + ) + .listRowBackground(Color.clear) + .listRowSeparator(.hidden) } } else { Section { ForEach(Array(current.tracks.enumerated()), id: \.offset) { pair in - TrackRow(track: pair.element, context: current.tracks) + ListRow( + track: pair.element, + isPlaying: player.current?.videoId == pair.element.videoId, + onTap: { player.play(current.tracks, startAt: pair.offset) } + ) + .listRowInsets(EdgeInsets()) + .listRowBackground(Color.clear) } .onDelete(perform: remove) } } } .listStyle(.plain) + .scrollContentBackground(.hidden) + .background { m3.background.ignoresSafeArea() } .navigationTitle(current.name) .navigationBarTitleDisplayMode(.inline) .toolbar { @@ -384,24 +792,26 @@ struct LocalPlaylistSheet: View { ) .frame(width: 160, height: 160) Text("\(current.tracks.count) songs") - .font(.subheadline) - .foregroundStyle(.secondary) + .font(.m3BodyMedium) + .foregroundStyle(m3.onSurfaceVariant) if !current.tracks.isEmpty { HStack(spacing: 12) { - GlassPrimaryButton { + Button { player.play(current.tracks) } label: { Label("Play", systemImage: "play.fill") - .font(.body.weight(.semibold)) } - GlassPlainButton { + .buttonStyle(M3FilledStyle()) + + Button { player.shuffle = true player.play(current.tracks.shuffled()) } label: { - Image(systemName: "shuffle") - .font(.body.weight(.semibold)) + Label("Shuffle", systemImage: "shuffle") } + .buttonStyle(M3TonalStyle()) } + .padding(.horizontal, 2) } } .frame(maxWidth: .infinity) diff --git a/ios/Carmen/Views/LyricsView.swift b/ios/Carmen/Views/LyricsView.swift index c59cee2..8f1b3cd 100644 --- a/ios/Carmen/Views/LyricsView.swift +++ b/ios/Carmen/Views/LyricsView.swift @@ -2,57 +2,88 @@ // LyricsView.swift // Carmen — the lyrics panel shown inside the Now Playing screen. // -// Apple-Music-style karaoke: the current line is bold rounded white and -// sharp, every other line sits blurred and dimmed around it, and the swap -// rides a spring. Lines auto-scroll with the playhead and seek on tap -// (same as the web client); `♪` instrumental rows stay quiet. Providers -// that only gave plain text render as a scrollable paragraph with the -// source credited underneath. +// Apple-Music-style karaoke: the current line is bold white and sharp, +// every other line sits blurred and dimmed around it, and the swap rides a +// spring. Lines auto-scroll with the playhead and seek on tap (same as the +// web client); `♪` instrumental rows stay quiet. Providers that only gave +// plain text (or a synced-off preference) render as a scrollable paragraph +// with the source credited underneath. M3 chrome: a small panel header and +// shimmer skeletons while the lyrics load. // import SwiftUI struct LyricsView: View { @Environment(Player.self) private var player + @Environment(\.m3) private var m3 var body: some View { - Group { - if player.lyricsLoading { - ProgressView() - .frame(maxWidth: .infinity, maxHeight: .infinity) - } else if let lyrics = player.lyrics { - if lyrics.hasSyncedLines, let lines = lyrics.synced { - syncedBody(lines) - } else if let plain = lyrics.plainText { - unsyncedBody(plain, source: lyrics.source) + VStack(spacing: 0) { + header + + Group { + if player.lyricsLoading { + loading + } else if let lyrics = player.lyrics { + if lyrics.hasSyncedLines, player.syncedLyricsEnabled, let lines = lyrics.synced { + syncedBody(lines) + } else if let plain = lyrics.plainText { + unsyncedBody(plain, source: lyrics.source) + } else { + unavailable + } } else { unavailable } - } else { - unavailable } + .frame(maxWidth: .infinity, maxHeight: .infinity) } .padding(.horizontal, 20) - .padding(.vertical, 8) + .padding(.bottom, 8) } - private var unavailable: some View { - VStack(spacing: 8) { - Image(systemName: "quote.opening") - .font(.title2) - .foregroundStyle(.secondary) - Text("No lyrics available") - .font(.subheadline) - .foregroundStyle(.secondary) + /// Small panel header — the Material chrome around the karaoke body. + private var header: some View { + HStack(spacing: 8) { + Text("Lyrics") + .font(.m3TitleMedium) + .foregroundStyle(m3.onSurface) + Spacer(minLength: 8) } - .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding(.bottom, 6) + } + + /// Skeleton shaped like a run of lyric lines while the fetch resolves. + private var loading: some View { + VStack(alignment: .leading, spacing: 18) { + ShimmerBlock(height: 26, radius: 10) + .frame(width: 230) + ShimmerBlock(height: 26, radius: 10) + .frame(width: 170) + ShimmerBlock(height: 26, radius: 10) + .frame(width: 280) + ShimmerBlock(height: 20, radius: 9) + .frame(width: 200) + ShimmerBlock(height: 20, radius: 9) + .frame(width: 250) + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .padding(.top, 12) + } + + private var unavailable: some View { + M3EmptyState( + title: "No Lyrics", + hint: "No lyrics came back for this song.", + systemImage: "quote.opening" + ) } // MARK: Synced - /// Apple-Music-style karaoke: the current line is bold, rounded, pure - /// white and sharp; every other line sits blurred and dimmed around it, - /// and the swap between them rides a spring. Tap any line to seek there. + /// Karaoke: the current line is bold, pure white and sharp; every other + /// line sits blurred and dimmed around it, and the swap between them + /// rides a spring. Tap any line to seek there. private func syncedBody(_ lines: [Lyrics.Line]) -> some View { // The active *vocal* line — filler (`♪`) rows never get highlighted. let elapsedMs = Int(player.currentTime * 1000) @@ -71,11 +102,10 @@ struct LyricsView: View { .font( .system( size: isActive ? 28 : 20, - weight: isActive ? .bold : .semibold, - design: .rounded + weight: isActive ? .bold : .semibold ) ) - .foregroundStyle(isActive ? Color.white : Color.white.opacity(0.4)) + .foregroundStyle(Color.white.opacity(isActive ? 1 : 0.4)) .lineLimit(2) .multilineTextAlignment(.leading) .frame(maxWidth: .infinity, alignment: .leading) @@ -120,16 +150,19 @@ struct LyricsView: View { ScrollView { VStack(alignment: .leading, spacing: 14) { Text(plain) - .font(.title3) + .font(.m3TitleLarge) + .foregroundStyle(m3.onSurface) .lineSpacing(6) .frame(maxWidth: .infinity, alignment: .leading) + .padding(.top, 8) if let source, !source.isEmpty { Text("Lyrics: \(source)") - .font(.caption) - .foregroundStyle(.secondary) + .font(.m3BodySmall) + .foregroundStyle(m3.onSurfaceVariant) } } .padding(.vertical, 8) + .frame(maxWidth: .infinity, alignment: .leading) } } } diff --git a/ios/Carmen/Views/MiniPlayer.swift b/ios/Carmen/Views/MiniPlayer.swift index 09deade..27e6cb5 100644 --- a/ios/Carmen/Views/MiniPlayer.swift +++ b/ios/Carmen/Views/MiniPlayer.swift @@ -1,55 +1,56 @@ // // MiniPlayer.swift -// Carmen — the floating now-playing bar above the tab bar. +// Carmen — the Material now-playing bar stacked on the nav pill. // -// Tapping (or dragging up) expands the full player; the trailing controls -// are the full transport — previous, play/pause and next. A hairline progress -// rail rides the top edge and the play button carries the chosen accent — -// same chrome the frontend's PlayerBar shows. The queue has its own tab, -// next to Search. +// Android's MiniPlayer: a 70pt surface bar with the cover thumb, the +// title/artist pair and the full transport (previous / filled play circle / +// next). A hairline progress rail rides the top edge. Tapping or swiping up +// expands the full player. The bar is a plain rectangle — RootView stacks it +// on the nav pill and clips the combined blob, so no corners live here. // import SwiftUI struct MiniPlayer: View { @Environment(Player.self) private var player + @Environment(\.m3) private var m3 var body: some View { if let track = player.current { - HStack(spacing: 10) { - ArtworkView(url: track.artwork, fallbackURL: track.artworkAlt, cornerRadius: 6) - .frame(width: 40, height: 40) - .shadow(color: .black.opacity(0.2), radius: 3, y: 1) + HStack(spacing: 12) { + ArtworkView(url: track.artwork, fallbackURL: track.artworkAlt, cornerRadius: 10) + .frame(width: 46, height: 46) - VStack(alignment: .leading, spacing: 1) { + VStack(alignment: .leading, spacing: 2) { Text(track.title) - .font(.subheadline.weight(.semibold)) + .font(.m3TitleSmall) + .foregroundStyle(m3.onSurface) .lineLimit(1) - .foregroundStyle(.primary) + .minimumScaleFactor(0.8) Text(track.artistLine) - .font(.caption) + .font(.m3BodySmall) + .foregroundStyle(m3.onSurfaceVariant) .lineLimit(1) - .foregroundStyle(.secondary) + .minimumScaleFactor(0.8) } - Spacer(minLength: 8) + Spacer(minLength: 4) if player.isBuffering { ProgressView() .controlSize(.small) - .padding(.trailing, 2) + .tint(m3.primary) } Button { player.previous() } label: { Image(systemName: "backward.fill") - .font(.footnote) - .foregroundStyle(.primary) + .font(.footnote.weight(.semibold)) .frame(width: 28, height: 32) .contentShape(Rectangle()) } - .buttonStyle(.plain) + .buttonStyle(M3IconButtonStyle()) .opacity(player.hasPrevious ? 1 : 0.35) .accessibilityLabel("Previous") @@ -57,10 +58,10 @@ struct MiniPlayer: View { player.togglePlayPause() } label: { Image(systemName: player.isPlaying ? "pause.fill" : "play.fill") - .font(.title3) - .foregroundStyle(Color.carmenOnAccent) - .frame(width: 32, height: 32) - .background(Circle().fill(Color.carmen)) + .font(.body.weight(.semibold)) + .foregroundStyle(m3.onPrimary) + .frame(width: 40, height: 40) + .background(m3.primary, in: Circle()) .contentShape(Circle()) } .buttonStyle(.plain) @@ -70,23 +71,22 @@ struct MiniPlayer: View { player.next() } label: { Image(systemName: "forward.fill") - .font(.footnote) - .foregroundStyle(.primary) + .font(.footnote.weight(.semibold)) .frame(width: 28, height: 32) .contentShape(Rectangle()) } - .buttonStyle(.plain) + .buttonStyle(M3IconButtonStyle()) .opacity(player.hasNext ? 1 : 0.35) .accessibilityLabel("Next") } .padding(.horizontal, 12) - .frame(height: 56) - .glassCard(cornerRadius: 16) + .frame(height: M3Dim.miniPlayer) + .background(m3.surfaceContainer) .overlay(alignment: .top) { - // Progress rail, inset so it never pokes out of the corners. + // Hairline progress rail, inset so it never pokes out. GeometryReader { proxy in Capsule() - .fill(Color.carmen) + .fill(m3.primary) .frame(width: proxy.size.width * player.progress, height: 2) } .frame(height: 2) diff --git a/ios/Carmen/Views/NowPlayingView.swift b/ios/Carmen/Views/NowPlayingView.swift index 4c94c82..f48e7f4 100644 --- a/ios/Carmen/Views/NowPlayingView.swift +++ b/ios/Carmen/Views/NowPlayingView.swift @@ -1,36 +1,33 @@ // // NowPlayingView.swift -// Carmen — the full player. +// Carmen — the full player (Material 3 clone of the Android player screen). // // Mounted by RootView as an overlay that slides up from the mini bar // (`Player.expansion` drives the offset; dragging the header tracks the -// finger back down). Inside: adaptive artwork (motion video when the track -// has any, growing with the sheet), metadata with a favorite heart, the -// scrubber, transport with shuffle/repeat, and the Lyrics / Related -// action pill that swaps the content area for a panel with a compact card + -// playing bar above it — the web client's panel takeover, with the scrubber -// and transport always one glance away. (The queue lives in its own tab, -// next to Search, like the frontend's drawer.) +// finger back down — that contract lives in RootView and must not change). +// Inside: the ambient blurred-cover backdrop washed with the artwork's own +// colour (adaptive accent), motion artwork that grows with the sheet, +// centered metadata, a capsule scrubber, transport with shuffle/repeat, and +// a bottom action bar (Lyrics / Queue / Related / Download) whose toggles +// swap the content area for a panel takeover — compact now-playing card + +// thin progress rail + the panel body, exactly like the web client's panel +// takeover, with dismiss one tap away. // -// Theming is ambient: the blurred cover fills the background and, when -// Settings → Appearance → Adaptive Accent is on, the cover's own colour -// washes the whole player so the surface belongs to the record. Panels -// stay transparent over that wash instead of masking it with a material. -// Liquid Glass via the Glass.swift wrappers; the player is always a dark -// surface, so the content is evaluated as dark regardless of system -// appearance. +// The player is always a dark surface: the content is re-themed with dark +// Material roles and evaluated as dark regardless of system appearance. // import SwiftUI enum PlayerPanel: String, CaseIterable, Identifiable { - case lyrics, related + case lyrics, queue, related var id: String { rawValue } var title: String { switch self { case .lyrics: return "Lyrics" + case .queue: return "Queue" case .related: return "Related" } } @@ -38,6 +35,7 @@ enum PlayerPanel: String, CaseIterable, Identifiable { var symbol: String { switch self { case .lyrics: return "text.quote" + case .queue: return "list.bullet" case .related: return "sparkles" } } @@ -45,6 +43,7 @@ enum PlayerPanel: String, CaseIterable, Identifiable { struct NowPlayingView: View { @Environment(Player.self) private var player + @Environment(\.m3) private var m3 @State private var activePanel: PlayerPanel? @State private var scrubTime: Double = 0 @@ -64,8 +63,8 @@ struct NowPlayingView: View { } } .onAppear { - // A panel the player asked for (mini-bar queue button, Related - // tap while playing) — open straight into it once. + // A panel the player asked for (openPlayer(panel:)) — open + // straight into it once. if let panel = player.pendingPanel { activePanel = panel player.pendingPanel = nil @@ -84,7 +83,7 @@ struct NowPlayingView: View { if let panel = activePanel { compactCard(for: track) - playingBar + thinProgressBar panelBody(panel) .frame(maxWidth: .infinity, maxHeight: .infinity) .transition(.opacity.combined(with: .move(edge: .bottom))) @@ -97,7 +96,10 @@ struct NowPlayingView: View { actionBar(for: track) } } - .environment(\.colorScheme, .dark) // the backdrop is always dark + // The backdrop is always dark — re-derive the Material roles for the + // player subtree (m3Theme reads the scheme set right below it). + .m3Theme() + .environment(\.colorScheme, .dark) .animation(.spring(response: 0.35, dampingFraction: 0.85), value: activePanel) } @@ -116,15 +118,15 @@ struct NowPlayingView: View { private var header: some View { VStack(spacing: 6) { Capsule() - .fill(.secondary.opacity(0.5)) + .fill(m3.onSurfaceVariant.opacity(0.5)) .frame(width: 36, height: 5) HStack { Button { player.closePlayer() } label: { Image(systemName: "chevron.down") - .font(.subheadline.weight(.semibold)) - .foregroundStyle(.secondary) + .font(.footnote.weight(.semibold)) + .foregroundStyle(m3.onSurfaceVariant) .frame(width: 32, height: 32) .contentShape(Rectangle()) } @@ -133,9 +135,9 @@ struct NowPlayingView: View { Spacer() Text("NOW PLAYING") - .font(.caption2.weight(.semibold)) + .font(.m3LabelSmall) .tracking(1.2) - .foregroundStyle(.secondary) + .foregroundStyle(m3.onSurfaceVariant) Spacer() Color.clear.frame(width: 32, height: 32) @@ -183,58 +185,80 @@ struct NowPlayingView: View { } .aspectRatio(1, contentMode: .fit) .frame(maxWidth: .infinity) - .clipShape(RoundedRectangle(cornerRadius: 12, style: .continuous)) - .shadow(color: .black.opacity(0.35), radius: 22, y: 12) + .clipShape(M3Shape.card) + .shadow(color: m3.scrim.opacity(0.35), radius: 22, y: 12) .scaleEffect(0.5 + 0.5 * expansion) .opacity(0.35 + 0.65 * expansion) - .frame(maxHeight: 420) - .padding(.bottom, 20) + .frame(maxHeight: 330) + .padding(.bottom, 16) } private func metadata(for track: Track) -> some View { HStack(alignment: .center, spacing: 12) { - VStack(alignment: .leading, spacing: 3) { + VStack(spacing: 4) { HStack(spacing: 5) { Text(track.title) - .font(.title3.weight(.semibold)) - .lineLimit(1) - .foregroundStyle(.primary) + .font(.m3TitleLarge) + .foregroundStyle(m3.onSurface) + .lineLimit(2) + .minimumScaleFactor(0.8) + .multilineTextAlignment(.center) if track.isExplicit { ExplicitBadge() } } - HStack(spacing: 4) { - Text(track.artistLine) - .font(.subheadline) - .lineLimit(1) - .foregroundStyle(.secondary) - if !track.albumName.isEmpty { - Text("·") - .foregroundStyle(.secondary) - Text(track.albumName) - .font(.subheadline) - .lineLimit(1) - .foregroundStyle(.secondary.opacity(0.8)) - } - } + Text(track.artistLine) + .font(.m3BodyLarge) + .foregroundStyle(m3.onSurfaceVariant) + .lineLimit(1) + .minimumScaleFactor(0.85) } - Spacer(minLength: 8) + .frame(maxWidth: .infinity, alignment: .center) + FavoriteButton(track: track) } .padding(.bottom, 14) } + /// Capsule scrubber: surface-highest track, primary fill up to the + /// playhead, circular thumb — seek on release. private var scrubber: some View { - VStack(spacing: 4) { - Slider( - value: Binding( - get: { isScrubbing ? scrubTime : player.currentTime }, - set: { scrubTime = $0 } - ), - in: 0...max(player.duration, 1) - ) { editing in - isScrubbing = editing - if !editing { player.seek(to: scrubTime) } + VStack(spacing: 6) { + GeometryReader { proxy in + let width = max(proxy.size.width, 1) + let total = max(player.duration, 1) + let shown = isScrubbing ? scrubTime : player.currentTime + let fraction = min(max(shown / total, 0), 1) + let playable = player.duration > 0 + + ZStack(alignment: .leading) { + Capsule() + .fill(m3.surfaceContainerHighest) + Capsule() + .fill(m3.primary) + .frame(width: max(0, width * fraction)) + Circle() + .fill(m3.primary) + .frame(width: 14, height: 14) + .offset(x: max(0, min(width - 14, width * fraction - 7))) + } + .frame(height: 20) + .contentShape(Rectangle()) + .opacity(playable ? 1 : 0.4) + .gesture( + DragGesture(minimumDistance: 0) + .onChanged { value in + // No duration yet → the track is parked, not seekable. + guard player.duration > 0 else { return } + isScrubbing = true + scrubTime = min(max(value.location.x / width, 0), 1) * player.duration + } + .onEnded { _ in + guard isScrubbing else { return } + isScrubbing = false + player.seek(to: scrubTime) + } + ) } - .tint(.white) + .frame(height: 20) HStack { Text(formatClock(isScrubbing ? scrubTime : player.currentTime)) @@ -245,34 +269,34 @@ struct NowPlayingView: View { ) ) } - .font(.caption.monospacedDigit()) - .foregroundStyle(.secondary) + .font(.m3LabelSmall.monospacedDigit()) + .foregroundStyle(m3.onSurfaceVariant) } - .padding(.bottom, 18) + .padding(.bottom, 14) } - /// shuffle · previous · play · next · repeat — Apple Music's order. + /// shuffle · previous · play · next · repeat — evenly spaced, the play + /// circle the only filled surface. private var transport: some View { HStack(spacing: 0) { Button { player.shuffle.toggle() } label: { Image(systemName: "shuffle") - .font(.title3) - .foregroundStyle(player.shuffle ? Color.carmen : .secondary) + .font(.system(size: 17, weight: .medium)) .frame(maxWidth: .infinity) } - .buttonStyle(.plain) + .buttonStyle(M3IconButtonStyle(foreground: player.shuffle ? m3.primary : m3.onSurfaceVariant)) Button { player.previous() } label: { Image(systemName: "backward.fill") - .font(.system(size: 28)) - .foregroundStyle(.primary) + .font(.system(size: 22, weight: .semibold)) + .foregroundStyle(m3.onSurface) .frame(maxWidth: .infinity) } - .buttonStyle(.plain) + .buttonStyle(M3IconButtonStyle(foreground: m3.onSurface)) .opacity(player.hasPrevious ? 1 : 0.35) Button { @@ -280,14 +304,15 @@ struct NowPlayingView: View { } label: { ZStack { Circle() - .fill(.primary.opacity(0.12)) + .fill(m3.primary) if player.isBuffering { ProgressView() .controlSize(.large) + .tint(m3.onPrimary) } else { Image(systemName: player.isPlaying ? "pause.fill" : "play.fill") - .font(.system(size: 28)) - .foregroundStyle(.primary) + .font(.system(size: 26, weight: .semibold)) + .foregroundStyle(m3.onPrimary) .offset(x: player.isPlaying ? 0 : 2) // optical centering } } @@ -295,16 +320,17 @@ struct NowPlayingView: View { .frame(maxWidth: .infinity) } .buttonStyle(.plain) + .accessibilityLabel(player.isPlaying ? "Pause" : "Play") Button { player.next() } label: { Image(systemName: "forward.fill") - .font(.system(size: 28)) - .foregroundStyle(.primary) + .font(.system(size: 22, weight: .semibold)) + .foregroundStyle(m3.onSurface) .frame(maxWidth: .infinity) } - .buttonStyle(.plain) + .buttonStyle(M3IconButtonStyle(foreground: m3.onSurface)) .opacity(player.hasNext ? 1 : 0.35) Button { @@ -315,139 +341,109 @@ struct NowPlayingView: View { } } label: { Image(systemName: player.repeatMode == .one ? "repeat.1" : "repeat") - .font(.title3) - .foregroundStyle(player.repeatMode == .off ? Color.secondary : Color.carmen) + .font(.system(size: 17, weight: .medium)) .frame(maxWidth: .infinity) } - .buttonStyle(.plain) + .buttonStyle( + M3IconButtonStyle( + foreground: player.repeatMode == .off ? m3.onSurfaceVariant : m3.primary + ) + ) + .accessibilityLabel("Repeat \(player.repeatMode.label)") } .padding(.bottom, 14) } - /// Panel mode keeps the scrubber + transport alive under the compact - /// card (queueing songs shouldn't stop you from skipping or seeking). - private var playingBar: some View { - VStack(spacing: 2) { - Slider( - value: Binding( - get: { isScrubbing ? scrubTime : player.currentTime }, - set: { scrubTime = $0 } - ), - in: 0...max(player.duration, 1) - ) { editing in - isScrubbing = editing - if !editing { player.seek(to: scrubTime) } - } - .tint(.white) - - HStack(spacing: 0) { - Button { - player.previous() - } label: { - Image(systemName: "backward.fill") - .font(.system(size: 20)) - .foregroundStyle(.primary) - .frame(maxWidth: .infinity) - } - .buttonStyle(.plain) - .opacity(player.hasPrevious ? 1 : 0.35) - - Button { - player.togglePlayPause() - } label: { - ZStack { - Circle() - .fill(.primary.opacity(0.12)) - if player.isBuffering { - ProgressView() - .controlSize(.regular) - } else { - Image(systemName: player.isPlaying ? "pause.fill" : "play.fill") - .font(.system(size: 20)) - .foregroundStyle(.primary) - .offset(x: player.isPlaying ? 0 : 1.5) - } - } - .frame(width: 44, height: 44) - .frame(maxWidth: .infinity) - } - .buttonStyle(.plain) - - Button { - player.next() - } label: { - Image(systemName: "forward.fill") - .font(.system(size: 20)) - .foregroundStyle(.primary) - .frame(maxWidth: .infinity) - } - .buttonStyle(.plain) - .opacity(player.hasNext ? 1 : 0.35) + /// Panel mode keeps the playhead one glance away: a 3pt progress rail + /// under the compact card (seeking stays in the artwork view). + private var thinProgressBar: some View { + GeometryReader { proxy in + ZStack(alignment: .leading) { + Capsule() + .fill(m3.surfaceContainerHighest) + Capsule() + .fill(m3.primary) + .frame(width: max(0, proxy.size.width * player.progress)) } } - .padding(.horizontal, 24) - .padding(.bottom, 10) + .frame(height: 3) + .padding(.horizontal, 20) + .padding(.bottom, 12) } // MARK: Panels - /// The bottom pill — the mini bar's twin (same 56 pt height, same 16 pt - /// radius, same inset): Lyrics / Related cells + the download control. - /// Tapping the active cell returns to the artwork view (frontend parity). + /// The bottom action bar — Lyrics / Queue / Related toggles plus the + /// download control for the current track. Tapping the active toggle + /// returns to the artwork view (frontend parity). private func actionBar(for track: Track) -> some View { HStack(spacing: 0) { ForEach(PlayerPanel.allCases) { panel in Button { - withAnimation { + withAnimation(.spring(response: 0.35, dampingFraction: 0.85)) { activePanel = activePanel == panel ? nil : panel } } label: { - VStack(spacing: 2) { + VStack(spacing: 3) { Image(systemName: panel.symbol) - .font(.footnote) + .font(.system(size: 16, weight: .medium)) + .frame(height: 30) Text(panel.title) - .font(.system(size: 10, weight: .medium)) + .font(.m3LabelSmall) + .lineLimit(1) + .minimumScaleFactor(0.8) } - .foregroundStyle(activePanel == panel ? Color.carmen : Color.secondary) + .foregroundStyle(activePanel == panel ? m3.primary : m3.onSurfaceVariant) .frame(maxWidth: .infinity) .contentShape(Rectangle()) } .buttonStyle(.plain) + .accessibilityLabel(panel.title) } - DownloadButton(track: track, diameter: 20) - .frame(maxWidth: .infinity) + VStack(spacing: 3) { + DownloadButton(track: track, diameter: 18) + .frame(height: 30) + Text("Download") + .font(.m3LabelSmall) + .lineLimit(1) + .minimumScaleFactor(0.8) + .foregroundStyle(m3.onSurfaceVariant) + } + .frame(maxWidth: .infinity) } .padding(.horizontal, 12) - .frame(height: 56) - .glassCard(cornerRadius: 16) - .padding(.horizontal, 8) + .padding(.vertical, 8) + .background(m3.surfaceContainerHigh, in: M3Shape.sheet) + .padding(.horizontal, 12) .padding(.bottom, 6) } /// Compact now-playing card above an open panel (the takeover header). private func compactCard(for track: Track) -> some View { HStack(spacing: 12) { - ArtworkView(url: track.artwork, fallbackURL: track.artworkAlt, cornerRadius: 6) + ArtworkView(url: track.artwork, fallbackURL: track.artworkAlt, cornerRadius: 8) .frame(width: 44, height: 44) VStack(alignment: .leading, spacing: 2) { Text(track.title) - .font(.subheadline.weight(.semibold)) + .font(.m3BodyMedium.weight(.semibold)) + .foregroundStyle(m3.onSurface) .lineLimit(1) - .foregroundStyle(.primary) Text(track.artistLine) - .font(.caption) + .font(.m3BodySmall) + .foregroundStyle(m3.onSurfaceVariant) .lineLimit(1) - .foregroundStyle(.secondary) } Spacer(minLength: 8) FavoriteButton(track: track) Button { - withAnimation { activePanel = nil } + withAnimation(.spring(response: 0.35, dampingFraction: 0.85)) { + activePanel = nil + } } label: { Image(systemName: "chevron.down") .font(.subheadline.weight(.semibold)) - .foregroundStyle(.secondary) + .foregroundStyle(m3.onSurfaceVariant) .frame(width: 32, height: 32) .contentShape(Rectangle()) } @@ -455,7 +451,7 @@ struct NowPlayingView: View { .accessibilityLabel("Back to Player") } .padding(.horizontal, 20) - .padding(.bottom, 10) + .padding(.bottom, 8) } /// Panels float over the ambient wash: a clear → black scrim dims the @@ -468,8 +464,8 @@ struct NowPlayingView: View { LinearGradient( stops: [ .init(color: .clear, location: 0), - .init(color: .black.opacity(0.35), location: 0.35), - .init(color: .black.opacity(0.6), location: 1), + .init(color: m3.scrim.opacity(0.35), location: 0.35), + .init(color: m3.scrim.opacity(0.6), location: 1), ], startPoint: .top, endPoint: .bottom @@ -481,6 +477,7 @@ struct NowPlayingView: View { private func panelContent(_ panel: PlayerPanel) -> some View { switch panel { case .lyrics: LyricsView() + case .queue: QueuePanel() case .related: RelatedPanel() } } diff --git a/ios/Carmen/Views/QueuePanel.swift b/ios/Carmen/Views/QueuePanel.swift new file mode 100644 index 0000000..9aed6df --- /dev/null +++ b/ios/Carmen/Views/QueuePanel.swift @@ -0,0 +1,177 @@ +// +// QueuePanel.swift +// Carmen — the player's Queue panel (Android's Queue bottom sheet, and the +// body the Queue tab can reuse). +// +// Header with the Shuffle toggle and "Clear upcoming", a Playing Now card +// with the animated bars, then the upcoming rows: tap to jump the playhead, +// trailing ✕ to remove, long-press drag to reorder (drag a row onto another +// and `Player.moveInQueue` rewrites the queue while keeping the playhead on +// the current track). +// + +import SwiftUI + +struct QueuePanel: View { + @Environment(Player.self) private var player + @Environment(\.m3) private var m3 + + /// Absolute queue index of the first "up next" row. + private var upcomingStart: Int { player.currentIndex + 1 } + private var upcoming: ArraySlice { + player.queue.indices.contains(upcomingStart) + ? player.queue[upcomingStart...] : player.queue[0..<0] + } + + var body: some View { + VStack(spacing: 0) { + header + + if player.current == nil { + M3EmptyState( + title: "Queue is Empty", + hint: "Play a song to start building your queue.", + systemImage: "list.bullet" + ) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + ScrollView { + LazyVStack(spacing: 0) { + if let current = player.current { + playingNow(current) + .padding(.bottom, 10) + } + + if upcoming.isEmpty { + M3EmptyState(title: "Nothing up next", systemImage: "tray") + .padding(.top, 20) + } else { + upcomingList + } + } + .padding(.horizontal, 16) + .padding(.bottom, 16) + } + } + } + } + + /// Panel header: title + Shuffle toggle + Clear upcoming (Android's + /// queue header actions). + private var header: some View { + HStack(spacing: 8) { + Text("Queue") + .font(.m3TitleMedium) + .foregroundStyle(m3.onSurface) + Spacer(minLength: 8) + FilterChip(isSelected: player.shuffle, title: "Shuffle", systemImage: "shuffle") { + player.shuffle.toggle() + } + Button("Clear upcoming") { + player.clearUpcoming() + } + .buttonStyle(M3TextStyle(compact: true)) + .disabled(upcoming.isEmpty) + .opacity(upcoming.isEmpty ? 0.4 : 1) + } + .padding(.horizontal, 4) + .padding(.bottom, 8) + } + + /// The song that owns the queue — card wash + animated bars. + private func playingNow(_ track: Track) -> some View { + M3Card(padding: 12) { + HStack(spacing: 12) { + ArtworkView(url: track.artwork, fallbackURL: track.artworkAlt, cornerRadius: 10) + .frame(width: 44, height: 44) + VStack(alignment: .leading, spacing: 2) { + Text(track.title) + .font(.m3BodyLarge.weight(.medium)) + .foregroundStyle(m3.primary) + .lineLimit(1) + Text(track.artistLine) + .font(.m3BodySmall) + .foregroundStyle(m3.onSurfaceVariant) + .lineLimit(1) + } + Spacer(minLength: 8) + PlayingIndicator() + } + .contentShape(Rectangle()) + } + } + + private var upcomingList: some View { + VStack(spacing: 0) { + ForEach(Array(upcoming.enumerated()), id: \.offset) { pair in + let offset = pair.offset + let track = pair.element + let index = upcomingStart + offset + + QueuePanelRow(track: track) + .contentShape(Rectangle()) + .onTapGesture { player.jump(to: index) } + // Drag to reorder: grab a row, drop it on another — the + // indices are absolute so moveInQueue sees queue space. + .draggable(index) + .dropDestination(for: Int.self) { payloads, _ in + guard let from = payloads.first, from != index, + player.queue.indices.contains(from) + else { return false } + player.moveInQueue(from: IndexSet(integer: from), to: index) + return true + } + } + } + } +} + +/// One compact upcoming row: 44pt thumb, title/artist, trailing ✕ that +/// removes the entry without hunting for a swipe gesture. +private struct QueuePanelRow: View { + let track: Track + + @Environment(Player.self) private var player + @Environment(\.m3) private var m3 + + var body: some View { + HStack(spacing: 12) { + ArtworkView(url: track.artwork, fallbackURL: track.artworkAlt, cornerRadius: 10) + .frame(width: 44, height: 44) + + VStack(alignment: .leading, spacing: 2) { + HStack(spacing: 5) { + Text(track.title) + .font(.m3BodyMedium) + .foregroundStyle(m3.onSurface) + .lineLimit(1) + if track.isExplicit { ExplicitBadge() } + } + Text(track.artistLine) + .font(.m3BodySmall) + .foregroundStyle(m3.onSurfaceVariant) + .lineLimit(1) + } + .frame(maxWidth: .infinity, alignment: .leading) + + Button { + player.removeFromQueue(at: IndexSet(integer: absoluteIndex)) + } label: { + Image(systemName: "xmark") + .font(.footnote.weight(.semibold)) + .foregroundStyle(m3.onSurfaceVariant) + .frame(width: 28, height: 28) + .contentShape(Circle()) + } + .buttonStyle(.plain) + .accessibilityLabel("Remove from queue") + } + .padding(.vertical, 6) + } + + /// The row's place in the full queue (the panel only renders upcoming + /// entries, so the offset has to be recovered from the queue itself). + private var absoluteIndex: Int { + player.queue.firstIndex { $0.videoId == track.videoId } ?? 0 + } +} diff --git a/ios/Carmen/Views/RelatedPanel.swift b/ios/Carmen/Views/RelatedPanel.swift index f14b08b..debe064 100644 --- a/ios/Carmen/Views/RelatedPanel.swift +++ b/ios/Carmen/Views/RelatedPanel.swift @@ -11,31 +11,61 @@ import SwiftUI struct RelatedPanel: View { @Environment(Player.self) private var player + @Environment(\.m3) private var m3 /// The list only describes the track it was fetched for — show the /// skeleton while it catches up after a skip. private var isStale: Bool { player.relatedVideoId != player.current?.videoId } var body: some View { - if isStale || player.relatedLoading { - LoadingState(label: "Finding more like this…") - } else if player.related.isEmpty { - EmptyState( - title: "Nothing Related", - hint: "No recommendations came back for this song." - ) - } else { - List { - Section { - ForEach(player.related) { track in - TrackRow(track: track, context: player.related) + VStack(spacing: 0) { + header + + if isStale || player.relatedLoading { + ScrollView { + ShimmerList(rows: 6) + .padding(.top, 4) + } + } else if player.related.isEmpty { + M3EmptyState( + title: "Nothing Related", + hint: "No recommendations came back for this song.", + systemImage: "sparkles" + ) + .frame(maxWidth: .infinity, maxHeight: .infinity) + } else { + ScrollView { + LazyVStack(spacing: 0) { + ForEach(player.related) { track in + ListRow( + track: track, + isPlaying: track.videoId == player.current?.videoId, + onTap: { player.play(player.related, startingAt: track) } + ) + } + + Text("More like this — plays automatically when the queue ends.") + .font(.m3BodySmall) + .foregroundStyle(m3.onSurfaceVariant) + .multilineTextAlignment(.center) + .padding(.horizontal, 24) + .padding(.vertical, 16) } - } footer: { - Text("More like this — plays automatically when the queue ends.") - .textCase(nil) + .padding(.bottom, 8) } } - .listStyle(.plain) } } + + /// Small panel header — the Material chrome around the list. + private var header: some View { + HStack(spacing: 8) { + Text("Related") + .font(.m3TitleMedium) + .foregroundStyle(m3.onSurface) + Spacer(minLength: 8) + } + .padding(.horizontal, 4) + .padding(.bottom, 4) + } } diff --git a/ios/Carmen/Views/SearchView.swift b/ios/Carmen/Views/SearchView.swift index eb975ec..44f222f 100644 --- a/ios/Carmen/Views/SearchView.swift +++ b/ios/Carmen/Views/SearchView.swift @@ -1,11 +1,18 @@ // // SearchView.swift -// Carmen — the Search tab: debounced search against the backend, results -// grouped by the API's `category` labels, plus the local search history. +// Carmen — the Search tab in Material 3 dress (Android search parity). +// +// Two screens live here: `SearchView` is the tab (a fake search dock that +// pushes, plus the local recent-search history), `SearchResultsView` is the +// pushed route with the editable field, the Online/Local source switch, the +// type-filter chip row and the grouped results. The request/decode logic is +// the same /search + /search/suggestions contract the old screen used. // import SwiftUI +// MARK: - Types + /// One visual group of the flat search payload — the `category` label of its /// cards, `nil` for unlabelled cards (rendered last, without a header). private struct SearchGroup: Identifiable { @@ -15,187 +22,514 @@ private struct SearchGroup: Identifiable { var id: String { title ?? "" } } +/// The source segmented control: server search vs the on-device library. +private enum SearchSource: Hashable { + case online, local +} + +/// One type-filter chip: the backend's `filter` id and its label. A struct +/// rather than a labelled tuple — key paths can't refer to tuple elements, +/// so `ForEach` keys off `Identifiable` here. +private struct SearchFilter: Identifiable { + let id: String + let label: String +} + +/// Client-side result order (the sort menu next to the filter chips). +private enum ResultSort: Equatable { + case relevance, title, duration + + var title: String { + switch self { + case .relevance: return "Relevance" + case .title: return "Title" + case .duration: return "Duration" + } + } +} + +// MARK: - Search tab + struct SearchView: View { - @Environment(SettingsStore.self) private var settings + @Environment(LibraryStore.self) private var library + @Environment(\.m3) private var m3 + + @State private var showSettings = false + + /// The store already caps history at 10; the prefix keeps the promise. + private var history: [String] { + Array(library.searchHistory.prefix(10)) + } + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 8) { + searchDock + if history.isEmpty { + M3EmptyState( + title: "Search Carmen", + hint: "Find songs, artists, albums and playlists.", + systemImage: "magnifyingglass" + ) + } else { + historySection + } + } + .padding(.top, 8) + .padding(.bottom, 24) + } + .background { m3.background.ignoresSafeArea() } + .toolbar(.hidden, for: .navigationBar) + .safeAreaInset(edge: .top) { + TopAppBar(title: "Search", onOverflow: { showSettings = true }) + } + .sheet(isPresented: $showSettings) { + SettingsView() + } + } + + // MARK: Search dock + + /// `M3SearchDock`'s visuals as a `NavigationLink` — a Button can't nest + /// inside a NavigationLink, so the label is inlined here. + private var searchDock: some View { + NavigationLink(value: Route.searchResults(query: "")) { + HStack(spacing: 10) { + Image(systemName: "magnifyingglass") + .font(.system(size: 17, weight: .medium)) + .foregroundStyle(m3.onSurfaceVariant) + Text("Search songs, albums, artists…") + .font(.m3BodyLarge) + .foregroundStyle(m3.onSurfaceVariant) + .lineLimit(1) + Spacer(minLength: 0) + } + .padding(.horizontal, 16) + .frame(height: 52) + .background(m3.surfaceContainerHigh, in: M3Shape.pill) + } + .buttonStyle(.plain) + .pressScale(0.99) + .padding(.horizontal, 16) + } + + // MARK: Recent searches + + private var historySection: some View { + VStack(alignment: .leading, spacing: 0) { + // LibraryStore has no per-entry removal — offer "Clear history". + SectionHeader(title: "Recent searches", actionTitle: "Clear history") { + library.clearSearchHistory() + } + ForEach(history, id: \.self) { term in + NavigationLink(value: Route.searchResults(query: term)) { + historyRow(term) + } + .buttonStyle(.plain) + } + } + } + + private func historyRow(_ term: String) -> some View { + HStack(spacing: 14) { + Image(systemName: "clock") + .font(.system(size: 16, weight: .medium)) + .foregroundStyle(m3.onSurfaceVariant) + .frame(width: 40, height: 40) + .background(m3.surfaceContainerHighest, in: M3Shape.small) + Text(term) + .font(.m3BodyLarge) + .foregroundStyle(m3.onSurface) + .lineLimit(1) + .minimumScaleFactor(0.85) + Spacer(minLength: 8) + Image(systemName: "arrow.up.left") + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(m3.onSurfaceVariant) + } + .padding(.horizontal, 16) + .frame(height: 64) + .contentShape(Rectangle()) + } +} + +// MARK: - Search results + +struct SearchResultsView: View { + @Environment(\.m3) private var m3 @Environment(LibraryStore.self) private var library @Environment(Player.self) private var player + @Environment(SettingsStore.self) private var settings - @State private var query = "" + @State private var query: String + /// The query that has actually been run — every submit and every tapped + /// history/suggestion term re-keys `.task(id:)` from here. + @State private var submitted: String + @State private var source: SearchSource = .online @State private var results: [MediaCard]? @State private var suggestions: [String] = [] @State private var errorMessage: String? - @State private var isSearching = false + @State private var isLoading = false + @State private var sort: ResultSort = .relevance /// Type filter, frontend parity: same chips, same default (Songs), and - /// it survives leaving the tab like the web client's `?filter=`. + /// it survives leaving the screen like the web client's `?filter=`. @AppStorage("carmen.search.filter") private var filter = "songs" - /// The frontend's chip row — `All` is the empty id the backend treats - /// as "no filter". - private static let filters: [(id: String, label: String)] = [ - ("all", "All"), - ("songs", "Songs"), - ("videos", "Videos"), - ("albums", "Albums"), - ("artists", "Artists"), - ("playlists", "Playlists"), + init(query: String) { + _query = State(initialValue: query) + _submitted = State(initialValue: query.trimmingCharacters(in: .whitespacesAndNewlines)) + } + + /// The frontend's chip row — `all` is the id the backend treats as "no + /// filter"; everything else maps 1:1 onto the server's `filter` values + /// (server/core.py: songs, videos, albums, artists, playlists, + /// community_playlists, featured_playlists, podcasts, …). + private static let filters: [SearchFilter] = [ + SearchFilter(id: "all", label: "All"), + SearchFilter(id: "songs", label: "Songs"), + SearchFilter(id: "videos", label: "Videos"), + SearchFilter(id: "albums", label: "Albums"), + SearchFilter(id: "artists", label: "Artists"), + SearchFilter(id: "playlists", label: "Playlists"), + SearchFilter(id: "podcasts", label: "Podcasts"), + SearchFilter(id: "community_playlists", label: "Community"), + SearchFilter(id: "featured_playlists", label: "Featured Playlists"), ] private var trimmed: String { query.trimmingCharacters(in: .whitespacesAndNewlines) } + /// Re-runs the query on submit, filter change and source change (local + /// needs no network — the guard below short-circuits it). + private var searchKey: String { + "\(submitted)#\(filter)#\(source == .online)" + } + var body: some View { ScrollView { - LazyVStack(alignment: .leading, spacing: 24) { - if trimmed.isEmpty { - historySection - } else if let errorMessage { - ErrorState(message: errorMessage) { - Task { await retrySearch() } - } - } else if let results { - if results.isEmpty { - EmptyState( - title: "No Results", - hint: filter == "all" - ? "Try a different search." - : "Check the spelling or try another filter." - ) - } else { - resultsContent(results) - } - } else { - LoadingState(label: "Searching…") - } + LazyVStack(alignment: .leading, spacing: 16) { + content } - .padding(.vertical, 8) + .padding(.vertical, 12) } - .navigationTitle("Search") - .navigationBarTitleDisplayMode(.large) - .searchable( - text: $query, - placement: .navigationBarDrawer(displayMode: .always), - prompt: "Songs, artists, albums…" - ) - .task(id: "\(trimmed)#\(filter)") { + .background { m3.background.ignoresSafeArea() } + .navigationBarTitleDisplayMode(.inline) + .safeAreaInset(edge: .top) { header } + .task(id: searchKey) { await runSearch() } + .onChange(of: query) { _, value in + // Clearing the field (the ✕) drops straight back to the empty state. + if value.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, !submitted.isEmpty { + submitted = "" + } + } } - // MARK: Type filter (chips) + // MARK: Pinned header (field + source + filters) + + private var header: some View { + VStack(alignment: .leading, spacing: 10) { + M3SearchField( + text: $query, + placeholder: "Songs, artists, albums…", + autoFocus: query.isEmpty, + onSubmit: submit + ) + .padding(.horizontal, 16) - private var filterBar: some View { - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 8) { - ForEach(Self.filters, id: \.id) { option in - Button { - filter = option.id - } label: { - Text(option.label) - .font(.subheadline.weight(.medium)) - .padding(.horizontal, 14) - .padding(.vertical, 7) - .background(Capsule().fill(chipFill(option.id))) - .foregroundStyle(chipInk(option.id)) - .contentShape(Capsule()) + Picker("Source", selection: $source) { + Text("Online").tag(SearchSource.online) + Text("Local").tag(SearchSource.local) + } + .pickerStyle(.segmented) + .padding(.horizontal, 16) + + if source == .online { + filterRow + } + } + .padding(.top, 8) + .padding(.bottom, 10) + .background(m3.background) + } + + private var filterRow: some View { + HStack(spacing: 8) { + ScrollView(.horizontal, showsIndicators: false) { + HStack(spacing: 8) { + ForEach(Self.filters) { option in + FilterChip(isSelected: filter == option.id, title: option.label) { + filter = option.id + } } - .buttonStyle(.plain) } + .padding(.horizontal, 16) + } + // Sorting only makes sense over the unfiltered (server-ordered) mix. + if filter == "all" { + sortMenu.padding(.trailing, 16) } - .padding(.horizontal) } } - private func chipFill(_ id: String) -> Color { - filter == id ? Color.carmen : Color(.secondarySystemBackground) + private var sortMenu: some View { + Menu { + sortOption(.relevance) + sortOption(.title) + sortOption(.duration) + } label: { + TagChip(title: sort.title, systemImage: "arrow.up.arrow.down") + } } - private func chipInk(_ id: String) -> Color { - filter == id ? Color.carmenOnAccent : Color.secondary + @ViewBuilder private func sortOption(_ option: ResultSort) -> some View { + Button { + sort = option + } label: { + if sort == option { + Label(option.title, systemImage: "checkmark") + } else { + Text(option.title) + } + } } - // MARK: Search history (empty query) + // MARK: Body states - @ViewBuilder private var historySection: some View { - if library.searchHistory.isEmpty { - EmptyState(title: "Search Carmen", hint: "Find songs, artists, albums and playlists.") - } else { - VStack(alignment: .leading, spacing: 4) { - HStack { - Text("Search History") - .font(.title3.weight(.bold)) - Spacer() - Button { - library.clearSearchHistory() - } label: { - Image(systemName: "trash") - .font(.subheadline) - .foregroundStyle(Color.carmen) - } - .buttonStyle(.plain) + @ViewBuilder private var content: some View { + // Local matches the live field (instant, no round-trip); online is + // submit-driven — mid-typing keeps the previous state on screen. + if source == .local { + if trimmed.isEmpty { + emptyQueryPane + } else { + localContent + } + } else if submitted.isEmpty { + emptyQueryPane + } else if let errorMessage { + M3ErrorState(message: errorMessage) { + Task { await retrySearch() } + } + } else if isLoading || results == nil { + ShimmerList(rows: 8) + } else if let results { + if results.isEmpty { + M3EmptyState( + title: "No Results", + hint: filter == "all" + ? "Try a different search." + : "Check the spelling or try another filter.", + systemImage: "magnifyingglass" + ) + } else { + onlineContent(results) + } + } + } + + // MARK: Empty query — history + suggestions + + @ViewBuilder private var emptyQueryPane: some View { + if !library.searchHistory.isEmpty { + SectionHeader(title: "Recent searches", actionTitle: "Clear history") { + library.clearSearchHistory() + } + ForEach(Array(library.searchHistory.prefix(10)), id: \.self) { term in + termRow(term, systemImage: "clock") { + run(term) } - .padding(.horizontal) - - ForEach(library.searchHistory, id: \.self) { term in - Button { - query = term - } label: { - HStack(spacing: 12) { - Image(systemName: "magnifyingglass") - .foregroundStyle(.secondary) - Text(term) - .font(.body) - .lineLimit(1) - Spacer(minLength: 8) - } - .padding(.horizontal) - .padding(.vertical, 2) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) + } + } + if !suggestions.isEmpty { + SectionHeader(title: "Suggested") + ForEach(suggestions, id: \.self) { term in + termRow(term, systemImage: "magnifyingglass") { + run(term) } } } + if library.searchHistory.isEmpty, suggestions.isEmpty { + M3EmptyState( + title: "Search Carmen", + hint: "Find songs, artists, albums and playlists.", + systemImage: "magnifyingglass" + ) + } + } + + private func termRow(_ term: String, systemImage: String, action: @escaping () -> Void) -> some View { + Button(action: action) { + HStack(spacing: 14) { + Image(systemName: systemImage) + .font(.system(size: 16, weight: .medium)) + .foregroundStyle(m3.onSurfaceVariant) + .frame(width: 40, height: 40) + .background(m3.surfaceContainerHighest, in: M3Shape.small) + Text(term) + .font(.m3BodyLarge) + .foregroundStyle(m3.onSurface) + .lineLimit(1) + .minimumScaleFactor(0.85) + Spacer(minLength: 8) + Image(systemName: "arrow.up.left") + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(m3.onSurfaceVariant) + } + .padding(.horizontal, 16) + .frame(height: 64) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) } - // MARK: Results + // MARK: Local (on-device) results - @ViewBuilder private func resultsContent(_ cards: [MediaCard]) -> some View { - if isSearching { - ProgressView() - .frame(maxWidth: .infinity) + @ViewBuilder private var localContent: some View { + if localResults.isEmpty { + M3EmptyState( + title: "No Results", + hint: "Nothing in your favorites, recents or playlists matches “\(trimmed)”.", + systemImage: "magnifyingglass" + ) + } else { + VStack(alignment: .leading, spacing: 0) { + ForEach(localResults) { track in + ListRow( + track: track, + isPlaying: player.current?.videoId == track.videoId, + onTap: { play(localResults, tapping: track) } + ) + } + } } - filterBar - if !suggestions.isEmpty { - suggestionSection + } + + /// Case- and accent-insensitive `contains` over favorites, recents and + /// local playlist tracks, deduped by video id (recents first). + private var localResults: [Track] { + let needle = normalize(trimmed) + guard !needle.isEmpty else { return [] } + var seen = Set() + let pool = (library.favorites + library.recents + library.playlists.flatMap(\.tracks)) + .filter { seen.insert($0.videoId).inserted } + return pool.filter { track in + normalize("\(track.title) \(track.artistLine) \(track.albumName)").contains(needle) + } + } + + private func normalize(_ text: String) -> String { + text.folding(options: [.diacriticInsensitive, .caseInsensitive], locale: .current) + } + + // MARK: Online results + + @ViewBuilder private func onlineContent(_ cards: [MediaCard]) -> some View { + if filter == "all" { + ForEach(groups(cards)) { group in + groupSection(group, allCards: cards) + } + } else { + flatSection(sorted(cards), allCards: cards) } - ForEach(groups(cards)) { group in - groupView(group) + } + + /// One `category` group: "Top result" gets its prominent card, everything + /// else gets a section header ("Play all" when it carries songs) and rows. + @ViewBuilder private func groupSection(_ group: SearchGroup, allCards: [MediaCard]) -> some View { + let cards = sorted(group.cards) + if let title = group.title, title.lowercased() == "top result" { + VStack(alignment: .leading, spacing: 4) { + SectionHeader(title: title) + if let card = cards.first { + TopResultCard(card: card, context: playableTracks(allCards)) + } + } + } else if let title = group.title { + let playable = playableTracks(cards) + let playAll: (() -> Void)? = playable.isEmpty ? nil : { player.play(playable) } + VStack(alignment: .leading, spacing: 12) { + SectionHeader( + title: title, + actionTitle: playAll == nil ? nil : "Play all", + action: playAll + ) + cardBlock(cards, context: cards) + } + } else { + cardBlock(cards, context: cards) } } - private var suggestionSection: some View { - VStack(alignment: .leading, spacing: 4) { - SectionHeader(title: "Suggested") - ForEach(suggestions, id: \.self) { term in - Button { - query = term - } label: { - HStack(spacing: 12) { - Image(systemName: "magnifyingglass") - .foregroundStyle(.secondary) - Text(term) - .font(.body) - .lineLimit(1) - Spacer(minLength: 8) + /// Single-filter mode: one flat run of rows — songs/videos as `ListRow`, + /// anything without a track as a grid (an album/artist/playlist card + /// cannot be a list row: it carries no `Track`). + @ViewBuilder private func flatSection(_ cards: [MediaCard], allCards: [MediaCard]) -> some View { + let withTracks = cards.filter { $0.track != nil } + let withoutTracks = cards.filter { $0.track == nil } + if !withTracks.isEmpty { + VStack(alignment: .leading, spacing: 0) { + ForEach(withTracks) { card in + if let track = card.track { + ListRow( + track: track, + isPlaying: player.current?.videoId == track.videoId, + onTap: { play(playableTracks(allCards), tapping: track) } + ) } - .padding(.horizontal) - .padding(.vertical, 2) - .contentShape(Rectangle()) } - .buttonStyle(.plain) } } + if !withoutTracks.isEmpty { + cardGrid(withoutTracks, context: allCards) + } + } + + /// Mixed cards: songs play as list rows, the rest as GridTile rows. + @ViewBuilder private func cardBlock(_ cards: [MediaCard], context: [MediaCard]) -> some View { + let withTracks = cards.filter { $0.track != nil } + let withoutTracks = cards.filter { $0.track == nil } + if !withTracks.isEmpty { + VStack(alignment: .leading, spacing: 0) { + ForEach(withTracks) { card in + if let track = card.track { + ListRow( + track: track, + isPlaying: player.current?.videoId == track.videoId, + onTap: { play(playableTracks(context), tapping: track) } + ) + } + } + } + } + if !withoutTracks.isEmpty { + ScrollView(.horizontal, showsIndicators: false) { + HStack(alignment: .top, spacing: 16) { + ForEach(withoutTracks) { card in + GridTile(card: card, siblings: context) + } + } + .padding(.horizontal, 16) + } + } + } + + /// Two-column grid of GridTiles — used for a whole filtered result set. + private func cardGrid(_ cards: [MediaCard], context: [MediaCard]) -> some View { + LazyVGrid( + columns: [ + GridItem(.flexible(), spacing: 16), + GridItem(.flexible(), spacing: 16), + ], + spacing: 20 + ) { + ForEach(cards) { card in + GridTile(card: card, siblings: context) + } + } + .padding(.horizontal, 16) } /// Flat payload → groups by `category`, preserving first-appearance order; @@ -222,64 +556,80 @@ struct SearchView: View { return grouped } - @ViewBuilder private func groupView(_ group: SearchGroup) -> some View { - if let title = group.title { - switch title.lowercased() { - case "top result": - VStack(alignment: .leading, spacing: 12) { - SectionHeader(title: title) - if let card = group.cards.first { - TopResultCard(card: card, context: playableTracks(results ?? [])) - } - } - case "songs": - VStack(alignment: .leading, spacing: 4) { - SectionHeader(title: title) - ForEach(group.cards) { card in - if let track = card.track { - TrackRow(track: track, context: playableTracks(group.cards)) - } - } - } - default: - cardList(group.cards) + /// Client-side order: Relevance keeps the server's, Title is A→Z, + /// Duration is shortest-first (tracks without a duration sort last). + private func sorted(_ cards: [MediaCard]) -> [MediaCard] { + switch sort { + case .relevance: + return cards + case .title: + return cards.sorted { + $0.title.localizedCaseInsensitiveCompare($1.title) == .orderedAscending + } + case .duration: + return cards.sorted { + ($0.track?.durationSeconds ?? Int.max) < ($1.track?.durationSeconds ?? Int.max) } - } else { - cardList(group.cards) } } - private func cardList(_ cards: [MediaCard]) -> some View { - VStack(alignment: .leading, spacing: 4) { - ForEach(cards) { card in - SearchCardRow(card: card, context: playableTracks(cards)) - } - } + // MARK: Playing + + private func play(_ context: [Track], tapping track: Track) { + var queue = context + if queue.isEmpty { queue = [track] } + let startAt = queue.firstIndex { $0.videoId == track.videoId } ?? 0 + player.play(queue, startAt: startAt) + } + + // MARK: Search lifecycle + + /// Enter in the field (or a tapped term) runs immediately. + private func submit() { + run(query) } - // MARK: Loading + /// Submitting a query re-queries and records it in the local history. + private func run(_ term: String) { + let clean = term.trimmingCharacters(in: .whitespacesAndNewlines) + query = clean + submitted = clean + if !clean.isEmpty { + library.recordSearch(clean) + } + } private func runSearch() async { - guard !trimmed.isEmpty else { + guard source == .online else { + isLoading = false results = nil - suggestions = [] errorMessage = nil - isSearching = false return } - isSearching = true + guard !submitted.isEmpty else { + isLoading = false + results = nil + errorMessage = nil + // Best-effort suggested terms for the empty state (the old file + // called the same endpoint — a failure just means no section). + if suggestions.isEmpty { + let api = API(baseURL: settings.apiBaseURL) + if let hints = try? await api.suggestions(""), !Task.isCancelled { + suggestions = hints + } + } + return + } + isLoading = true errorMessage = nil - // Debounce — this task restarts on every keystroke via `task(id:)`. - try? await Task.sleep(for: .milliseconds(350)) - if Task.isCancelled { return } - await fetch(trimmed) + await fetch(submitted) } private func retrySearch() async { - guard !trimmed.isEmpty else { return } - isSearching = true + guard !submitted.isEmpty else { return } + isLoading = true errorMessage = nil - await fetch(trimmed) + await fetch(submitted) } private func fetch(_ term: String) async { @@ -289,17 +639,14 @@ struct SearchView: View { let found = try await api.search(term, filter: activeFilter, limit: 100) if Task.isCancelled { return } results = found - isSearching = false - if !found.isEmpty { - library.recordSearch(term) - } + isLoading = false } catch is CancellationError { return } catch { if Task.isCancelled { return } results = nil errorMessage = error.localizedDescription - isSearching = false + isLoading = false return } // Best effort — a suggestions failure must never surface as an error. @@ -309,40 +656,46 @@ struct SearchView: View { } } -// MARK: - Result rows +// MARK: - Top result card -/// The "Top result" card — one prominent horizontal card (large artwork). -/// Taps mirror `CardTile`: songs play in their result context, everything -/// navigable pushes its screen, stations start a radio queue. +/// The "Top result" — one prominent card (large artwork). Taps mirror the old +/// `CardTile`: songs play in their result context, everything navigable +/// pushes its screen, stations start a radio queue. private struct TopResultCard: View { let card: MediaCard var context: [Track] = [] @Environment(Player.self) private var player + @Environment(\.m3) private var m3 var body: some View { if card.kind == .song, let track = card.track { - Button { - var queue = context - if queue.isEmpty { queue = [track] } - let startAt = queue.firstIndex { $0.videoId == track.videoId } ?? 0 - player.play(queue, startAt: startAt) - } label: { + M3Card(padding: 12, action: { play(track) }) { content } - .buttonStyle(.plain) } else if let route = Route(card: card) { - NavigationLink(value: route) { content } - .buttonStyle(.plain) + NavigationLink(value: route) { + M3Card(padding: 12) { content } + } + .buttonStyle(.plain) } else { - Button { - if card.kind == .station, !card.targetId.isEmpty { - player.playWatch(playlistId: card.targetId) - } - } label: { + M3Card(padding: 12, action: stationTapped) { content } - .buttonStyle(.plain) + } + } + + private func play(_ track: Track) { + var queue = context + if queue.isEmpty { queue = [track] } + let startAt = queue.firstIndex { $0.videoId == track.videoId } ?? 0 + player.play(queue, startAt: startAt) + } + + /// Non-navigable, non-song card: only stations start a radio queue. + private func stationTapped() { + if card.kind == .station, !card.targetId.isEmpty { + player.playWatch(playlistId: card.targetId) } } @@ -354,102 +707,28 @@ private struct TopResultCard: View { cornerRadius: 8, placeholderSymbol: placeholderSymbol ) - .frame(width: 120, height: 120) + .frame(width: 104, height: 104) VStack(alignment: .leading, spacing: 6) { HStack(spacing: 5) { Text(card.title) - .font(.title2.bold()) + .font(.m3TitleLarge) .lineLimit(2) + .minimumScaleFactor(0.85) if card.isExplicit && card.kind == .song { ExplicitBadge() } } if !card.subtitle.isEmpty { Text(card.subtitle) - .font(.subheadline) - .foregroundStyle(.secondary) + .font(.m3BodyMedium) + .foregroundStyle(m3.onSurfaceVariant) .lineLimit(2) } } Spacer(minLength: 0) } - .padding(.horizontal) - .contentShape(Rectangle()) - } - - private var placeholderSymbol: String { - switch card.kind { - case .artist: return "person.crop.square" - case .playlist, .station: return "music.note.list" - case .album: return "square.stack" - case .song, .unsupported: return "music.note" - } - } -} - -/// One vertical result row (Apple Music shows lists, not grids): 56pt -/// artwork, title, subtitle. Tap behaviour matches `CardTile`. -private struct SearchCardRow: View { - let card: MediaCard - var context: [Track] = [] - - @Environment(Player.self) private var player - - var body: some View { - if card.kind == .song, let track = card.track { - Button { - var queue = context - if queue.isEmpty { queue = [track] } - let startAt = queue.firstIndex { $0.videoId == track.videoId } ?? 0 - player.play(queue, startAt: startAt) - } label: { - content - } - .buttonStyle(.plain) - } else if let route = Route(card: card) { - NavigationLink(value: route) { content } - .buttonStyle(.plain) - } else { - Button { - if card.kind == .station, !card.targetId.isEmpty { - player.playWatch(playlistId: card.targetId) - } - } label: { - content - } - .buttonStyle(.plain) - } - } - - private var content: some View { - HStack(spacing: 12) { - ArtworkView( - url: card.artwork, - fallbackURL: card.artworkAlt, - cornerRadius: 6, - placeholderSymbol: placeholderSymbol - ) - .frame(width: 56, height: 56) - VStack(alignment: .leading, spacing: 3) { - HStack(spacing: 4) { - Text(card.title) - .font(.body) - .lineLimit(1) - if card.isExplicit && card.kind == .song { - ExplicitBadge() - } - } - if !card.subtitle.isEmpty { - Text(card.subtitle) - .font(.footnote) - .foregroundStyle(.secondary) - .lineLimit(1) - } - } - Spacer(minLength: 8) - } - .padding(.horizontal) - .contentShape(Rectangle()) + .frame(maxWidth: .infinity, alignment: .leading) + .contentShape(M3Shape.card) } private var placeholderSymbol: String { diff --git a/ios/Carmen/Views/SettingsView.swift b/ios/Carmen/Views/SettingsView.swift index 734d240..27e3667 100644 --- a/ios/Carmen/Views/SettingsView.swift +++ b/ios/Carmen/Views/SettingsView.swift @@ -1,8 +1,14 @@ // // SettingsView.swift -// Carmen — server URL, appearance, lyrics, Live Activities, storage, -// diagnostics and about. Presented either as a tab (4th tab, no Done) or -// as a sheet from the Library tab (with its own Done button). +// Carmen — Material 3 settings hub: server URL, appearance, lyrics, Live +// Activities, storage, diagnostics and about. Presented either as a tab +// (RootView already hosts a NavigationStack, so no Done) or as a sheet +// from Home/Search/Library — in sheet mode it brings its own +// NavigationStack so a Done button can always dismiss it. +// +// Anatomy mirrors Android's SettingsScreen: a large "Settings" app bar, +// then grouped cards, each row = 40pt icon badge + title/subtitle + +// trailing control, hairline dividers between rows. // import SwiftUI @@ -16,6 +22,7 @@ struct SettingsView: View { @Environment(Player.self) private var player @Environment(DownloadsStore.self) private var downloads @Environment(\.dismiss) private var dismiss + @Environment(\.m3) private var m3 @State private var syncedLyrics = true @State private var logSize = DebugLog.sizeLabel @@ -23,30 +30,111 @@ struct SettingsView: View { private let swatchColumns = [GridItem(.adaptive(minimum: 44), spacing: 12)] var body: some View { + // Sheets from Home/Search present us bare, so non-embedded mode has + // to bring the stack itself; the tab already lives in one. + if embeddedInTab { + settingsContent + } else { + // Library wraps us in its own stack too — the extra hidden + // suppresses that outer bar so only our TopAppBar shows. + NavigationStack { settingsContent } + .toolbar(.hidden, for: .navigationBar) + } + } + + private var settingsContent: some View { + ScrollView { + VStack(alignment: .leading, spacing: 24) { + serverSection + appearanceSection + lyricsSection + liveActivitiesSection + storageSection + diagnosticsSection + aboutSection + } + .padding(.horizontal, 16) + .padding(.top, 4) + .padding(.bottom, 32) + } + .background { m3.background.ignoresSafeArea() } + .toolbar(.hidden, for: .navigationBar) + .safeAreaInset(edge: .top) { + TopAppBar( + title: "Settings", + large: true, + showOverflow: false, + trailing: { + if !embeddedInTab { + Button("Done") { dismiss() } + .font(.m3LabelLarge) + .foregroundStyle(m3.primary) + .accessibilityLabel("Close Settings") + } + } + ) + } + .onAppear { + syncedLyrics = player.syncedLyricsEnabled + logSize = DebugLog.sizeLabel + } + .onChange(of: syncedLyrics) { _, newValue in + player.syncedLyricsEnabled = newValue + player.reloadLyrics() + } + } + + // MARK: - Server + + @ViewBuilder private var serverSection: some View { @Bindable var settings = settings - List { - Section { - TextField("Server URL", text: $settings.apiBase) - .textInputAutocapitalization(.never) - .autocorrectionDisabled() - .keyboardType(.URL) - Button("Reset to Default") { - settings.resetServerURL() + SettingsGroup("Server") { + M3Card(padding: 0) { + HStack(spacing: 14) { + SettingsIconBadge(systemImage: "server.rack") + TextField("Server URL", text: $settings.apiBase) + .font(.m3BodyLarge) + .foregroundStyle(m3.onSurface) + .tint(m3.primary) + .textInputAutocapitalization(.never) + .autocorrectionDisabled() + .keyboardType(.URL) + .frame(maxWidth: .infinity, alignment: .leading) + Button("Reset") { + settings.resetServerURL() + } + .font(.m3LabelLarge) + .foregroundStyle(m3.primary) + .accessibilityLabel("Reset to Default") } - } header: { - Text("Server") - } footer: { - VStack(alignment: .leading, spacing: 4) { - Text("Using: \(settings.apiBaseURL.absoluteString)") - Text( - "Default: \(SettingsStore.defaultBaseURL) — leave empty to use it. e.g. http://192.168.1.5:8000 for a LAN dev server." - ) + .padding(.horizontal, 16) + .frame(minHeight: 64) + } + VStack(alignment: .leading, spacing: 4) { + SettingsFooter("Using: \(settings.apiBaseURL.absoluteString)") + SettingsFooter( + "Default: \(SettingsStore.defaultBaseURL) — leave empty to use it. e.g. http://192.168.1.5:8000 for a LAN dev server." + ) + } + } + } + + // MARK: - Appearance + + @ViewBuilder private var appearanceSection: some View { + @Bindable var settings = settings + + SettingsGroup("Appearance") { + M3Card(padding: 0) { + SettingsRow(icon: "paintpalette", title: "Adaptive Accent") { + Toggle("", isOn: $settings.adaptiveAccentEnabled) + .labelsHidden() + .tint(Color.carmenControlTint) } } - Section { - Toggle("Adaptive Accent", isOn: $settings.adaptiveAccentEnabled) + M3Card { LazyVGrid(columns: swatchColumns, spacing: 12) { ForEach(AccentOption.allCases) { option in Button { @@ -56,11 +144,11 @@ struct SettingsView: View { .fill(Color(hex: option.hex)) .frame(width: 30, height: 30) .overlay { - Circle().strokeBorder(.secondary.opacity(0.35), lineWidth: 0.5) + Circle().strokeBorder(m3.outlineVariant, lineWidth: 0.5) } .overlay { if settings.accent == option { - Circle().strokeBorder(Color.primary, lineWidth: 2) + Circle().strokeBorder(m3.primary, lineWidth: 2) } } } @@ -69,107 +157,327 @@ struct SettingsView: View { .accessibilityLabel(option.name) } } - .padding(.vertical, 4) + .frame(maxWidth: .infinity) .disabled(settings.adaptiveAccentEnabled) - } header: { - Text("Appearance") - } footer: { - Text( - "Adaptive Accent lets the album cover pick the color — the player, buttons and highlights follow the record, like Apple Music. When off, the chosen palette accent applies everywhere: defaults to Material Green (#4CAF50), the same palette the web app ships." - ) } - Section { - Picker("Provider", selection: $settings.lyricsProvider) { - ForEach(LyricsProvider.allCases) { provider in - Text(provider.label).tag(provider) + SettingsFooter( + "Adaptive Accent lets the album cover pick the color — the player, buttons and highlights follow the record, like Apple Music. When off, the chosen palette accent applies everywhere: defaults to Material Green (#4CAF50), the same palette the web app ships." + ) + } + } + + // MARK: - Lyrics + + @ViewBuilder private var lyricsSection: some View { + @Bindable var settings = settings + + SettingsGroup("Lyrics") { + M3Card(padding: 0) { + VStack(spacing: 0) { + SettingsRow(icon: "quote.bubble", title: "Provider") { + Menu { + ForEach(LyricsProvider.allCases) { provider in + Button { + settings.lyricsProvider = provider + } label: { + if settings.lyricsProvider == provider { + Label(provider.label, systemImage: "checkmark") + } else { + Text(provider.label) + } + } + } + } label: { + HStack(spacing: 4) { + Text(settings.lyricsProvider.label) + .font(.m3BodyMedium) + .foregroundStyle(m3.onSurfaceVariant) + Image(systemName: "chevron.down") + .font(.system(size: 11, weight: .semibold)) + .foregroundStyle(m3.onSurfaceVariant) + } + } + } + SettingsDivider() + SettingsRow(icon: "captions.bubble", title: "Synced (timed) Lyrics") { + Toggle("", isOn: $syncedLyrics) + .labelsHidden() + .tint(Color.carmenControlTint) } } - .pickerStyle(.menu) - Toggle("Synced (timed) Lyrics", isOn: $syncedLyrics) - } header: { - Text("Lyrics") - } footer: { - Text("Auto follows the backend chain: YouTube timed lyrics, then ytmusicapi, then syncedlyrics.") } + SettingsFooter("Auto follows the backend chain: YouTube timed lyrics, then ytmusicapi, then syncedlyrics.") + } + } - Section { - Toggle("Lyric Live Activities", isOn: $settings.liveActivitiesEnabled) - Toggle("Download Live Activities", isOn: $settings.downloadLiveActivitiesEnabled) - } header: { - Text("Live Activities") - } footer: { - Text( - "Lyrics show the current line on the Lock Screen and Dynamic Island while music plays; downloads show how many bytes have landed so far." - ) - } + // MARK: - Live Activities + + @ViewBuilder private var liveActivitiesSection: some View { + @Bindable var settings = settings - Section { - LabeledContent("Downloaded Songs", value: "\(downloads.downloadedIds.count)") - LabeledContent("Storage Used", value: downloads.totalSizeLabel) - if !downloads.downloadedIds.isEmpty { - Button("Remove All Downloads", role: .destructive) { - downloads.removeAll() + SettingsGroup("Live Activities") { + M3Card(padding: 0) { + VStack(spacing: 0) { + SettingsRow(icon: "music.note", title: "Lyric Live Activities") { + Toggle("", isOn: $settings.liveActivitiesEnabled) + .labelsHidden() + .tint(Color.carmenControlTint) + } + SettingsDivider() + SettingsRow(icon: "arrow.down.circle", title: "Download Live Activities") { + Toggle("", isOn: $settings.downloadLiveActivitiesEnabled) + .labelsHidden() + .tint(Color.carmenControlTint) } } - } header: { - Text("Storage") - } footer: { - Text("A download keeps the audio, artwork and lyrics on the device — the song then plays and shows its lyrics fully offline.") } + SettingsFooter( + "Lyrics show the current line on the Lock Screen and Dynamic Island while music plays; downloads show how many bytes have landed so far." + ) + } + } - Section { - LabeledContent("Log Size", value: logSize) - ShareLink(item: DebugLog.fileURL) { - Label("Share Debug Log", systemImage: "square.and.arrow.up") - } - Button("Clear Log") { - DebugLog.clear() - logSize = DebugLog.sizeLabel + // MARK: - Storage + + @ViewBuilder private var storageSection: some View { + SettingsGroup("Storage") { + M3Card(padding: 0) { + VStack(spacing: 0) { + SettingsRow(icon: "music.note.list", title: "Downloaded Songs") { + Text("\(downloads.downloadedIds.count)") + .font(.m3BodyMedium) + .foregroundStyle(m3.onSurfaceVariant) + } + if !downloads.downloadedIds.isEmpty { + SettingsDivider() + } + SettingsRow(icon: "internaldrive", title: "Storage Used") { + Text(downloads.totalSizeLabel) + .font(.m3BodyMedium) + .foregroundStyle(m3.onSurfaceVariant) + } + if !downloads.downloadedIds.isEmpty { + SettingsDivider() + Button(role: .destructive) { + downloads.removeAll() + } label: { + SettingsRow(icon: "trash", title: "Remove All Downloads", tint: m3.error) + } + .buttonStyle(.plain) + } } - } header: { - Text("Diagnostics") - } footer: { - Text( - "Every API call, stream fetch, playback transition and download writes a line to this log. If a song refuses to play, share it and the real reason is in there." - ) } + SettingsFooter( + "A download keeps the audio, artwork and lyrics on the device — the song then plays and shows its lyrics fully offline." + ) + } + } - Section { - LabeledContent("Version", value: appVersion) - Link(destination: URL(string: "https://github.com/z44d/carmen")!) { - Label("Open-Source API", systemImage: "server.rack") - } - Link(destination: URL(string: "https://github.com/z44d/carmen")!) { - Label("Source Code", systemImage: "chevron.left.forwardslash.chevron.right") + // MARK: - Diagnostics + + @ViewBuilder private var diagnosticsSection: some View { + SettingsGroup("Diagnostics") { + M3Card(padding: 0) { + VStack(spacing: 0) { + SettingsRow(icon: "doc.text", title: "Log Size") { + Text(logSize) + .font(.m3BodyMedium) + .foregroundStyle(m3.onSurfaceVariant) + } + SettingsDivider() + ShareLink(item: DebugLog.fileURL) { + SettingsRow(icon: "square.and.arrow.up", title: "Share Debug Log") { + rowChevron + } + } + SettingsDivider() + Button { + DebugLog.clear() + logSize = DebugLog.sizeLabel + } label: { + SettingsRow(icon: "xmark.bin", title: "Clear Log", tint: m3.primary) + } + .buttonStyle(.plain) } - } header: { - Text("About") - } footer: { - Text("Carmen plays YouTube Music through the Carmen API.") } + SettingsFooter( + "Every API call, stream fetch, playback transition and download writes a line to this log. If a song refuses to play, share it and the real reason is in there." + ) } - .listStyle(.insetGrouped) - .navigationTitle("Settings") - .toolbar { - if !embeddedInTab { - ToolbarItem(placement: .topBarTrailing) { - Button("Done") { dismiss() } + } + + // MARK: - About + + @ViewBuilder private var aboutSection: some View { + SettingsGroup("About") { + M3Card(padding: 0) { + VStack(spacing: 0) { + SettingsRow(icon: "info.circle", title: "Version") { + Text(appVersion) + .font(.m3BodyMedium) + .foregroundStyle(m3.onSurfaceVariant) + } + SettingsDivider() + Link(destination: URL(string: "https://github.com/z44d/carmen")!) { + SettingsRow(icon: "server.rack", title: "Open-Source API") { + rowChevron + } + } + SettingsDivider() + Link(destination: URL(string: "https://github.com/z44d/carmen")!) { + SettingsRow( + icon: "chevron.left.forwardslash.chevron.right", + title: "Source Code" + ) { + rowChevron + } + } } } - } - .onAppear { - syncedLyrics = player.syncedLyricsEnabled - logSize = DebugLog.sizeLabel - } - .onChange(of: syncedLyrics) { _, newValue in - player.syncedLyricsEnabled = newValue - player.reloadLyrics() + SettingsFooter("Carmen plays YouTube Music through the Carmen API.") } } + private var rowChevron: some View { + Image(systemName: "chevron.right") + .font(.system(size: 14, weight: .semibold)) + .foregroundStyle(m3.onSurfaceVariant.opacity(0.4)) + } + private var appVersion: String { let version = Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "" return version.isEmpty ? "1.0.0" : version } } + +// MARK: - Settings building blocks + +/// Group header label + card stack (Android's section label + segmented card). +private struct SettingsGroup: View { + var label: String + private let content: () -> Content + + @Environment(\.m3) private var m3 + + init(_ label: String, @ViewBuilder content: @escaping () -> Content) { + self.label = label + self.content = content + } + + var body: some View { + VStack(alignment: .leading, spacing: 10) { + Text(label.uppercased()) + .font(.m3TitleSmall) + .kerning(0.8) + .foregroundStyle(m3.onSurfaceVariant) + .padding(.leading, 4) + content() + } + .frame(maxWidth: .infinity, alignment: .leading) + } +} + +/// 40pt icon badge in a primary-container circle, icon tinted primary — +/// the leading element of every settings row. +private struct SettingsIconBadge: View { + var systemImage: String + + @Environment(\.m3) private var m3 + + var body: some View { + Circle() + .fill(m3.primaryContainer) + .frame(width: 40, height: 40) + .overlay { + Image(systemName: systemImage) + .font(.system(size: 17, weight: .medium)) + .foregroundStyle(m3.primary) + } + } +} + +/// Row anatomy: badge + title/subtitle + trailing control. Plain rows read +/// as content; buttons, links and ShareLinks reuse it as their label. +private struct SettingsRow: View { + var icon: String + var title: String + var subtitle: String? = nil + /// Destructive / action-row title tint (defaults to on-surface). + var tint: Color? = nil + + private let trailing: () -> Trailing + + @Environment(\.m3) private var m3 + + init( + icon: String, + title: String, + subtitle: String? = nil, + tint: Color? = nil, + @ViewBuilder trailing: @escaping () -> Trailing = { EmptyView() } + ) { + self.icon = icon + self.title = title + self.subtitle = subtitle + self.tint = tint + self.trailing = trailing + } + + var body: some View { + HStack(spacing: 14) { + SettingsIconBadge(systemImage: icon) + VStack(alignment: .leading, spacing: 2) { + Text(title) + .font(.m3BodyLarge) + .fontWeight(.medium) + .foregroundStyle(tint ?? m3.onSurface) + .lineLimit(1) + .minimumScaleFactor(0.8) + if let subtitle { + Text(subtitle) + .font(.m3BodyMedium) + .foregroundStyle(m3.onSurfaceVariant) + .lineLimit(1) + .minimumScaleFactor(0.8) + } + } + .frame(maxWidth: .infinity, alignment: .leading) + trailing() + } + .padding(.horizontal, 16) + .frame(minHeight: 64) + .contentShape(Rectangle()) + } +} + +/// Hairline separator between rows, inset past the icon badge. +private struct SettingsDivider: View { + @Environment(\.m3) private var m3 + + var body: some View { + Rectangle() + .fill(m3.outlineVariant) + .frame(height: 0.5) + .padding(.leading, 70) + } +} + +/// Supporting text under a card (the old List section footers). +private struct SettingsFooter: View { + var text: String + + @Environment(\.m3) private var m3 + + init(_ text: String) { + self.text = text + } + + var body: some View { + Text(text) + .font(.m3BodyMedium) + .foregroundStyle(m3.onSurfaceVariant) + .fixedSize(horizontal: false, vertical: true) + .frame(maxWidth: .infinity, alignment: .leading) + } +} From 02a1b8ecfa8e148cbed1540d4415560252abe279 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Za=C2=A1d?= Date: Sat, 24 Oct 2026 20:09:05 +0300 Subject: [PATCH 5/9] feat(ios): 3-tab pill shell with connected mini player MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit RootView now hosts Home/Search/Library behind a floating pill nav (FloatingNavBlob — Android's CONNECTED bar: mini player stacked on the pill as one surfaceContainer blob, 32↔28 radius lerp, bottom safeAreaInset so scrolls end above it), mounts .m3Theme() outermost, and keeps the expansion-driven player overlay. Settings moves from a tab to the per-screen overflow sheet. Deletes the superseded Queue tab (QueueView.swift) and the legacy Components.swift — the kit owns all shared UI now. --- AGENTS.md | 6 +- ios/Carmen/Views/Components.swift | 339 ------------------------------ ios/Carmen/Views/QueueView.swift | 174 --------------- ios/Carmen/Views/RootView.swift | 179 ++++++++++++---- 4 files changed, 141 insertions(+), 557 deletions(-) delete mode 100644 ios/Carmen/Views/Components.swift delete mode 100644 ios/Carmen/Views/QueueView.swift diff --git a/AGENTS.md b/AGENTS.md index eb2f8e3..eb3f3ba 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -22,7 +22,9 @@ FastAPI backend (`server/`) wrapping `ytmusicapi` to expose YouTube Music endpoi ## iOS app (`ios/`) - Two XcodeGen targets from `ios/project.yml`: `Carmen` (app) + `CarmenLiveActivity` (widget extension for the Live Activities — lock-screen lyrics *and* download progress). `Shared/` sources compile into both. `Carmen.xcodeproj` and the `Info.plist`s are **generated — never hand-edit**; regenerate with `cd ios && xcodegen generate`. -- Min iOS 18; every iOS 26 / Liquid Glass API is gated with `#available(iOS 26.0, *)` — use the wrappers in `ios/Carmen/Views/Glass.swift`, never call `.glassEffect` directly (and never `.clipped()` on a glass surface). +- The UI is a **Material 3 clone of the Android app** (`android/`, ArchiveTune fork): design tokens in `Views/Theme/Theme.swift` (`@Environment(\.m3)` roles derived live from the artwork accent / settings accent / `ED5564` fallback; Poppins type scale `.m3*`, bundled from `android/app/src/main/res/font/poppins.ttf` via `UIAppFonts`; `M3Shape`/`M3Dim`), and the component kit in `Views/Components/` (`TopAppBar`, `ListRow`, `GridTile`, chips, cards, shimmers, `M3*State` panes, sheet menus, buttons, `ArtworkView`). Screens consume the kit — never raw hexes or `.system(size:)` fonts. The root applies `.m3Theme()` (outermost modifier in `RootView`); NowPlayingView re-derives dark roles for its subtree. +- Shell (`RootView`): **3 tabs** (Home / Search / Library) with the native tab bar hidden behind a floating pill nav (`FloatingNavBlob` — Android's CONNECTED navigation bar: mini player stacked on the pill as one `surfaceContainer` blob, radius lerp 32↔28). The blob is a bottom `safeAreaInset` so scrolls end above it. Root screens own their TopAppBar (pinned via `safeAreaInset(edge: .top)`) and present Settings themselves; pushed detail/results screens keep the native inline bar. `Route`/`.carmenDestinations()` (`Views/Theme/Navigation.swift`) are the single push contract (album/artist/playlist/searchResults). +- Min iOS 18; every iOS 26 / Liquid Glass API is gated with `#available(iOS 26.0, *)` — use the wrappers in `ios/Carmen/Views/Glass.swift`, never call `.glassEffect` directly (and never `.clipped()` on a glass surface). The M3 clone does not use Liquid Glass anywhere. - `Player` / `SettingsStore.shared` / `LibraryStore.shared` / `DownloadsStore.shared` are the single sources of truth (`@MainActor @Observable`); views read them from the environment (the App injects all four). The API base URL defaults to `https://api-carmen-v1.syr.qzz.io` and is overridable in-app. - Playback source chain in `Player.startCurrent`: downloaded file (`Documents/songs//audio.*`) → `carmen-cache://` URL whose `StreamResourceLoader` (`State/StreamCache.swift`) serves/fills byte ranges from `Library/Caches/streams/` (1 GB LRU) → failure. Library data (favorites/recents/local playlists/downloads/suggestions) is on-device only — the API is read-only. - The full player is an **overlay driven by `Player.expansion`** (0 = mini bar, 1 = full; RootView slides it by 1000 pt), not a `.fullScreenCover`. Always go through `openPlayer()` / `closePlayer()`; `showNowPlaying` is `private(set)` (set only there). Drag gestures write `expansion` directly. @@ -31,7 +33,7 @@ FastAPI backend (`server/`) wrapping `ytmusicapi` to expose YouTube Music endpoi - Adaptive accent: `Color.carmen` follows `Player.adaptiveColorHex` (the cover's own colour, read by `State/ArtworkPalette.swift` — port of the web `pickAccentColor`) when Settings → Appearance → Adaptive Accent is on (default), else the chosen palette accent. `NowPlayingBackground` washes the player with it; panels scrim over it instead of using a material. - Downloads run through a **background URLSession** (`com.z44d.carmen.downloads` in `State/Downloads.swift`) so they survive suspension; iOS relaunches the app to deliver events (`CarmenAppDelegate.handleEventsForBackgroundURLSession` in `CarmenApp.swift`). Byte progress drives a download Live Activity (`Shared/DownloadActivityAttributes.swift` + `State/DownloadActivity.swift`; second `ActivityConfiguration` in the widget bundle). Delegate helpers the store exposes to the session are `nonisolated` and hop to `@MainActor` internally. - Every fallible layer (API requests, stream loader, player transitions, downloads, motion artwork) writes to `State/DebugLog.swift` (`Caches/logs/carmen.log`, 2 MB rotation); users export it from Settings → Diagnostics. Log failures where they happen — that file is the bug report. -- Settings is the **4th tab** (`RootView`); `SettingsView(embeddedInTab: true)` hides Done. `NetworkMonitor` (`State/NetworkMonitor.swift`) bumps a `generation` that `ArtworkView` `.id()`s on (covers reload on reconnect) and triggers `Player.retryIfBroken()` from `RootView`. +- Settings is **not a tab** — each root screen's TopAppBar overflow opens it as `.sheet { SettingsView() }` (self-wrapped in a NavigationStack with Done; `embeddedInTab` still hides Done for embedded use). `NetworkMonitor` (`State/NetworkMonitor.swift`) bumps a `generation` that `ArtworkView` `.id()`s on (covers reload on reconnect) and triggers `Player.retryIfBroken()` from `RootView`. The Queue tab is gone — the queue lives in the player as a `QueuePanel` (`PlayerPanel.queue`). - Use `Color.carmen` for the accent (Settings → Appearance, hex in `Settings.swift`), never `Color.accentColor` — it no longer matches the theme. - No signing in `project.yml`: CI builds unsigned (`CODE_SIGNING_ALLOWED=NO`); a sideload tool re-signs the IPA. Release version = `MARKETING_VERSION` in `ios/project.yml`. - There is **no Xcode on this machine** (CommandLineTools only): you cannot compile Swift locally. Verify with `swift format lint` (full parse), then rely on the `ios-check` job in `.github/workflows/build.yml` — it requires the iOS 26 SDK (Xcode 26+), enforced by an SDK guard. diff --git a/ios/Carmen/Views/Components.swift b/ios/Carmen/Views/Components.swift deleted file mode 100644 index e868320..0000000 --- a/ios/Carmen/Views/Components.swift +++ /dev/null @@ -1,339 +0,0 @@ -// -// Components.swift -// Carmen — legacy shared blocks still used by the pre-Material screens -// (state views, track rows, feed tiles). Every declaration here dies with -// its last consumer during the Android-parity rewrite; the Material -// vocabulary lives in Views/Components/ and Views/Theme/. -// - -import SwiftUI - -// MARK: - State views - -struct LoadingState: View { - var label: String = "Loading…" - - var body: some View { - VStack(spacing: 10) { - ProgressView() - Text(label).font(.subheadline).foregroundStyle(.secondary) - } - .frame(maxWidth: .infinity, maxHeight: .infinity) - .padding(.vertical, 60) - } -} - -struct ErrorState: View { - let message: String - var retry: (() -> Void)? - - var body: some View { - VStack(spacing: 12) { - Image(systemName: "wifi.exclamationmark") - .font(.system(size: 34)) - .foregroundStyle(.secondary) - Text("Something went wrong") - .font(.headline) - Text(message) - .font(.subheadline) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - .padding(.horizontal, 32) - if let retry { - Button("Try Again", action: retry) - .buttonStyle(.bordered) - } - } - .frame(maxWidth: .infinity) - .padding(.vertical, 50) - } -} - -struct EmptyState: View { - let title: String - var hint: String? - - var body: some View { - VStack(spacing: 8) { - Text(title).font(.headline) - if let hint { - Text(hint) - .font(.subheadline) - .foregroundStyle(.secondary) - .multilineTextAlignment(.center) - } - } - .frame(maxWidth: .infinity) - .padding(.vertical, 50) - .padding(.horizontal, 24) - } -} - -// MARK: - Track row - -/// One row of a song list: artwork (or index), title, artist, duration and -/// the Apple Music-style context menu. Tapping plays `track` inside `context`; -/// the trailing heart favorites it without opening anything. -struct TrackRow: View { - let track: Track - var context: [Track] = [] - var index: Int? - var showArtwork: Bool = true - var showAlbumLine: Bool = false - - @Environment(Player.self) private var player - @Environment(LibraryStore.self) private var library - @Environment(DownloadsStore.self) private var downloads - - private var isCurrent: Bool { player.current?.videoId == track.videoId } - - var body: some View { - HStack(spacing: 4) { - Button { - let startAt = context.firstIndex { $0.videoId == track.videoId } ?? 0 - player.play(context.isEmpty ? [track] : context, startAt: startAt) - } label: { - HStack(spacing: 12) { - artworkOrIndex - VStack(alignment: .leading, spacing: 2) { - HStack(spacing: 5) { - Text(track.title) - .font(.body) - .lineLimit(1) - .foregroundStyle(isCurrent ? Color.carmen : .primary) - if track.isExplicit { ExplicitBadge() } - } - HStack(spacing: 4) { - Text(subtitle) - .font(.footnote) - .foregroundStyle(.secondary) - .lineLimit(1) - if downloads.isDownloaded(track.videoId) { - Image(systemName: "checkmark.circle.fill") - .font(.caption2) - .foregroundStyle(.secondary) - } - } - } - Spacer(minLength: 8) - if isCurrent { - Image(systemName: "waveform") - .font(.footnote) - .foregroundStyle(Color.carmen) - } else { - Text(track.durationLabel ?? "") - .font(.footnote.monospacedDigit()) - .foregroundStyle(.secondary) - } - } - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - - FavoriteButton(track: track) - } - .contextMenu { menu } - } - - private var subtitle: String { - if showAlbumLine && !track.albumName.isEmpty { - return "\(track.artistLine) — \(track.albumName)" - } - return track.artistLine - } - - @ViewBuilder private var artworkOrIndex: some View { - if showArtwork { - ArtworkView(url: track.artwork, fallbackURL: track.artworkAlt, cornerRadius: 4) - .frame(width: 44, height: 44) - } else if let index { - Text("\(index)") - .font(.callout) - .foregroundStyle(.secondary) - .frame(width: 24, alignment: .trailing) - } - } - - @ViewBuilder private var menu: some View { - Button { - player.playNext(track) - } label: { - Label("Play Next", systemImage: "text.insert") - } - Button { - player.enqueue(track) - } label: { - Label("Add to Queue", systemImage: "list.bullet.plus") - } - Button { - library.toggleFavorite(track) - } label: { - Label( - library.isFavorite(track.videoId) ? "Remove Favorite" : "Favorite", - systemImage: library.isFavorite(track.videoId) ? "heart.slash" : "heart") - } - Button { - player.playStation(seed: track) - } label: { - Label("Play Radio", systemImage: "dot.radiowaves.left.and.right") - } - Menu { - PlaylistPickerMenu(track: track) - } label: { - Label("Add to Playlist…", systemImage: "music.note.list") - } - Divider() - DownloadMenuItems(track: track) - if let albumId = track.album?.id, !albumId.isEmpty { - NavigationLink(value: Route.album(browseId: albumId, title: track.albumName)) { - Label("Go to Album", systemImage: "square.stack") - } - } - if let artistId = track.artists.first(where: { !($0.id ?? "").isEmpty })?.id { - NavigationLink(value: Route.artist(channelId: artistId, title: track.artists[0].name)) { - Label("Go to Artist", systemImage: "person.crop.circle") - } - } - ShareLink(item: "https://carmen.z44d.com/?song=\(track.videoId)") { - Label("Share", systemImage: "square.and.arrow.up") - } - } -} - -// MARK: - Feed tile - -/// Card used by home rows and search grids: big artwork, title, subtitle. -/// Songs play in their row context; albums/artists/playlists push a screen; -/// stations start a radio queue. -struct CardTile: View { - let card: MediaCard - /// Sibling cards of the same row — the play context when tapped. - var siblings: [MediaCard] = [] - var width: CGFloat = 150 - - @Environment(Player.self) private var player - - var body: some View { - if let track = card.track, card.kind == .song { - Button { - var context = playableTracks(siblings) - if context.isEmpty { context = [track] } - let startAt = context.firstIndex { $0.videoId == track.videoId } ?? 0 - player.play(context, startAt: startAt) - } label: { - content - } - .buttonStyle(.plain) - } else if let route = Route(card: card) { - NavigationLink(value: route) { content } - .buttonStyle(.plain) - } else { - Button { - if card.kind == .station, !card.targetId.isEmpty { - player.playWatch(playlistId: card.targetId) - } - } label: { - content - } - .buttonStyle(.plain) - } - } - - private var content: some View { - VStack(alignment: .leading, spacing: 6) { - ArtworkView( - url: card.artwork, - fallbackURL: card.artworkAlt, - cornerRadius: 8, - placeholderSymbol: placeholderSymbol - ) - .frame(width: width, height: width) - HStack(spacing: 4) { - Text(card.title) - .font(.footnote.weight(.semibold)) - .lineLimit(2) - .multilineTextAlignment(.leading) - if card.isExplicit && card.kind == .song { ExplicitBadge() } - } - if !card.subtitle.isEmpty { - Text(card.subtitle) - .font(.caption) - .foregroundStyle(.secondary) - .lineLimit(1) - } - } - .frame(width: width, alignment: .leading) - .contentShape(Rectangle()) - } - - private var placeholderSymbol: String { - switch card.kind { - case .artist: return "person.crop.square" - case .playlist, .station: return "music.note.list" - case .album: return "square.stack" - case .song, .unsupported: return "music.note" - } - } -} - -// MARK: - Track tile - -/// Square tile of a bare `Track` (home suggestions): artwork, title, artist. -/// `context` is the full play order the Home screen built around the tap -/// (frontend: `[tapped] + recents + shuffled suggestions`). -struct TrackTile: View { - let track: Track - var context: [Track] - var width: CGFloat = 150 - - @Environment(Player.self) private var player - - private var isCurrent: Bool { player.current?.videoId == track.videoId } - - var body: some View { - Button { - let startAt = context.firstIndex { $0.videoId == track.videoId } ?? 0 - player.play(context.isEmpty ? [track] : context, startAt: startAt) - } label: { - VStack(alignment: .leading, spacing: 6) { - ZStack(alignment: .bottomLeading) { - ArtworkView(url: track.artwork, fallbackURL: track.artworkAlt, cornerRadius: 8) - .frame(width: width, height: width) - if isCurrent { - Image(systemName: "waveform") - .font(.footnote) - .foregroundStyle(.white) - .padding(6) - .shadow(radius: 2) - } - } - HStack(spacing: 4) { - Text(track.title) - .font(.footnote.weight(.semibold)) - .lineLimit(2) - .multilineTextAlignment(.leading) - .foregroundStyle(isCurrent ? Color.carmen : .primary) - if track.isExplicit { ExplicitBadge() } - } - Text(track.artistLine) - .font(.caption) - .foregroundStyle(.secondary) - .lineLimit(1) - } - .frame(width: width, alignment: .leading) - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - .contextMenu { - Button { player.playNext(track) } label: { - Label("Play Next", systemImage: "text.insert") - } - Button { player.enqueue(track) } label: { - Label("Add to Queue", systemImage: "list.bullet.plus") - } - Divider() - FavoriteButton(track: track) - DownloadMenuItems(track: track) - } - } -} diff --git a/ios/Carmen/Views/QueueView.swift b/ios/Carmen/Views/QueueView.swift deleted file mode 100644 index d14403d..0000000 --- a/ios/Carmen/Views/QueueView.swift +++ /dev/null @@ -1,174 +0,0 @@ -// -// QueueView.swift -// Carmen — the "Up Next" list: the Queue tab's screen and the Now Playing -// panel it is reused from. -// -// Count header with shuffle/clear (mirrors the web client's NPQueue), the -// playing row on top, then upcoming rows with an explicit ✕ remove plus -// swipe/delete and drag reordering. Tapping a row jumps the playhead to it. -// - -import SwiftUI - -/// The Queue as a tab (frontend parity: the drawer lists Queue right after -/// Search) — the same rows as the player's panel, under a large title. -struct QueueTabView: View { - var body: some View { - QueuePanel() - .navigationTitle("Queue") - .navigationBarTitleDisplayMode(.large) - } -} - -struct QueuePanel: View { - @Environment(Player.self) private var player - - /// Absolute queue index of the first "Up Next" row. - private var upcomingStart: Int { player.currentIndex + 1 } - private var upcomingCount: Int { max(player.queue.count - upcomingStart, 0) } - - var body: some View { - if player.queue.isEmpty { - EmptyState(title: "Queue is Empty", hint: "Play a song to start building your queue.") - .frame(maxWidth: .infinity, maxHeight: .infinity) - } else { - queueList - } - } - - private var queueList: some View { - List { - if let current = player.current { - Section("Playing Now") { - QueueRow(track: current, isPlaying: player.isPlaying, isCurrent: true) - } - } - - Section { - ForEach(Array(player.queue.enumerated()), id: \.offset) { index, track in - if index >= upcomingStart { - QueueRow(track: track, isPlaying: false, isCurrent: false) - .contentShape(Rectangle()) - .onTapGesture { player.jump(to: index) } - .swipeActions(edge: .trailing, allowsFullSwipe: false) { - Button(role: .destructive) { - player.removeFromQueue(at: IndexSet(integer: index)) - } label: { - Label("Remove", systemImage: "trash") - } - } - } - } - .onDelete { offsets in - player.removeFromQueue(at: IndexSet(offsets.map { $0 + upcomingStart })) - } - .onMove { offsets, destination in - player.moveInQueue( - from: IndexSet(offsets.map { $0 + upcomingStart }), - to: destination + upcomingStart - ) - } - } header: { - HStack(spacing: 14) { - Text("\(upcomingCount) tracks up next") - .textCase(nil) - Spacer() - Button { - player.shuffle.toggle() - } label: { - Image(systemName: "shuffle") - .foregroundStyle(player.shuffle ? Color.carmen : Color.secondary) - } - .buttonStyle(.plain) - Button("Clear") { - player.clearUpcoming() - } - .font(.footnote.weight(.semibold)) - } - .font(.footnote) - .foregroundStyle(.secondary) - .textCase(nil) - } footer: { - if upcomingCount == 0 { - Text("When the queue runs out, related songs take over.") - .textCase(nil) - } - } - } - .listStyle(.plain) - } -} - -/// One queue row: artwork, title, artist, duration — and an ✕ that removes -/// it without hunting for a swipe gesture. No play-on-tap conflict: the list -/// owns tapping so it can jump instead of restart. -private struct QueueRow: View { - let track: Track - let isPlaying: Bool - let isCurrent: Bool - - @Environment(Player.self) private var player - @Environment(LibraryStore.self) private var library - @Environment(DownloadsStore.self) private var downloads - - var body: some View { - HStack(spacing: 12) { - ArtworkView(url: track.artwork, fallbackURL: track.artworkAlt, cornerRadius: 4) - .frame(width: 44, height: 44) - - VStack(alignment: .leading, spacing: 2) { - HStack(spacing: 5) { - Text(track.title) - .font(.body) - .lineLimit(1) - .foregroundStyle(isCurrent ? Color.carmen : .primary) - if track.isExplicit { ExplicitBadge() } - } - HStack(spacing: 4) { - Text(track.artistLine) - .font(.footnote) - .foregroundStyle(.secondary) - .lineLimit(1) - if downloads.isDownloaded(track.videoId) { - Image(systemName: "checkmark.circle.fill") - .font(.caption2) - .foregroundStyle(.secondary) - } - if library.isFavorite(track.videoId) { - Image(systemName: "heart.fill") - .font(.caption2) - .foregroundStyle(Color.carmen) - } - } - } - - Spacer(minLength: 4) - - if isPlaying { - Image(systemName: "waveform") - .font(.footnote) - .foregroundStyle(Color.carmen) - } else { - Text(track.durationLabel ?? "") - .font(.footnote.monospacedDigit()) - .foregroundStyle(.secondary) - } - - if !isCurrent { - Button { - player.removeFromQueue(at: IndexSet(integer: player.queue.firstIndex { - $0.videoId == track.videoId - } ?? 0)) - } label: { - Image(systemName: "xmark") - .font(.footnote.weight(.semibold)) - .foregroundStyle(.secondary) - .frame(width: 28, height: 28) - .contentShape(Circle()) - } - .buttonStyle(.plain) - .accessibilityLabel("Remove from queue") - } - } - } -} diff --git a/ios/Carmen/Views/RootView.swift b/ios/Carmen/Views/RootView.swift index a4bc842..ade436a 100644 --- a/ios/Carmen/Views/RootView.swift +++ b/ios/Carmen/Views/RootView.swift @@ -1,12 +1,15 @@ // // RootView.swift -// Carmen — the five-tab shell (Home / Search / Queue / Library / Settings), -// the floating mini player and the full Now Playing overlay. +// Carmen — the Material 3 shell: three tabs (Home / Search / Library) +// behind a floating pill nav, with the mini player stacked on top of the +// pill as one merged blob (Android's CONNECTED navigation bar) and the +// full player as an overlay driven by Player.expansion (0 = parked below +// the screen, 1 = full). // -// Each tab owns its own NavigationStack; the mini player floats above the -// tab bar. The full player is an overlay driven by the Player's -// `expansion` (0 = parked below screen on the mini bar, 1 = full) so it can -// be dragged open and closed interactively. +// The native tab bar is hidden; screens pin their own TopAppBar and the +// blob is a bottom safeAreaInset, so every tab's scroll content ends +// above the bar instead of sliding under it. Settings is not a tab — it +// is the overflow sheet each root screen owns. // import SwiftUI @@ -16,10 +19,32 @@ struct RootView: View { @State private var selectedTab: Tab = .home - /// Matches the frontend's drawer order: Home, Search, Queue, Library, - /// Settings — the queue is navigation, not player chrome. enum Tab: Hashable { - case home, search, queue, library, settings + case home, search, library + + var title: String { + switch self { + case .home: "Home" + case .search: "Search" + case .library: "Library" + } + } + + var symbol: String { + switch self { + case .home: "house" + case .search: "magnifyingglass" + case .library: "square.stack" + } + } + + var activeSymbol: String { + switch self { + case .home: "house.fill" + case .search: "magnifyingglass" + case .library: "square.stack.fill" + } + } } var body: some View { @@ -29,52 +54,38 @@ struct RootView: View { HomeView() .carmenDestinations() } - .tabItem { Label("Home", systemImage: "house") } + .toolbar(.hidden, for: .tabBar) .tag(Tab.home) NavigationStack { SearchView() .carmenDestinations() } - .tabItem { Label("Search", systemImage: "magnifyingglass") } + .toolbar(.hidden, for: .tabBar) .tag(Tab.search) - NavigationStack { - QueueTabView() - .carmenDestinations() - } - .tabItem { Label("Queue", systemImage: "list.bullet") } - .tag(Tab.queue) - NavigationStack { LibraryView() .carmenDestinations() } - .tabItem { Label("Library", systemImage: "square.stack") } + .toolbar(.hidden, for: .tabBar) .tag(Tab.library) - - NavigationStack { - SettingsView(embeddedInTab: true) - } - .tabItem { Label("Settings", systemImage: "gearshape") } - .tag(Tab.settings) } - - // Mini player floats just above the tab bar when something is - // queued — it cross-fades out as the full player expands over it. - if player.hasQueue { - MiniPlayer() - .padding(.horizontal, 8) - .padding(.bottom, 49) // tab bar height + margin + .safeAreaInset(edge: .bottom, spacing: 0) { + FloatingNavBlob(selectedTab: $selectedTab) + .padding(.horizontal, 12) + .padding(.bottom, 6) + // Cross-fades out as the full player expands over it + // (old mini-player math, now on the whole blob). .opacity(player.showNowPlaying ? max(0, 1 - player.expansion * 2) : 1) .scaleEffect(player.showNowPlaying ? max(0.92, 1 - player.expansion * 0.08) : 1) - .transition(.move(edge: .bottom).combined(with: .opacity)) } - // Full player: slides up from the mini bar's position — mounted at - // expansion 0, then animated to 1 (openPlayer), and drag-tracked - // back down inside NowPlayingView. Fixed travel (taller than any - // iPhone) so the view is fully parked off-screen at expansion 0. + // Full player: slides up from the mini bar's position — mounted + // at expansion 0, then animated to 1 (openPlayer), and + // drag-tracked back down inside NowPlayingView. Fixed travel + // (taller than any iPhone) so the view is fully parked + // off-screen at expansion 0. if player.showNowPlaying { NowPlayingView() .frame(maxWidth: .infinity, maxHeight: .infinity) @@ -111,11 +122,94 @@ struct RootView: View { // (covers the offline-play → reconnect repro). if online { player.retryIfBroken() } } + // Material scheme for the whole shell — keep this the OUTERMOST + // modifier so the floating bar, the banner overlay and every screen + // below receive the live roles. + .m3Theme() + } +} + +/// The floating bottom bar: the nav pill with the mini player stacked on +/// top when something is queued — Android's CONNECTED navigation bar. One +/// blob in `surfaceContainer`; radii lerp 32 (standalone) → 28 (merged) +/// with mini-player proximity, exactly like the Compose original. +private struct FloatingNavBlob: View { + @Binding var selectedTab: RootView.Tab + + @Environment(Player.self) private var player + @Environment(\.m3) private var m3 + + /// 0 = pill alone, 1 = mini player seated on it (miniPlayerProximity). + private var proximity: CGFloat { player.hasQueue ? 1 : 0 } + + /// Android's FloatingBarStandaloneCornerRadius (32) → …Outer (28). + private var outerRadius: CGFloat { 32 - 4 * proximity } + + var body: some View { + VStack(spacing: 0) { + if player.hasQueue { + MiniPlayer() + .transition(.opacity) + } + pill + } + .frame(maxWidth: 420) // NavigationBarMaxWidth + .background( + m3.surfaceContainer, + in: RoundedRectangle(cornerRadius: outerRadius, style: .continuous) + ) + .clipShape(RoundedRectangle(cornerRadius: outerRadius, style: .continuous)) + .shadow(color: .black.opacity(0.12), radius: 12, y: 4) + } + + private var pill: some View { + HStack(spacing: 0) { + ForEach([RootView.Tab.home, .search, .library], id: \.self) { tab in + pillItem(tab) + } + } + .frame(maxWidth: 360) // NavigationItemsMaxWidth + .padding(.vertical, 8) // NavigationItemVerticalPadding + .frame(maxWidth: .infinity) + .frame(height: M3Dim.navPill) // NavigationBarHeight = 78 + } + + private func pillItem(_ tab: RootView.Tab) -> some View { + let selected = selectedTab == tab + return Button { + selectedTab = tab + } label: { + VStack(spacing: 3) { + Image(systemName: selected ? tab.activeSymbol : tab.symbol) + .font(.title3) + .frame(width: 56, height: 32) + .background { + // M3 active indicator — the icon pill. + if selected { + Capsule().fill(m3.secondaryContainer) + } + } + Text(tab.title) + .font(.m3LabelMedium) + .lineLimit(1) + .minimumScaleFactor(0.7) + .frame(maxWidth: .infinity) + } + .foregroundStyle(selected ? m3.onSurface : m3.onSurfaceVariant) + .frame(maxWidth: .infinity) + .contentShape(Rectangle()) + } + .buttonStyle(.plain) + .pressScale() + .accessibilityLabel(tab.title) + .accessibilityAddTraits(selected ? .isSelected : []) } } /// Transient banner explaining why playback stopped — retry or dismiss. private struct ErrorBanner: View { + @Environment(\.m3) private var m3 + let message: String let retry: () -> Void let dismiss: () -> Void @@ -125,18 +219,19 @@ private struct ErrorBanner: View { Image(systemName: "exclamationmark.triangle.fill") .foregroundStyle(.yellow) Text(message) - .font(.footnote) + .font(.m3BodySmall) + .foregroundStyle(m3.onSurface) .lineLimit(2) Spacer(minLength: 4) Button("Retry", action: retry) - .font(.footnote.weight(.semibold)) + .font(.m3LabelLarge) .buttonStyle(.bordered) .buttonBorderShape(.capsule) .controlSize(.small) Button(action: dismiss) { Image(systemName: "xmark") - .font(.footnote.weight(.semibold)) - .foregroundStyle(.secondary) + .font(.m3LabelLarge) + .foregroundStyle(m3.onSurfaceVariant) .frame(width: 28, height: 28) .contentShape(Rectangle()) } @@ -144,7 +239,7 @@ private struct ErrorBanner: View { .accessibilityLabel("Dismiss") } .padding(12) - .background(.regularMaterial, in: RoundedRectangle(cornerRadius: 14, style: .continuous)) + .background(.regularMaterial, in: M3Shape.sheet) .shadow(color: .black.opacity(0.15), radius: 10, y: 4) } } From 4cfa3f6b33fc304883684524af2581ac7df189be Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Za=C2=A1d?= Date: Sat, 24 Oct 2026 21:05:08 +0300 Subject: [PATCH 6/9] fix(ios): move @ViewBuilder to declaration position on SectionHeader menu init MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit In type position (menuItems: @ViewBuilder () -> Menu) the compiler reads the attribute as a type attribute and rejects it as unknown — it must precede the parameter name. Caught by the ios-check job on PR #7. --- ios/Carmen/Views/Components/Rows.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/ios/Carmen/Views/Components/Rows.swift b/ios/Carmen/Views/Components/Rows.swift index dc75ba6..1e54e8b 100644 --- a/ios/Carmen/Views/Components/Rows.swift +++ b/ios/Carmen/Views/Components/Rows.swift @@ -388,7 +388,7 @@ struct SectionHeader: View { self.menuContent = nil } - init(title: String, menuItems: @ViewBuilder () -> Menu) { + init(title: String, @ViewBuilder menuItems: () -> Menu) { self.title = title self.actionTitle = nil self.action = nil From 6a6dc04fed43b3c38f1f020a5989ed7a143fd426 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Za=C2=A1d?= Date: Sat, 24 Oct 2026 21:13:15 +0300 Subject: [PATCH 7/9] fix(ios): DetailHero Trailing inference + presentationGrabber API - DetailHero's init re-declared shadowing the struct's own generic, leaving the type parameter unbound at call sites (could not be inferred). The init now uses the struct's generic parameter. - presentationGrabber does not exist as a View member in this SDK; the sheet grabber is presentationDragIndicator(.visible). --- ios/Carmen/Views/Components/Chrome.swift | 2 +- ios/Carmen/Views/DetailViews.swift | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/ios/Carmen/Views/Components/Chrome.swift b/ios/Carmen/Views/Components/Chrome.swift index ff75c73..b7e71f4 100644 --- a/ios/Carmen/Views/Components/Chrome.swift +++ b/ios/Carmen/Views/Components/Chrome.swift @@ -157,7 +157,7 @@ extension View { sheet(isPresented: isPresented) { SheetMenuView(title: title, content: content) .presentationDetents([.medium, .large]) - .presentationGrabber(true) + .presentationDragIndicator(.visible) .presentationCornerRadius(28) } } diff --git a/ios/Carmen/Views/DetailViews.swift b/ios/Carmen/Views/DetailViews.swift index f7bd103..89ff3cf 100644 --- a/ios/Carmen/Views/DetailViews.swift +++ b/ios/Carmen/Views/DetailViews.swift @@ -41,7 +41,7 @@ private struct DetailHero: View { @Environment(\.m3) private var m3 @Environment(Player.self) private var player - init( + init( title: String, artworkURL: String?, placeholderSymbol: String = "music.note", From 540fc3c08423815a67f721854410e740e0c51968 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Za=C2=A1d?= Date: Sat, 24 Oct 2026 21:30:23 +0300 Subject: [PATCH 8/9] fix(ios): rewrite QueuePanel upcoming rows over slice indices Replace ForEach(Array(upcoming.enumerated()), id: \.offset) with ForEach(upcoming.indices, id: \.self): ArraySlice indices are already absolute queue positions, so the pair/offset arithmetic disappears and the constraint system that ios-check choked on goes with it. --- ios/Carmen/Views/QueuePanel.swift | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/ios/Carmen/Views/QueuePanel.swift b/ios/Carmen/Views/QueuePanel.swift index 9aed6df..614f80b 100644 --- a/ios/Carmen/Views/QueuePanel.swift +++ b/ios/Carmen/Views/QueuePanel.swift @@ -103,10 +103,10 @@ struct QueuePanel: View { private var upcomingList: some View { VStack(spacing: 0) { - ForEach(Array(upcoming.enumerated()), id: \.offset) { pair in - let offset = pair.offset - let track = pair.element - let index = upcomingStart + offset + // ArraySlice indices are the base array's — `index` here IS the + // absolute queue position, no offset arithmetic needed. + ForEach(upcoming.indices, id: \.self) { index in + let track = upcoming[index] QueuePanelRow(track: track) .contentShape(Rectangle()) From 29c2c233f71281a4b0fd3d1f50a1841491077944 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Za=C2=A1d?= Date: Sat, 24 Oct 2026 21:46:56 +0300 Subject: [PATCH 9/9] fix(ios): spell the upcoming queue range explicitly in QueuePanel A bare upcoming.indices resolves to the SDK RangeSet-returning indices(where:) method instead of the Collection property, so ForEach received a function type. Expose the slice bounds as an explicitly typed Range from a getter instead. --- ios/Carmen/Views/QueuePanel.swift | 14 ++++++++++---- 1 file changed, 10 insertions(+), 4 deletions(-) diff --git a/ios/Carmen/Views/QueuePanel.swift b/ios/Carmen/Views/QueuePanel.swift index 614f80b..fb9745f 100644 --- a/ios/Carmen/Views/QueuePanel.swift +++ b/ios/Carmen/Views/QueuePanel.swift @@ -22,6 +22,14 @@ struct QueuePanel: View { player.queue.indices.contains(upcomingStart) ? player.queue[upcomingStart...] : player.queue[0..<0] } + /// The upcoming rows as an absolute range of queue indices — spelled as + /// `Range` explicitly: a bare `.indices` on the slice resolves + /// ambiguously against the SDK's RangeSet-returning `indices(where:)`. + private var upcomingRange: Range { + let queue = player.queue + return queue.indices.contains(upcomingStart) + ? upcomingStart..