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/Fonts/poppins.ttf b/ios/Carmen/Fonts/poppins.ttf new file mode 100644 index 0000000..9f0c71b Binary files /dev/null and b/ios/Carmen/Fonts/poppins.ttf differ diff --git a/ios/Carmen/Views/Components.swift b/ios/Carmen/Views/Components.swift deleted file mode 100644 index 1f3c959..0000000 --- a/ios/Carmen/Views/Components.swift +++ /dev/null @@ -1,704 +0,0 @@ -// -// Components.swift -// Carmen — the shared building blocks every screen composes from: -// navigation routes, artwork, track rows, feed tiles and state views. -// - -import SwiftUI - -// MARK: - Accent - -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 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 -/// *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: - 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 { - 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: - 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. -/// 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/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..b7e71f4 --- /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]) + .presentationDragIndicator(.visible) + .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..1e54e8b --- /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, @ViewBuilder menuItems: () -> 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) + } +} diff --git a/ios/Carmen/Views/DetailViews.swift b/ios/Carmen/Views/DetailViews.swift index 257ebd8..89ff3cf 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..fb9745f --- /dev/null +++ b/ios/Carmen/Views/QueuePanel.swift @@ -0,0 +1,183 @@ +// +// 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] + } + /// 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.. 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(upcomingRange, id: \.self) { index in + let track = player.queue[index] + + 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/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/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/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) } } 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) + } +} 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 } +} 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