From a0b73b3593db26825f0607411e85d41ddf66a1a8 Mon Sep 17 00:00:00 2001 From: Enes Yilmaz Date: Mon, 28 Sep 2026 23:48:48 -0400 Subject: [PATCH 01/14] feat(player): playback speed applies to every track, speed pill always shown --- scripts/shoot-speed.mjs | 39 ++++++++++++++++++++++++++++++++++++ src/components/Player.jsx | 11 +++++----- src/index.css | 3 +++ src/state/PlayerProvider.jsx | 8 ++++---- 4 files changed, 51 insertions(+), 10 deletions(-) create mode 100644 scripts/shoot-speed.mjs diff --git a/scripts/shoot-speed.mjs b/scripts/shoot-speed.mjs new file mode 100644 index 0000000..0a4f0be --- /dev/null +++ b/scripts/shoot-speed.mjs @@ -0,0 +1,39 @@ +// Now Playing with the speed pill on a regular track. node scripts/shoot-speed.mjs +import { chromium, devices } from 'playwright' +import { createServer } from 'vite' +import { resolve, dirname } from 'node:path' +import { fileURLToPath } from 'node:url' + +const here = dirname(fileURLToPath(import.meta.url)) +const shot = (name) => resolve(here, `../screenshots/${name}.png`) +const server = await createServer({ root: resolve(here, '..'), server: { port: 5193, strictPort: true }, logLevel: 'error' }) +await server.listen() + +const browser = await chromium.launch({ args: ['--autoplay-policy=no-user-gesture-required'] }) +const page = await (await browser.newContext({ ...devices['iPhone 13'] })).newPage() +try { + await page.goto('http://localhost:5193', { waitUntil: 'networkidle' }) + await page.waitForTimeout(800) + await page.locator('.row__main').first().click() + await page.waitForTimeout(600) + await page.locator('.mini').click() + await page.waitForTimeout(600) + await page.screenshot({ path: shot('15-speed-1x') }) + + await page.getByLabel(/Playback speed/).click() // 1.25 + await page.getByLabel(/Playback speed/).click() // 1.5 + await page.waitForTimeout(400) + const rate = await page.evaluate(() => document.querySelector('audio').playbackRate) + console.log('playbackRate on a regular track:', rate) + await page.screenshot({ path: shot('16-speed-1.5x') }) + + await page.reload({ waitUntil: 'networkidle' }) + await page.waitForTimeout(800) + await page.locator('.row__main').nth(1).click() + await page.waitForTimeout(800) + console.log('after reload, pill says:', await page.locator('.mini').count() && await page.evaluate(() => localStorage.getItem('melody.speed')), + 'rate:', await page.evaluate(() => document.querySelector('audio').playbackRate)) +} finally { + await browser.close() + await server.close() +} diff --git a/src/components/Player.jsx b/src/components/Player.jsx index 7298a67..6136e7c 100644 --- a/src/components/Player.jsx +++ b/src/components/Player.jsx @@ -132,11 +132,7 @@ function NowPlaying({ p, onClose }) { )} - {p.longForm ? ( - - ) : ( +
- )} + +
) } diff --git a/src/index.css b/src/index.css index aac3a51..f28140d 100644 --- a/src/index.css +++ b/src/index.css @@ -553,6 +553,9 @@ body { cursor: pointer; position: relative; } .loopbtn--all, .loopbtn--one { color: var(--accent); border-color: color-mix(in srgb, var(--accent) 40%, var(--line)); background: var(--accent-soft); } +.now__pills { display: flex; gap: 10px; justify-content: center; } +.speedbtn--on { border-color: color-mix(in srgb, var(--accent) 40%, var(--line)); background: var(--accent-soft); } +.speedbtn.speedbtn--on span { color: var(--accent); } .loopbtn__one { position: absolute; top: -3px; right: 8px; font-style: normal; font-size: 9px; font-weight: 800; diff --git a/src/state/PlayerProvider.jsx b/src/state/PlayerProvider.jsx index 098ba31..f2ec92b 100644 --- a/src/state/PlayerProvider.jsx +++ b/src/state/PlayerProvider.jsx @@ -10,7 +10,7 @@ export const usePlayer = () => useContext(PlayerContext) // off → all (loop the whole queue/playlist) → one (loop this song) → off export const LOOP_MODES = ['off', 'all', 'one'] -// Podcast speeds, cycled by the speed pill. Music always plays at 1x. +// Playback speeds, cycled by the speed pill. Applies to everything. export const SPEEDS = [1, 1.25, 1.5, 1.75, 2, 0.75] const SKIP_BACK = 15 const SKIP_FORWARD = 30 @@ -61,12 +61,12 @@ export function PlayerProvider({ children }) { const current = index >= 0 ? queue[index] : null const longForm = isLongForm(current) - // Long-form plays at the chosen speed; music always at 1x. Safari resets - // playbackRate on every load, so this also runs from loadedmetadata. + // Everything plays at the chosen speed. Safari resets playbackRate on + // every load, so this also runs from loadedmetadata. const applyRate = useCallback(() => { const audio = audioRef.current if (!audio) return - const rate = isLongForm(loadedRef.current) ? speedRef.current : 1 + const rate = speedRef.current audio.defaultPlaybackRate = rate audio.playbackRate = rate }, []) From 6344f30e152a62d4804259657c21f5893e244762 Mon Sep 17 00:00:00 2001 From: Enes Yilmaz Date: Tue, 29 Sep 2026 00:53:11 -0400 Subject: [PATCH 02/14] feat(ui): adopt the mockup design tokens, one icon set and the width-axis font --- index.html | 2 +- src/components/Icon.jsx | 58 +++ src/index.css | 1050 ++++++++++++++++++++------------------- src/lib/motion.js | 9 + src/main.jsx | 2 +- vite.config.js | 4 +- 6 files changed, 605 insertions(+), 520 deletions(-) create mode 100644 src/components/Icon.jsx create mode 100644 src/lib/motion.js diff --git a/index.html b/index.html index 858f74b..09aaf31 100644 --- a/index.html +++ b/index.html @@ -19,7 +19,7 @@ - + Melody diff --git a/src/components/Icon.jsx b/src/components/Icon.jsx new file mode 100644 index 0000000..f039133 --- /dev/null +++ b/src/components/Icon.jsx @@ -0,0 +1,58 @@ +// One SF-Symbols-like set: filled glyphs for transport, 2px round strokes +// elsewhere. +const S = 'fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"' +const NUM = 'text-anchor="middle" font-size="8.4" font-weight="750" style="font-stretch:75%;font-family:inherit" fill="currentColor"' +const NEXT = '' +const REPEAT = `` +const STAR = 'M12 2.9l2.75 5.6 6.15.9-4.45 4.35 1.05 6.15L12 17l-5.5 2.9 1.05-6.15L3.1 9.4l6.15-.9z' + +const ICONS = { + play: '', + pause: '', + next: NEXT, + prev: `${NEXT}`, + back15: `15`, + fwd30: `30`, + shuffle: ``, + repeat: REPEAT, + repeat1: `${REPEAT}`, + lyrics: ``, + star: ``, + starline: ``, + more: '', + search: ``, + plus: ``, + minus: ``, + sort: ``, + import: ``, + export: ``, + restore: ``, + tab_library: '', + tab_search: ``, + tab_playlists: ``, + check: ``, + chev: ``, + back: ``, + trash: ``, + pencil: ``, + playnext: ``, + queue: ``, + paste: ``, + xfill: '', + note: ``, +} + +export default function Icon({ name, size = 22, className, style }) { + return ( + + ) +} diff --git a/src/index.css b/src/index.css index f28140d..8fd4877 100644 --- a/src/index.css +++ b/src/index.css @@ -1,22 +1,51 @@ :root { - /* Warm "analog studio" palette — warm near-black, warm off-white, ONE amber - signal used only for active/playing states. Deliberately not blue/purple. */ - --bg: #110f0c; - --bg-grad: #1a160f; - --surface: #1b1813; - --surface-2: #262119; - --line: #2c271e; - --text: #f3efe6; - --text-dim: #9b9387; - --text-faint: #6c655b; - --accent: #e9a23b; - --accent-soft: #2a2114; + /* Warm near-black, warm off-white, one amber signal for "playing" and the + active tab. Tokens follow the approved mockups (dir-D/base.css). */ + --bg: #15120e; + --surface: #1f1b16; + --raised: #2a251e; + --hair: rgba(242, 236, 226, .09); + --text: #f2ece2; + --dim: #a39a8c; + --faint: #857c6f; + --accent: #eba73e; + --accent-ink: #1c1407; + --accent-soft: rgba(235, 167, 62, .15); + --press: rgba(242, 236, 226, .07); + --danger: #e8704a; + + /* legacy names still used by the (unreachable) podcast screens */ + --surface-2: var(--raised); + --line: var(--hair); + --text-dim: var(--dim); + --text-faint: var(--faint); --font: 'Bricolage Grotesque Variable', -apple-system, system-ui, sans-serif; + --r-art: 6px; + --r-ctl: 10px; + --r-card: 14px; + --g: 16px; + + --ease-out: cubic-bezier(.22, 1, .36, 1); + --ease-sheet: cubic-bezier(.32, .72, 0, 1); + --dur-press: .12s; + --dur-fade: .25s; + --dur-present: .38s; + + /* Safe areas as variables so screenshot scripts can emulate a notch. */ + --sat: env(safe-area-inset-top, 0px); + --sab: env(safe-area-inset-bottom, 0px); + --dock-h: 150px; /* measured at runtime, see App */ + color-scheme: dark; } +@media (prefers-reduced-motion: reduce) { + :root { --dur-press: .01s; --dur-fade: .01s; --dur-present: .01s; } + *, *::before, *::after { animation-duration: .01s !important; animation-iteration-count: 1 !important; } +} + * { box-sizing: border-box; -webkit-tap-highlight-color: transparent; @@ -25,15 +54,8 @@ html, body, #root { - /* Pin the app to the true VISIBLE viewport on iOS. Plain vh/dvh can - OVER-resolve at first paint — iOS measures the toolbar-hidden height before - layout settles — making the page taller than what's actually visible. - Because the body wasn't scroll-locked (see below), that overflow became - scrollable and the in-flow dock started just below the fold until a swipe - forced a viewport recompute. svh is the *small* viewport (always <= - visible), so the dock is guaranteed on screen; in an installed standalone - PWA svh == dvh, so there's no black-gap regression. vh/dvh stay as - fallbacks for engines without svh. */ + /* svh is the small viewport (always <= visible), so the dock can never sit + below the fold at first paint; vh/dvh are fallbacks. */ height: 100vh; height: 100dvh; height: 100svh; @@ -41,17 +63,19 @@ body, } body { - background: var(--bg); + /* black shows only in the gap above Now Playing, like an iOS card stack */ + background: #000; color: var(--text); font-family: var(--font); font-optical-sizing: auto; + font-size: 16px; + -webkit-font-smoothing: antialiased; overscroll-behavior: none; -webkit-user-select: none; user-select: none; - /* Lock the document so ONLY .content scrolls — belt-and-suspenders with the - svh height above. Even if the viewport height is momentarily miscalculated, - the page itself can't scroll, so the dock can never be pushed below the - fold. The inner .content keeps its own overflow-y:auto and scrolls fine. */ + -webkit-touch-callout: none; + -webkit-text-size-adjust: 100%; + /* Only the view scroller moves; the document never scrolls. */ position: fixed; top: 0; left: 0; @@ -59,556 +83,550 @@ body { overflow: hidden; } +button { font: inherit; color: inherit; } +img { -webkit-user-drag: none; } +p { margin: 0; } +h1, h2, h3 { margin: 0; } +svg { display: block; flex-shrink: 0; } +:focus { outline: none; } +:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; } +::selection { background: rgba(235, 167, 62, .35); color: var(--text); } + +/* The numeric voice: condensed, tabular, heavy. Times, counts, part numbers. */ +.num { + font-stretch: 75%; + font-variant-numeric: tabular-nums; + font-weight: 650; + letter-spacing: .01em; +} + +.dim { color: var(--dim); } + .app { - display: flex; - flex-direction: column; - height: 100%; - position: relative; - overflow: hidden; /* only .content scrolls; dock stays put */ + position: absolute; + inset: 0; + overflow: hidden; + background: var(--bg); + transform-origin: 50% 0; + /* --np: 0 closed … 1 Now Playing fully up. The page behind recedes. */ + transform: translateY(calc(var(--np, 0) * (var(--sat) - 7px))) scale(calc(1 - var(--np, 0) * .07)); + border-radius: calc(var(--np, 0) * 12px); + opacity: calc(1 - var(--np, 0) * .45); } +.np-anim .app { transition: transform var(--dur-present) var(--ease-sheet), border-radius var(--dur-present) var(--ease-sheet), opacity var(--dur-present) var(--ease-sheet); } -/* ── content / native large titles ──────────────────────────── */ +/* ── view scroller ───────────────────────────────────────────── */ .content { - flex: 1; + position: absolute; + inset: 0; overflow-y: auto; + overscroll-behavior-y: contain; -webkit-overflow-scrolling: touch; - padding: calc(env(safe-area-inset-top) + 16px) 20px 14px; -} - -.view__head { margin-bottom: 18px; } -.view__titlerow { display: flex; align-items: flex-start; justify-content: space-between; gap: 12px; } - -.importbtn { display: inline-flex; align-items: center; gap: 7px; flex-shrink: 0; margin-top: 6px; } -.importbtn:disabled { opacity: 0.7; } - -.spinner { - width: 14px; height: 14px; border-radius: 50%; - border: 2px solid var(--text-faint); border-top-color: var(--text); - animation: spin 0.7s linear infinite; -} -@keyframes spin { to { transform: rotate(360deg); } } -.view__head h1 { - font-size: 32px; - font-weight: 800; - letter-spacing: -0.03em; - margin: 6px 0 0; -} -.view__meta { margin: 4px 0 0; font-size: 13px; } -.eyebrow { - font-size: 11px; - font-weight: 700; - letter-spacing: 0.18em; - text-transform: uppercase; - color: var(--accent); - margin: 0; + padding: var(--sat) var(--g) calc(var(--dock-h) + 24px); } +.view { min-height: 100%; } -.dim { color: var(--text-dim); } -.hint { font-size: 12px; margin-top: 18px; color: var(--text-faint); } - -/* Shared playlist header — identical shape for the list and the detail view so - the title + top-right action never shift position between them. */ -.phead { - display: flex; align-items: center; gap: 10px; - min-height: 44px; margin: 4px 0 18px; +/* Top bar: a single trailing control, no title. */ +.topbar { + display: flex; align-items: center; justify-content: flex-end; gap: 4px; + height: 52px; margin: 0 -10px 0 -10px; padding-top: 4px; } -.phead h1 { - flex: 1; min-width: 0; - font-size: 32px; font-weight: 800; letter-spacing: -0.03em; margin: 0; - white-space: nowrap; overflow: hidden; text-overflow: ellipsis; +.topbar--split { justify-content: space-between; } + +.iconbtn { + width: 44px; height: 44px; border-radius: 22px; flex-shrink: 0; + display: grid; place-items: center; + border: 0; background: none; color: var(--text); cursor: pointer; padding: 0; + transition: opacity var(--dur-press), transform var(--dur-press); } -.phead__back { margin-left: -8px; color: var(--text); } -.phead .iconbtn { color: var(--text); } -.phead__meta { margin: -10px 0 16px; font-size: 13px; } +.iconbtn:active { opacity: .5; } +.iconbtn:disabled { opacity: .35; } /* ── buttons ─────────────────────────────────────────────────── */ .btn { - border: none; - border-radius: 999px; - padding: 11px 18px; - font-family: var(--font); - font-size: 14px; - font-weight: 700; - cursor: pointer; - color: var(--text); + height: 46px; padding: 0 18px; border-radius: var(--r-ctl); border: 0; + display: inline-flex; align-items: center; justify-content: center; gap: 8px; + font: 600 16px var(--font); cursor: pointer; + background: var(--raised); color: var(--accent); + transition: transform var(--dur-press), filter var(--dur-press); +} +.btn:active { transform: scale(.97); filter: brightness(.9); } +.btn--accent { background: var(--accent); color: var(--accent-ink); } +.btn--ghost { background: var(--raised); color: var(--text); } +.btn--danger { background: var(--danger); color: #1c0d07; } +.btn:disabled { opacity: .45; } +.btnrow { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; margin-top: 18px; } + +/* ── rows ────────────────────────────────────────────────────── */ +.list { margin: 0 calc(var(--g) * -1); } +.row { + position: relative; display: flex; align-items: center; gap: 12px; + width: 100%; min-height: 60px; padding: 8px var(--g); + border: 0; background: none; color: var(--text); text-align: left; cursor: pointer; + transition: background var(--dur-press); +} +.row:active { background: var(--press); } +/* separator starts at the text column */ +.row::after { + content: ''; position: absolute; left: 72px; right: 0; bottom: 0; height: .5px; + background: var(--hair); +} +.row:last-child::after { display: none; } +.row__text { flex: 1; min-width: 0; } +.row__title { + display: block; font-size: 16px; font-weight: 600; letter-spacing: -.005em; + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } -.btn--accent { background: var(--accent); color: #1c1408; } -.btn--accent:disabled { opacity: 0.35; } -.btn--ghost { background: var(--surface-2); } -.btn--danger { background: #e0673e; color: #1c0d07; } - -.row-actions { display: flex; gap: 10px; margin-bottom: 18px; } -.playall { - display: inline-flex; align-items: center; gap: 8px; -} - -/* segmented control */ -/* Row of segmented controls (Library: filter + sort). Wraps on narrow screens - instead of squeezing the pills. */ -.segrow { display: flex; flex-wrap: wrap; column-gap: 10px; align-items: center; } -.segmented { - display: inline-flex; - background: var(--surface); - border: 1px solid var(--line); - border-radius: 999px; - padding: 3px; - margin-top: 14px; -} -.segmented button { - border: none; background: none; - font-family: var(--font); - color: var(--text-dim); - font-size: 13px; font-weight: 600; - padding: 7px 16px; border-radius: 999px; cursor: pointer; +.row__sub { + display: block; font-size: 13.5px; color: var(--dim); margin-top: 2px; + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; +} +.row__end { + flex: none; display: flex; align-items: center; gap: 8px; + color: var(--faint); font-size: 14.5px; +} +.row__end .num { min-width: 34px; text-align: right; } +.row--playing .row__title { color: var(--accent); } +.star { color: var(--accent); } +.chev { color: var(--faint); } + +/* playing indicator; frozen while paused */ +.eq { display: flex; gap: 2px; align-items: flex-end; height: 14px; width: 34px; justify-content: flex-end; } +.eq i { width: 3px; height: 40%; background: var(--accent); border-radius: 1px; animation: eq .9s ease-in-out infinite; } +.eq i:nth-child(2) { animation-delay: -.3s; } +.eq i:nth-child(3) { animation-delay: -.6s; } +.eq i:nth-child(4) { animation-delay: -.15s; } +.eq--paused i { animation-play-state: paused; } +@keyframes eq { 0%, 100% { height: 30%; } 50% { height: 100%; } } + +/* ── artwork ─────────────────────────────────────────────────── */ +.artwork { + flex: none; position: relative; overflow: hidden; display: block; + background: var(--raised); +} +.artwork::after { content: ''; position: absolute; inset: 0; border-radius: inherit; box-shadow: inset 0 0 0 .5px rgba(255, 255, 255, .08); pointer-events: none; } +.artwork img { width: 100%; height: 100%; object-fit: cover; display: block; pointer-events: none; } +.artwork__zoom { transform: scale(1.36); } +.artwork--ph { display: grid; place-items: center; } +.artwork--ph svg { opacity: .5; color: var(--text); } +/* numbered series: the number is the artwork */ +.tile { + display: grid; place-items: center; + background: radial-gradient(120% 90% at 20% 10%, hsl(var(--h) 48% 36%), hsl(var(--h) 42% 16%) 75%); } -.segmented button.on { background: var(--text); color: var(--bg); } +.tile b { + font-stretch: 75%; font-weight: 800; font-variant-numeric: tabular-nums; + letter-spacing: -.01em; color: rgba(248, 236, 214, .92); line-height: 1; +} + +/* ── dock: mini player + tab bar, translucent, content scrolls under ── */ +.dock { + position: absolute; left: 0; right: 0; bottom: 0; z-index: 40; + padding: 8px 8px 0; + background: rgba(21, 18, 14, .78); + border-top: .5px solid rgba(242, 236, 226, .07); + -webkit-backdrop-filter: saturate(170%) blur(22px); + backdrop-filter: saturate(170%) blur(22px); +} +.dock--bare { padding-top: 0; } -/* Library empty state — centered, unlike SearchView's left-aligned .empty. */ +.mini { + position: relative; height: 60px; border-radius: var(--r-card); + background: rgba(46, 40, 32, .82); + box-shadow: 0 6px 22px rgba(0, 0, 0, .35), inset 0 0 0 .5px rgba(255, 255, 255, .07); + display: flex; align-items: center; gap: 11px; padding: 0 4px 0 9px; + overflow: hidden; cursor: pointer; touch-action: none; + transition: transform var(--dur-press) var(--ease-out); +} +.mini:active { transform: scale(.985); } +.mini .artwork { border-radius: var(--r-art); } +.mini__t { flex: 1; min-width: 0; } +.mini__title { display: block; font-size: 15px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.mini__sub { display: block; font-size: 13px; color: var(--accent); margin-top: 1px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.mini__prog { position: absolute; left: 14px; right: 14px; bottom: 0; height: 2px; background: rgba(242, 236, 226, .12); border-radius: 1px; overflow: hidden; } +.mini__prog i { position: absolute; inset: 0; background: var(--text); border-radius: 1px; transform-origin: 0 50%; } + +.tabs { + display: grid; grid-template-columns: repeat(3, 1fr); + margin: 0 -8px; padding: 7px 0 calc(var(--sab) + 4px); +} +.mini + .tabs { margin-top: 8px; } +.tab { + display: flex; flex-direction: column; align-items: center; gap: 3px; + min-height: 46px; border: 0; background: none; cursor: pointer; padding: 0; + color: var(--faint); font-size: 10.5px; font-weight: 600; letter-spacing: .01em; + transition: opacity var(--dur-press); +} +.tab:active { opacity: .6; } +.tab--on { color: var(--accent); } + +/* Toasts and the hidden build readout float just above the dock. */ +.versionpeek, .toast { + position: absolute; bottom: calc(100% + 8px); left: 50%; transform: translateX(-50%); + max-width: calc(100% - 32px); padding: 9px 14px; border: 0; border-radius: 12px; + background: rgba(58, 51, 42, .96); color: var(--text); box-shadow: 0 8px 24px rgba(0, 0, 0, .4); + font: 500 13.5px/1.35 var(--font); text-align: center; cursor: pointer; z-index: 5; + animation: toast-in var(--dur-fade) var(--ease-out); +} +.versionpeek { color: var(--dim); font-size: 11.5px; white-space: nowrap; } +@keyframes toast-in { from { opacity: 0; transform: translate(-50%, 6px); } } + +/* import progress under the top bar */ +.importing { display: flex; align-items: center; gap: 5px; flex-wrap: wrap; font-size: 13.5px; color: var(--dim); margin: -4px 0 10px; } +.importing__bar { flex-basis: 100%; height: 3px; border-radius: 2px; background: rgba(242, 236, 226, .12); overflow: hidden; margin-top: 4px; } +.importing__bar i { display: block; height: 100%; background: var(--accent); transform-origin: 0 50%; transition: transform var(--dur-fade); } + +/* ── empty states ────────────────────────────────────────────── */ .emptystate { - display: flex; flex-direction: column; align-items: center; - text-align: center; gap: 14px; padding-top: 48px; + display: flex; flex-direction: column; align-items: center; text-align: center; + gap: 10px; padding: 18vh 12px 0; } +.emptystate svg { color: var(--faint); margin-bottom: 6px; } +.emptystate h2 { font-size: 20px; font-weight: 700; letter-spacing: -.01em; } +.emptystate p { color: var(--dim); font-size: 15px; line-height: 1.45; max-width: 30ch; } +.emptystate .btn { margin-top: 12px; min-width: 180px; } /* ── search ──────────────────────────────────────────────────── */ .searchbar { - display: flex; align-items: center; gap: 10px; - background: var(--surface); - border: 1px solid var(--line); - border-radius: 14px; - padding: 0 14px; - margin: 8px 0 20px; - color: var(--text-dim); + display: flex; align-items: center; gap: 6px; + height: 40px; margin: 10px 0 12px; padding: 0 4px 0 10px; + border-radius: var(--r-ctl); background: rgba(118, 112, 100, .24); color: var(--dim); } .searchbar__input { - flex: 1; border: none; background: none; - font-family: var(--font); - color: var(--text); font-size: 16px; /* 16px = no iOS zoom-on-focus */ - padding: 13px 0; outline: none; + flex: 1; min-width: 0; height: 100%; border: 0; background: none; outline: none; + font: 400 17px var(--font); color: var(--text); caret-color: var(--accent); + -webkit-user-select: text; user-select: text; } +.searchbar__input::placeholder { color: var(--faint); } +.searchbar__input::-webkit-search-cancel-button { -webkit-appearance: none; appearance: none; } .searchbar__icon { - border: none; background: none; color: var(--text-dim); - display: grid; place-items: center; cursor: pointer; - width: 32px; height: 32px; font-size: 22px; line-height: 1; flex-shrink: 0; -} -.searchhint { padding-top: 12px; line-height: 1.5; } -.empty { display: flex; flex-direction: column; gap: 18px; align-items: flex-start; padding-top: 12px; } - -/* ── YouTube link → command card ─────────────────────────────── */ -.ytcard { - background: var(--surface); - border: 1px solid var(--line); - border-radius: 16px; - padding: 16px; -} -.ytcard .eyebrow { margin-bottom: 12px; } -.ytcard__preview { display: flex; gap: 12px; align-items: center; margin-bottom: 16px; } -.ytcard__preview img { width: 88px; height: 50px; object-fit: cover; border-radius: 8px; flex-shrink: 0; background: var(--surface-2); } -.ytcard__pmeta { min-width: 0; } -.ytcard__title { font-size: 15px; font-weight: 600; margin: 0 0 2px; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; } -/* the command box is the copy button — tap it to copy */ + width: 36px; height: 36px; border: 0; background: none; color: var(--dim); + display: grid; place-items: center; cursor: pointer; padding: 0; border-radius: 18px; +} +.searchbar__icon:active { opacity: .5; } +.pasterow { margin-top: 4px; } +.pasterow .row__art { width: 44px; height: 44px; border-radius: var(--r-art); display: grid; place-items: center; background: var(--raised); color: var(--accent); flex: none; } +.pasterow .row__title { color: var(--accent); } +.pasterow::after { display: none; } +.searchnote { color: var(--faint); font-size: 14px; line-height: 1.45; margin-top: 10px; } +.noresults { text-align: center; padding-top: 14vh; } +.noresults svg { color: var(--faint); margin: 0 auto 12px; } +.noresults h2 { font-size: 20px; font-weight: 700; } +.noresults p { color: var(--dim); font-size: 15px; margin-top: 6px; } + +/* ── YouTube link card ───────────────────────────────────────── */ +.ytcard { background: var(--surface); border-radius: var(--r-card); padding: 14px; } +.ytcard__preview { display: flex; gap: 12px; align-items: center; margin-bottom: 14px; min-height: 63px; } +.ytcard__preview img { width: 112px; height: 63px; object-fit: cover; border-radius: 8px; flex-shrink: 0; background: var(--raised); } +.ytcard__skel { width: 112px; height: 63px; border-radius: 8px; background: var(--raised); flex-shrink: 0; } +.ytcard__title { font-size: 15px; font-weight: 600; line-height: 1.3; display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden; } +.ytcard__copy { width: 100%; height: 50px; } .ytcard__cmd { - display: block; width: 100%; text-align: left; - background: var(--bg); - border: 1px solid var(--line); - border-radius: 10px; - padding: 12px; - cursor: pointer; - transition: border-color 0.15s ease; -} -.ytcard__cmd.is-copied { border-color: var(--accent); } -.ytcard__cmd code { - display: block; - font-family: ui-monospace, 'SF Mono', Menlo, monospace; - font-size: 12.5px; line-height: 1.5; color: var(--text); - white-space: pre-wrap; word-break: break-all; -} -.ytcard__hint { - display: block; margin-top: 8px; - font-size: 11px; font-weight: 700; letter-spacing: 0.06em; text-transform: uppercase; - color: var(--text-faint); -} -.ytcard__cmd.is-copied .ytcard__hint { color: var(--accent); } -.ytcard__note { margin: 14px 0 0; font-size: 13px; color: var(--text-dim); } + margin-top: 10px; font-family: ui-monospace, 'SF Mono', Menlo, monospace; font-size: 11.5px; + color: var(--faint); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + -webkit-user-select: text; user-select: text; +} +.ytcard__note { margin-top: 12px; font-size: 14px; color: var(--dim); line-height: 1.45; } .ytcard__note b { color: var(--text); font-weight: 600; } -/* ── track rows ──────────────────────────────────────────────── */ -.list { display: flex; flex-direction: column; } -.row { display: flex; align-items: center; gap: 2px; padding: 5px 0; } -.row--active .row__title { color: var(--accent); } -.row__main { - flex: 1; min-width: 0; - display: flex; align-items: center; gap: 13px; - background: none; border: none; color: inherit; - text-align: left; cursor: pointer; padding: 5px 2px; -} -.row__meta { min-width: 0; display: flex; flex-direction: column; gap: 2px; } -.row__title { - font-size: 15px; font-weight: 600; letter-spacing: -0.01em; - white-space: nowrap; overflow: hidden; text-overflow: ellipsis; -} -.row__artist { - font-size: 13px; color: var(--text-dim); +/* ── playlists ───────────────────────────────────────────────── */ +.plrow { min-height: 72px; } +.plrow::after { left: 84px; } +.mosaic { + flex: none; width: 56px; height: 56px; border-radius: var(--r-art); overflow: hidden; + display: grid; grid-template-columns: 1fr 1fr; background: var(--raised); +} +.mosaic .artwork { width: 28px !important; height: 28px !important; border-radius: 0 !important; } +.mosaic--one { display: block; } +.mosaic--one .artwork { width: 56px !important; height: 56px !important; } +.hero .mosaic { width: 200px; height: 200px; border-radius: 10px; box-shadow: 0 18px 44px rgba(0, 0, 0, .45); } +.hero .mosaic .artwork { width: 100px !important; height: 100px !important; } +.hero .mosaic--one .artwork { width: 200px !important; height: 200px !important; } +.hero .mosaic .tile b { font-size: 34px !important; } +.hero .mosaic--one .tile b { font-size: 72px !important; } + +/* sticky compact bar inside the detail view */ +.navbar { + position: sticky; top: calc(var(--sat) * -1); z-index: 10; + display: flex; align-items: center; justify-content: space-between; + height: calc(var(--sat) + 48px); padding-top: var(--sat); + margin: calc(var(--sat) * -1) calc(var(--g) * -1) 0; padding-left: 6px; padding-right: 6px; + background: rgba(21, 18, 14, .8); + -webkit-backdrop-filter: saturate(170%) blur(22px); + backdrop-filter: saturate(170%) blur(22px); +} +.navbar__title { + flex: 1; min-width: 0; text-align: center; font-size: 17px; font-weight: 650; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; + opacity: 0; transition: opacity var(--dur-fade); } -.row__dur { - font-size: 12px; color: var(--text-faint); margin: 0 6px; - font-variant-numeric: tabular-nums; -} - -.artwork { object-fit: cover; flex-shrink: 0; } -.artwork--ph { display: grid; place-items: center; } +.navbar--scrolled { box-shadow: 0 .5px 0 var(--hair); } +.navbar--scrolled .navbar__title { opacity: 1; } +.navbar .iconbtn { color: var(--accent); } -.iconbtn { - border: none; background: none; color: var(--text-dim); - display: grid; place-items: center; - width: 36px; height: 36px; border-radius: 50%; - cursor: pointer; flex-shrink: 0; +.hero { display: flex; flex-direction: column; align-items: center; text-align: center; padding-top: 8px; } +.hero h1 { + margin-top: 18px; font-size: 24px; font-weight: 750; letter-spacing: -.02em; line-height: 1.15; + text-wrap: balance; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; } -.iconbtn:active { background: var(--surface-2); } -.iconbtn--star-on { color: var(--accent); } +.hero__meta { margin-top: 6px; color: var(--dim); font-size: 14.5px; } +.hero__meta .num { font-size: 15px; } +.hero + .btnrow { margin-bottom: 14px; } -/* now-playing equalizer on the active row */ -.eq { display: inline-flex; align-items: flex-end; gap: 2px; height: 13px; margin-right: 6px; } -.eq i { width: 2.5px; background: var(--accent); border-radius: 2px; animation: eq 0.9s ease-in-out infinite; } -.eq i:nth-child(2) { animation-delay: 0.3s; } -.eq i:nth-child(3) { animation-delay: 0.6s; } -@keyframes eq { 0%, 100% { height: 3px; } 50% { height: 13px; } } - -/* ── playlists ───────────────────────────────────────────────── */ -.plcard { - display: flex; align-items: center; gap: 14px; - width: 100%; text-align: left; - background: var(--surface); border: 1px solid var(--line); - border-radius: 14px; padding: 14px 18px; margin-bottom: 10px; - color: var(--text); cursor: pointer; font-family: var(--font); -} -/* 2×2 artwork mosaic; empty cells fall back to Artwork's gradient tiles. */ -.plcard__mosaic { - display: grid; grid-template-columns: repeat(2, 24px); gap: 2px; - border-radius: 8px; overflow: hidden; flex-shrink: 0; -} -.plcard__text { min-width: 0; display: flex; flex-direction: column; gap: 2px; } -.plcard__name { - font-size: 16px; font-weight: 600; - white-space: nowrap; overflow: hidden; text-overflow: ellipsis; +.resume { + display: flex; align-items: center; gap: 12px; width: 100%; text-align: left; + margin: 0 0 12px; padding: 12px 14px; border: 0; border-radius: var(--r-card); + background: var(--accent-soft); color: var(--accent); cursor: pointer; font-family: var(--font); + transition: transform var(--dur-press); } -.plcard__meta { font-size: 13px; } -.plrow { display: flex; align-items: center; } -.plrow .row { flex: 1; } +.resume:active { transform: scale(.98); } +.resume__text { flex: 1; min-width: 0; } +.resume__label { display: block; font-size: 13px; font-weight: 650; } +.resume__title { display: block; color: var(--text); font-size: 16px; font-weight: 600; margin-top: 1px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.resume__time { font-size: 14.5px; } -/* Quiet backup pills under the playlist list — same recipe as - .lyrics__research, deliberately dim so they read as an afterthought - next to + New rather than a feature. */ -.backuprow { display: flex; gap: 8px; margin-top: 26px; } -.backupbtn { - background: var(--surface-2); border: 1px solid var(--line); border-radius: 999px; - color: var(--text-dim); font-family: var(--font); font-size: 12px; font-weight: 600; - padding: 8px 16px; cursor: pointer; -} +.hint { color: var(--faint); font-size: 14px; line-height: 1.45; margin-top: 16px; text-align: center; } -/* ── bottom sheet ────────────────────────────────────────────── */ +/* ── sheets (grouped, like an iOS action sheet) ──────────────── */ .sheet-overlay { position: fixed; inset: 0; z-index: 60; - background: rgba(0, 0, 0, 0.55); - display: flex; align-items: flex-end; - backdrop-filter: blur(2px); + background: rgba(0, 0, 0, .45); + animation: fade var(--dur-fade) ease; + transition: opacity var(--dur-fade); } +.sheet-overlay.is-closing { opacity: 0; } +@keyframes fade { from { opacity: 0; } } .sheet { - width: 100%; background: var(--surface); - border-top: 1px solid var(--line); - border-radius: 20px 20px 0 0; - padding: 8px 16px calc(env(safe-area-inset-bottom) + 16px); - animation: rise 0.22s cubic-bezier(0.2, 0.7, 0.2, 1); -} -@keyframes rise { from { transform: translateY(100%); } to { transform: translateY(0); } } -.sheet__grip { width: 36px; height: 4px; border-radius: 2px; background: var(--line); margin: 8px auto 14px; } -.sheet__title { font-weight: 700; margin: 0 0 8px; font-size: 15px; } -.sheet__item { - display: flex; align-items: center; justify-content: space-between; - width: 100%; text-align: left; - background: none; border: none; border-bottom: 1px solid var(--line); - color: var(--text); font-family: var(--font); font-size: 15px; - padding: 15px 4px; cursor: pointer; -} -.sheet__item:disabled { color: var(--text-faint); } -.sheet__item--new { color: var(--accent); font-weight: 700; } -.sheet__item--danger { color: #e0673e; } -.sheet__item svg { color: var(--text-dim); } -.sheet__item--danger svg { color: #e0673e; } -.sheet__label { - font-size: 11px; font-weight: 700; letter-spacing: 0.12em; text-transform: uppercase; - color: var(--text-faint); margin: 18px 4px 2px; -} -.sheet__cancel { width: 100%; margin-top: 16px; } - -/* ── centered modal (e.g. new playlist name) ─────────────────── */ + position: fixed; left: 8px; right: 8px; bottom: calc(var(--sab) + 8px); z-index: 61; + display: flex; flex-direction: column; gap: 8px; + max-height: calc(100% - var(--sat) - 40px); + will-change: transform; +} +.sheet__grp { + background: rgba(44, 38, 31, .97); border-radius: var(--r-card); overflow: hidden; + -webkit-backdrop-filter: blur(20px); backdrop-filter: blur(20px); + display: flex; flex-direction: column; min-height: 0; +} +.sheet__grp--scroll { overflow-y: auto; overscroll-behavior: contain; flex-shrink: 1; } +.sheet__grp--fixed { flex-shrink: 0; } +.sheet__head { + display: flex; align-items: center; gap: 12px; padding: 12px 16px; + box-shadow: inset 0 -.5px 0 rgba(242, 236, 226, .12); +} +.sheet__head .artwork { border-radius: var(--r-art); } +.sheet__headtitle { flex: 1; min-width: 0; font-size: 15px; font-weight: 650; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.sheet__headsub { display: block; font-size: 13px; color: var(--dim); font-weight: 500; margin-top: 1px; } +.sheet__row { + display: flex; align-items: center; justify-content: space-between; gap: 12px; + width: 100%; min-height: 56px; padding: 0 18px; border: 0; background: none; + color: var(--text); font: 400 17px var(--font); text-align: left; cursor: pointer; + transition: background var(--dur-press); +} +.sheet__row:active { background: var(--press); } +.sheet__row + .sheet__row, .sheet__row + .seg, .sheet__sep { box-shadow: inset 0 .5px 0 rgba(242, 236, 226, .12); } +.sheet__row > span:first-child { min-width: 0; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.sheet__row svg { color: rgba(242, 236, 226, .7); } +.sheet__row--danger, .sheet__row--danger svg { color: var(--danger); } +.sheet__row--accent, .sheet__row--accent svg { color: var(--accent); } +.sheet__row--c { justify-content: center; font-weight: 600; } +.sheet__row:disabled { color: var(--faint); cursor: default; } +.sheet__row .check { color: var(--accent); } +.sheet__count { color: var(--faint); font-size: 15px; } +.sheet__label { padding: 12px 18px 6px; font-size: 13px; font-weight: 600; color: var(--dim); box-shadow: inset 0 .5px 0 rgba(242, 236, 226, .12); } + +/* speed: segmented control, no label */ +.seg { display: flex; margin: 0; padding: 14px 18px; } +.seg__track { flex: 1; display: flex; background: rgba(242, 236, 226, .08); border-radius: 9px; padding: 2px; } +.seg button { + flex: 1; height: 36px; border: 0; background: none; border-radius: 7px; cursor: pointer; + font: 600 15px var(--font); font-variant-numeric: tabular-nums; color: rgba(242, 236, 226, .75); + transition: background var(--dur-press), color var(--dur-press); +} +.seg button.on { background: rgba(242, 236, 226, .2); color: var(--text); } + +/* ── centered modal (new playlist name, confirm) ─────────────── */ .modal-overlay { position: fixed; inset: 0; z-index: 70; - background: rgba(0, 0, 0, 0.6); - backdrop-filter: blur(3px); - display: flex; align-items: center; justify-content: center; - padding: 24px; - animation: fade 0.15s ease; + background: rgba(0, 0, 0, .55); + display: flex; align-items: flex-start; justify-content: center; + padding: calc(var(--sat) + 14vh) 24px 24px; + animation: fade .15s ease; } -@keyframes fade { from { opacity: 0; } to { opacity: 1; } } .modal { - width: 100%; max-width: 360px; - background: var(--surface); - border: 1px solid var(--line); - border-radius: 20px; - padding: 22px; - box-shadow: 0 24px 60px rgba(0, 0, 0, 0.5); - animation: pop 0.2s cubic-bezier(0.2, 0.8, 0.2, 1); -} -@keyframes pop { from { transform: scale(0.94); opacity: 0; } to { transform: scale(1); opacity: 1; } } -.modal__title { font-size: 19px; font-weight: 700; letter-spacing: -0.01em; margin: 0 0 16px; } -.modal__msg { color: var(--text-dim); font-size: 14px; line-height: 1.5; margin: -6px 0 4px; } + width: 100%; max-width: 340px; padding: 20px; + background: rgba(44, 38, 31, .98); border-radius: 16px; + box-shadow: 0 24px 60px rgba(0, 0, 0, .5); + animation: pop .2s var(--ease-out); +} +@keyframes pop { from { transform: scale(.94); opacity: 0; } } +.modal__title { font-size: 18px; font-weight: 700; letter-spacing: -.01em; margin: 0 0 14px; } +.modal__msg { color: var(--dim); font-size: 15px; line-height: 1.45; margin: -4px 0 4px; } .modal__input { - width: 100%; - background: var(--bg); - border: 1px solid var(--line); - border-radius: 12px; - color: var(--text); - font-family: var(--font); - font-size: 16px; /* 16px = no iOS zoom-on-focus */ - padding: 13px 14px; - outline: none; + width: 100%; height: 46px; padding: 0 14px; + background: var(--bg); border: 1px solid var(--hair); border-radius: var(--r-ctl); + color: var(--text); font: 400 17px var(--font); outline: none; caret-color: var(--accent); + -webkit-user-select: text; user-select: text; } .modal__input:focus { border-color: var(--accent); } -.modal__actions { display: flex; gap: 10px; margin-top: 18px; } +.modal__actions { display: flex; gap: 10px; margin-top: 16px; } .modal__actions .btn { flex: 1; } -/* ── dock (mini-player + tab bar) — in normal flow at the bottom of the - flex column, so it's deterministically pinned to the screen bottom ── */ -.dock { - position: relative; - flex-shrink: 0; - background: var(--bg); - padding-bottom: env(safe-area-inset-bottom); +/* ── Now Playing ─────────────────────────────────────────────── */ +.now { + position: fixed; left: 0; right: 0; top: calc(var(--sat) + 7px); bottom: 0; z-index: 50; + border-radius: 14px 14px 0 0; overflow: hidden; isolation: isolate; + display: flex; flex-direction: column; align-items: stretch; + padding: 0 24px calc(max(var(--sab), 12px) + 12px); + background: #1a1612; + will-change: transform; } - -/* Hidden build readout (long-press the tab bar). Deliberately tiny and muted — - it should feel like a debug peek, not a feature. */ -.versionpeek { - position: absolute; - bottom: calc(100% + 6px); - left: 50%; - transform: translateX(-50%); - padding: 5px 11px; - border: 1px solid var(--line); - border-radius: 999px; - background: var(--surface-2); - color: var(--text-dim); - font-family: var(--font); - font-size: 11px; - white-space: nowrap; - cursor: pointer; - z-index: 5; -} -/* Transient status pill (import summaries, restore results). Same absolute- - inside-dock recipe as .versionpeek — never position:fixed, the dock carries - the safe-area inset and sits above the mini-player automatically. z-index 5 - keeps it under .now (50), sheets (60), and modals (70). */ -.toast { - position: absolute; - bottom: calc(100% + 6px); - left: 50%; - transform: translateX(-50%); - max-width: calc(100% - 32px); - padding: 7px 14px; - border: 1px solid var(--line); - border-radius: 999px; - background: var(--surface-2); - color: var(--text); - font-family: var(--font); - font-size: 12px; - cursor: pointer; - z-index: 5; +.now__bg { position: absolute; inset: 0; z-index: -2; overflow: hidden; pointer-events: none; } +.now__bg img, .now__bg .blob { + position: absolute; left: -30%; top: -30%; width: 160%; height: 160%; object-fit: cover; + filter: blur(70px) saturate(1.45); opacity: .7; } - -/* Only show a divider above the tab bar when nothing is playing; when the mini - card is present it provides the separation (and it's the dock's first child). */ -.dock > .tabbar:first-child { border-top: 1px solid var(--line); } - -/* mini-player — a floating card, not a flush strip */ -.mini { - position: relative; - display: flex; align-items: center; gap: 11px; - width: calc(100% - 16px); - margin: 8px 8px 8px; - background: var(--surface-2); - border: 1px solid var(--line); - border-radius: 14px; - padding: 9px 10px; - overflow: hidden; - cursor: pointer; color: inherit; text-align: left; - font-family: var(--font); - box-shadow: 0 10px 30px rgba(0, 0, 0, 0.45); +.now__bg .blob { + filter: blur(40px); opacity: .9; + background: radial-gradient(40% 40% at 35% 30%, hsl(var(--h) 62% 44%), transparent), radial-gradient(50% 50% at 70% 65%, hsl(calc(var(--h) - 6) 58% 27%), transparent), hsl(var(--h) 40% 12%); } -.mini__meta { flex: 1; min-width: 0; display: flex; flex-direction: column; gap: 1px; } -.mini__title { font-size: 14px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } -.mini__artist { font-size: 12px; color: var(--text-dim); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } -.mini__controls { display: flex; gap: 0; } -.mini__progress { position: absolute; left: 0; bottom: 0; height: 2px; background: var(--accent); transition: width 0.25s linear; } +.now__bg::after { content: ''; position: absolute; inset: 0; background: linear-gradient(180deg, rgba(18, 15, 12, .35), rgba(18, 15, 12, .72) 70%); } -/* tab bar */ -.tabbar { display: flex; padding: 4px 8px 6px; } -.tab { - flex: 1; display: flex; flex-direction: column; align-items: center; gap: 4px; - padding: 6px 0; background: none; border: none; - color: var(--text-faint); font-family: var(--font); - font-size: 10.5px; font-weight: 600; cursor: pointer; - transition: color 0.15s ease; +.grab { width: 36px; height: 5px; border-radius: 3px; background: rgba(242, 236, 226, .38); margin: 7px auto 0; flex: none; } +.source { + text-align: center; font-size: 13px; font-weight: 600; color: rgba(242, 236, 226, .62); + margin-top: 10px; flex: none; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } -.tab--active { color: var(--text); } -.tab--active svg { color: var(--accent); } +.source b { color: var(--text); font-weight: 650; } -/* ── now playing (full screen) ───────────────────────────────── */ -.now { - position: fixed; inset: 0; z-index: 50; - background: - radial-gradient(120% 70% at 50% -10%, var(--np-glow, #3a2c14) 0%, transparent 60%), - linear-gradient(180deg, var(--bg-grad), var(--bg) 55%); - display: flex; flex-direction: column; align-items: center; - padding: calc(env(safe-area-inset-top) + 10px) 28px calc(env(safe-area-inset-bottom) + 30px); - animation: rise 0.28s cubic-bezier(0.2, 0.7, 0.2, 1); -} -.now__bar { - display: flex; align-items: center; justify-content: space-between; - width: 100%; margin-bottom: 8px; +.now__art { + flex: 1 1 auto; min-height: 0; margin-top: 8px; + display: flex; align-items: center; justify-content: center; + container-type: size; +} +.cover { + width: min(100cqw, 100cqh, 342px); aspect-ratio: 1; border-radius: 12px; overflow: hidden; position: relative; + box-shadow: 0 22px 50px rgba(0, 0, 0, .5), 0 0 0 .5px rgba(255, 255, 255, .1); + background: var(--raised); + transition: transform .45s var(--ease-out); + container-type: inline-size; +} +.cover--paused { transform: scale(.86); } +.cover .fill { position: absolute; left: -10%; top: -10%; width: 120%; height: 120%; object-fit: cover; filter: blur(22px) brightness(.62) saturate(1.2); } +.cover .fit { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: contain; } +.cover .artwork { width: 100% !important; height: 100% !important; border-radius: 0 !important; } +.cover--series { + width: min(100cqw, 100cqh, 300px); + background: radial-gradient(130% 100% at 15% 0%, hsl(var(--h) 55% 40%), hsl(calc(var(--h) - 4) 48% 18%) 70%, hsl(calc(var(--h) - 8) 40% 11%)); + display: flex; flex-direction: column; justify-content: flex-end; padding: 7% 7.5% 5.5%; +} +.cover--series .big { + font-stretch: 75%; font-weight: 800; font-variant-numeric: tabular-nums; + font-size: 50cqw; line-height: .78; letter-spacing: -.035em; color: rgba(250, 236, 212, .95); + margin-left: -2cqw; +} +.cover--series .cap { + font-size: 13px; font-weight: 600; color: rgba(250, 236, 212, .62); margin-top: 12px; + white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } -.now__bar .iconbtn { color: var(--text); } -.now__eyebrow { font-size: 11px; font-weight: 700; letter-spacing: 0.18em; text-transform: uppercase; color: var(--text-dim); } -.iconbtn--on { color: var(--accent); } -/* lyrics view (replaces the artwork when toggled on) */ .lyrics { - flex: 1; width: 100%; overflow-y: auto; - padding: 8px 2px; -webkit-overflow-scrolling: touch; - -webkit-mask-image: linear-gradient(to bottom, transparent, #000 14%, #000 86%, transparent); - mask-image: linear-gradient(to bottom, transparent, #000 14%, #000 86%, transparent); + flex: 1 1 auto; min-height: 0; margin: 8px -24px 0; padding: 30% 24px 45%; + overflow-y: auto; overscroll-behavior: contain; -webkit-overflow-scrolling: touch; + -webkit-mask-image: linear-gradient(to bottom, transparent, #000 12%, #000 82%, transparent); + mask-image: linear-gradient(to bottom, transparent, #000 12%, #000 82%, transparent); } -.lyrics--msg { display: flex; flex-direction: column; align-items: center; justify-content: center; color: var(--text-dim); font-size: 14px; } -.lyrics--plain { white-space: pre-wrap; color: var(--text-dim); font-size: 16px; line-height: 1.8; } +.lyrics--msg { display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 14px; padding: 0 24px; color: var(--dim); font-size: 15px; text-align: center; } +.lyrics--plain { white-space: pre-wrap; color: rgba(242, 236, 226, .78); font-size: 19px; font-weight: 600; line-height: 1.6; padding-top: 12px; -webkit-user-select: text; user-select: text; } .lyrics__line { - margin: 0; padding: 7px 4px; - font-size: 21px; font-weight: 700; letter-spacing: -0.01em; line-height: 1.25; - color: var(--text-faint); transition: color 0.2s ease, opacity 0.2s ease; - cursor: pointer; -} -.lyrics__line.is-past { color: var(--text-faint); opacity: 0.7; } -.lyrics__line.is-active { color: var(--accent); } -.lyrics--msg { gap: 14px; } + padding: 7px 0; font-size: 26px; font-weight: 800; letter-spacing: -.015em; line-height: 1.2; + color: rgba(242, 236, 226, .3); cursor: pointer; + transition: color var(--dur-fade), opacity var(--dur-fade); +} +.lyrics__line.is-past { opacity: .7; } +.lyrics__line.is-active { color: var(--text); } .lyrics__research { - margin: 18px auto; display: block; - background: var(--surface-2); border: 1px solid var(--line); border-radius: 999px; - color: var(--text-dim); font-family: var(--font); font-size: 12px; font-weight: 600; - padding: 8px 16px; cursor: pointer; -} - -.now__art { flex: 1; display: grid; place-items: center; width: 100%; } -.now__art .artwork { - box-shadow: 0 30px 70px rgba(0, 0, 0, 0.55); - max-width: 78vw; max-height: 78vw; width: auto; height: auto; -} -.now__info { display: flex; align-items: center; gap: 14px; width: 100%; margin: 28px 0 6px; } -.now__text { flex: 1; min-width: 0; } -.now__text h2 { font-size: 24px; font-weight: 800; letter-spacing: -0.02em; margin: 0 0 3px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } -.now__text p { margin: 0; color: var(--text-dim); font-size: 15px; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } -.now__info .iconbtn { width: 44px; height: 44px; } - -.scrub { width: 100%; margin: 10px 0 2px; } -.scrub input[type='range'] { - width: 100%; -webkit-appearance: none; appearance: none; - height: 4px; border-radius: 2px; cursor: pointer; - /* the played-portion fill is set inline as a gradient by the Player */ - background: var(--surface-2); -} -/* keep the native track transparent so the element's gradient background shows */ -.scrub input[type='range']::-webkit-slider-runnable-track { background: transparent; height: 4px; } -.scrub input[type='range']::-webkit-slider-thumb { - -webkit-appearance: none; appearance: none; - width: 13px; height: 13px; border-radius: 50%; background: var(--text); - margin-top: -4.5px; /* center the thumb on the 4px track */ -} -.scrub__times { display: flex; justify-content: space-between; font-size: 12px; color: var(--text-faint); font-variant-numeric: tabular-nums; margin-top: 6px; } - -.transport { display: flex; align-items: center; justify-content: center; gap: 34px; margin: 22px 0 18px; } -.transport .iconbtn { width: 52px; height: 52px; color: var(--text); } -.playbtn { - width: 72px; height: 72px; border-radius: 50%; - border: none; background: var(--accent); color: #1c1408; - display: grid; place-items: center; cursor: pointer; - box-shadow: 0 8px 24px rgba(233, 162, 59, 0.35); -} -.playbtn:active { transform: scale(0.96); } -.playbtn:disabled, .mini__controls .iconbtn:disabled { opacity: 0.4; cursor: default; box-shadow: none; } -.now__missing { margin: 14px 0; padding: 0 24px; text-align: center; font-size: 13px; color: var(--accent); } - -.loopbtn { - display: inline-flex; align-items: center; gap: 8px; - background: var(--surface); border: 1px solid var(--line); border-radius: 999px; - padding: 9px 18px; color: var(--text-dim); - font-family: var(--font); font-size: 13px; font-weight: 600; - cursor: pointer; position: relative; -} -.loopbtn--all, .loopbtn--one { color: var(--accent); border-color: color-mix(in srgb, var(--accent) 40%, var(--line)); background: var(--accent-soft); } -.now__pills { display: flex; gap: 10px; justify-content: center; } -.speedbtn--on { border-color: color-mix(in srgb, var(--accent) 40%, var(--line)); background: var(--accent-soft); } -.speedbtn.speedbtn--on span { color: var(--accent); } -.loopbtn__one { - position: absolute; top: -3px; right: 8px; - font-style: normal; font-size: 9px; font-weight: 800; - background: var(--accent); color: #1c1408; - border-radius: 50%; width: 14px; height: 14px; display: grid; place-items: center; -} - -/* ── podcasts ─────────────────────────────────────────────────── */ -.speedbtn span { font-variant-numeric: tabular-nums; min-width: 3.2ch; text-align: center; color: var(--text); } -.skipbtn svg text { font-family: var(--font); } -.showrow { - display: flex; align-items: center; gap: 14px; width: 100%; text-align: left; - background: none; border: none; color: var(--text); font-family: var(--font); - padding: 8px 0; cursor: pointer; -} -.showhead { display: flex; gap: 16px; align-items: center; margin: -6px 0 18px; } -.showhead__meta { display: flex; flex-direction: column; gap: 10px; align-items: flex-start; min-width: 0; } -.showhead__meta p { margin: 0; } + margin: 22px auto 0; display: block; padding: 9px 16px; border: 0; border-radius: 18px; + background: rgba(242, 236, 226, .1); color: var(--dim); font: 600 13px var(--font); cursor: pointer; +} + +.titlerow { display: flex; align-items: center; gap: 2px; margin-top: 22px; flex: none; } +.titlerow__t { flex: 1; min-width: 0; margin-right: 6px; } +.np-title { + font-size: 22px; font-weight: 700; letter-spacing: -.015em; line-height: 1.2; + white-space: nowrap; overflow: hidden; +} +.np-title--clip { -webkit-mask-image: linear-gradient(90deg, #000 calc(100% - 24px), transparent); mask-image: linear-gradient(90deg, #000 calc(100% - 24px), transparent); } +.np-title span { display: inline-block; } +.np-title--marquee span { animation: marquee var(--mq-dur, 12s) linear 2s infinite; padding-right: 40px; } +@keyframes marquee { 0%, 12% { transform: translateX(0); } 88%, 100% { transform: translateX(var(--mq, 0px)); } } +.glass { width: 44px; height: 44px; display: grid; place-items: center; border: 0; background: none; padding: 0; cursor: pointer; color: var(--text); flex: none; } +.glass i { width: 32px; height: 32px; border-radius: 16px; display: grid; place-items: center; background: rgba(242, 236, 226, .13); transition: background var(--dur-press), transform var(--dur-press); } +.glass:active i { transform: scale(.9); background: rgba(242, 236, 226, .22); } +.glass--on { color: var(--accent); } +.np-missing { margin-top: 18px; text-align: center; font-size: 14px; color: var(--accent); line-height: 1.4; flex: none; } + +/* scrubber: no thumb, thickens while held */ +.scrub { position: relative; margin-top: 12px; padding: 12px 0 0; flex: none; touch-action: none; cursor: pointer; } +.scrub__track { + height: 5px; border-radius: 3px; background: rgba(242, 236, 226, .2); position: relative; overflow: hidden; + transition: height .15s var(--ease-out), margin .15s var(--ease-out); +} +.scrub__track i { position: absolute; inset: 0; background: rgba(242, 236, 226, .88); transform-origin: 0 50%; } +.scrub__times { display: flex; justify-content: space-between; margin-top: 8px; font-size: 12.5px; color: rgba(242, 236, 226, .6); transition: color .15s; } +.scrub__times .num { font-weight: 650; } +.scrub--active .scrub__track { height: 11px; margin: -3px -4px; border-radius: 6px; } +.scrub--active .scrub__track i { background: var(--text); } +.scrub--active .scrub__times { color: var(--text); } + +.transport { display: flex; align-items: center; justify-content: center; gap: 46px; margin-top: 16px; flex: none; color: var(--text); } +.transport--long { gap: 16px; } +.tp { + display: grid; place-items: center; width: 64px; height: 64px; padding: 0; + border: 0; background: none; color: inherit; cursor: pointer; border-radius: 32px; + transition: transform var(--dur-press), opacity var(--dur-press); +} +.tp:active { transform: scale(.86); opacity: .6; } +.tp:disabled { opacity: .35; } +.tp--sm { width: 48px; color: rgba(242, 236, 226, .78); } + +.foot { display: grid; grid-template-columns: repeat(2, 1fr); padding: 0 60px; margin-top: 18px; flex: none; justify-items: center; } +.ft { + width: 44px; height: 44px; border-radius: 10px; display: grid; place-items: center; + border: 0; background: none; padding: 0; cursor: pointer; + color: rgba(242, 236, 226, .5); + transition: background var(--dur-press), color var(--dur-press), transform var(--dur-press); +} +.ft:active { transform: scale(.9); } +.ft--on { color: var(--accent); background: rgba(235, 167, 62, .16); } + +/* Short screens (iPhone SE): tighten the vertical rhythm. */ +@media (max-height: 700px) { + .titlerow { margin-top: 14px; } + .scrub { margin-top: 6px; } + .transport { margin-top: 8px; } + .foot { margin-top: 8px; } +} + +/* ── legacy podcast screens (unreachable, kept building) ──────── */ +.phead { display: flex; align-items: center; gap: 10px; min-height: 52px; } +.phead h1 { flex: 1; min-width: 0; font-size: 28px; font-weight: 800; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.row > .row__main { flex: 1; min-width: 0; display: flex; align-items: center; gap: 12px; background: none; border: 0; color: inherit; text-align: left; padding: 0; } +.row__meta { min-width: 0; display: flex; flex-direction: column; gap: 2px; } +.row__artist { font-size: 13.5px; color: var(--dim); white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } +.showrow { display: flex; align-items: center; gap: 14px; width: 100%; background: none; border: 0; padding: 8px 0; text-align: left; } +.showhead { display: flex; gap: 16px; align-items: center; margin-bottom: 18px; } +.showhead__meta { display: flex; flex-direction: column; gap: 10px; align-items: flex-start; } .rsslink { width: 100%; margin: 10px 0 14px; } -.subbtn { padding: 8px 14px; font-size: 13px; flex-shrink: 0; } -.row__main--static { cursor: default; } -.continue { margin-bottom: 14px; } -.eprow { align-items: flex-start; padding: 8px 0; border-bottom: 1px solid var(--line); } -.eprow .row__main { align-items: flex-start; } -.eprow__title { white-space: normal; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; } -.eprow--played .eprow__title { color: var(--text-dim); } -.eprow__bar { display: block; height: 3px; border-radius: 2px; background: var(--surface-2); margin-top: 6px; width: 120px; overflow: hidden; } +.subbtn { height: 36px; padding: 0 14px; font-size: 14px; } +.eprow__title { white-space: normal; } +.eprow__bar { display: block; height: 3px; border-radius: 2px; background: var(--raised); margin-top: 6px; width: 120px; overflow: hidden; } .eprow__bar i { display: block; height: 100%; background: var(--accent); } -.eprow__dl { font-size: 12px; font-variant-numeric: tabular-nums; color: var(--accent); width: 44px; height: 44px; display: grid; place-items: center; flex-shrink: 0; } -.episheet__summary { font-size: 13px; line-height: 1.5; margin: 0 0 8px; max-height: 30vh; overflow-y: auto; } - -/* ── playlist resume ─────────────────────────────────────────── */ -.resume { - display: flex; align-items: center; gap: 12px; width: 100%; text-align: left; - background: var(--accent-soft); border: 1px solid color-mix(in srgb, var(--accent) 40%, var(--line)); - border-radius: 14px; padding: 12px 14px; margin: -6px 0 16px; - color: var(--accent); font-family: var(--font); cursor: pointer; -} -.resume__meta { flex: 1; min-width: 0; display: flex; flex-direction: column; } -.resume__label { font-size: 11px; font-weight: 700; letter-spacing: 0.12em; text-transform: uppercase; } -.resume__title { color: var(--text); font-size: 15px; font-weight: 600; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } -.resume__time { font-size: 13px; font-variant-numeric: tabular-nums; } - -/* ── polish ───────────────────────────────────────────────────── */ -/* One clear button: hide WebKit's own × inside type=search. */ -.searchbar__input::-webkit-search-cancel-button { -webkit-appearance: none; appearance: none; } -.artwork--img { display: block; overflow: hidden; flex-shrink: 0; background: var(--surface-2); } -.artwork--img img { width: 100%; height: 100%; object-fit: cover; display: block; } -.artwork__zoom { transform: scale(1.34); } -.segrow { justify-content: space-between; } -.sortbtn { - display: inline-flex; align-items: center; gap: 7px; - background: none; border: none; color: var(--text-dim); - font-family: var(--font); font-size: 14px; font-weight: 600; - padding: 8px 4px; cursor: pointer; -} +.eprow__dl { font-size: 12px; color: var(--accent); width: 44px; height: 44px; display: grid; place-items: center; } +.now__missing { color: var(--accent); font-size: 14px; margin: 12px 0; } +.searchhint, .episheet__summary { font-size: 14px; line-height: 1.45; } +.sheet__grip { width: 36px; height: 5px; border-radius: 3px; background: rgba(242, 236, 226, .3); margin: 8px auto; } +.sheet__title { font-weight: 650; padding: 4px 18px 10px; } +.sheet__item { display: flex; align-items: center; justify-content: space-between; width: 100%; min-height: 52px; padding: 0 18px; border: 0; background: none; color: var(--text); font: 400 17px var(--font); } +.sheet__item--danger { color: var(--danger); } +.sheet > .sheet__grip, .sheet > .sheet__title, .sheet > .sheet__item, .sheet > .sheet__label, .sheet > .episheet__summary { background: rgba(44, 38, 31, .97); } +.sheet__cancel { width: 100%; } diff --git a/src/lib/motion.js b/src/lib/motion.js new file mode 100644 index 0000000..dca6a00 --- /dev/null +++ b/src/lib/motion.js @@ -0,0 +1,9 @@ +// Duration for JS-driven transitions; near zero when the user asked for less motion. +export function motionMs(ms) { + try { + if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) return 1 + } catch { /* old engines */ } + return ms +} + +export const EASE_SHEET = 'cubic-bezier(.32, .72, 0, 1)' diff --git a/src/main.jsx b/src/main.jsx index 6bf59f8..2c8d52c 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -1,7 +1,7 @@ import React from 'react' import ReactDOM from 'react-dom/client' // Self-hosted variable font (bundled by Vite, precached for offline use). -import '@fontsource-variable/bricolage-grotesque' +import '@fontsource-variable/bricolage-grotesque/standard.css' import App from './App.jsx' import './index.css' import { seedIfEmpty, removeSamples } from './lib/seed' diff --git a/vite.config.js b/vite.config.js index 523150e..bcefb51 100644 --- a/vite.config.js +++ b/vite.config.js @@ -99,8 +99,8 @@ export default defineConfig(({ command }) => ({ name: 'Melody', short_name: 'Melody', description: 'Personal local audio player', - theme_color: '#110f0c', - background_color: '#110f0c', + theme_color: '#15120e', + background_color: '#15120e', display: 'standalone', orientation: 'portrait', // Relative scope/start so it works under the /melody/ subpath. From 2e767f70ef7d7be85751fe95cc7e3193f499a32c Mon Sep 17 00:00:00 2001 From: Enes Yilmaz Date: Tue, 29 Sep 2026 00:53:11 -0400 Subject: [PATCH 03/14] feat(artwork): numbered series tracks get a number tile, title without the number --- src/components/Artwork.jsx | 51 +++++++++++++++++++------------------- src/lib/series.js | 20 +++++++++++++++ src/lib/tileArt.js | 25 +++++++++++++++++++ 3 files changed, 70 insertions(+), 26 deletions(-) create mode 100644 src/lib/series.js create mode 100644 src/lib/tileArt.js diff --git a/src/components/Artwork.jsx b/src/components/Artwork.jsx index e610370..dd3c7b1 100644 --- a/src/components/Artwork.jsx +++ b/src/components/Artwork.jsx @@ -1,27 +1,23 @@ import { useState, useEffect } from 'react' import { ytThumbId, ytArtworkChain } from '../lib/youtube' +import { seriesTile } from '../lib/series' +import Icon from './Icon' -// Square artwork. Uses the YouTube thumbnail when present; otherwise renders a -// deterministic gradient tile (same track → same colors) so the library still -// looks intentional for the sample tones and any thumbnail-less tracks. +// Square artwork. YouTube thumbnail when present; a numbered series track gets +// its number tile; anything else a warm gradient tile (same track → same +// colors) so the library still looks intentional. function hueFromId(id = '') { let h = 0 for (let i = 0; i < id.length; i++) h = (h * 31 + id.charCodeAt(i)) % 360 return h } -// Warm glow color for the Now Playing backdrop, derived from the same id. -export function warmGlow(id) { - const h = 16 + (hueFromId(id) % 30) // 16°–46°: amber → orange → sienna - return `hsl(${h} 52% 22%)` -} - -export default function Artwork({ track, size = 48, radius = 8 }) { +export default function Artwork({ track, size = 44, radius = 6 }) { const style = { width: size, height: size, borderRadius: radius } const url = track?.thumbnailUrl const ytId = ytThumbId(url) const chain = ytId ? ytArtworkChain(ytId, size) : url ? [url] : [] - // Walk down the chain on failure; past the end means the gradient tile. A + // Walk down the chain on failure; past the end means the fallback tile. A // present-but-broken URL (expired/offline/deleted video) should fall back, // not show the browser's broken-image glyph. Reset when the track changes, // since this instance is reused in lists. @@ -31,7 +27,7 @@ export default function Artwork({ track, size = 48, radius = 8 }) { const src = chain[step] if (src) { return ( - + ) } - // Keep the hue in a warm band (deep amber → sienna → olive) and low-ish - // saturation so placeholders stay cohesive with the palette — never rainbow. - const h = 16 + (hueFromId(track?.id) % 30) // 16°–46°: amber → orange → sienna + + const tile = seriesTile(track) + if (tile) { + return ( + + ) + } + + // Keep the hue in a warm band (amber → sienna) at low saturation so + // placeholders stay cohesive with the palette, never rainbow. + const h = 16 + (hueFromId(track?.id) % 30) return ( - + + ) } diff --git a/src/lib/series.js b/src/lib/series.js new file mode 100644 index 0000000..fe58adb --- /dev/null +++ b/src/lib/series.js @@ -0,0 +1,20 @@ +// Numbered series tracks ("037 Wrap Up") without cover art get a generated +// number tile instead, and drop the number from the displayed title since the +// tile already shows it. Tracks with real artwork keep their title as-is. +const NUMBERED = /^(\d{1,4})[\s.)_-]+(.*\S.*)$/ + +export function seriesTile(track) { + if (!track || track.thumbnailUrl || track.kind === 'episode') return null + const m = NUMBERED.exec(track.title || '') + if (!m) return null + return { num: m[1].padStart(3, '0'), label: m[2].trim(), hue: tileHue(track.artist || track.id) } +} + +export const displayTitle = (track) => seriesTile(track)?.label ?? track?.title ?? '' + +// One warm hue per series (keyed on the show name), 22°–40°. +export function tileHue(key = '') { + let h = 0 + for (let i = 0; i < key.length; i++) h = (h * 31 + key.charCodeAt(i)) % 360 + return 22 + (h % 19) +} diff --git a/src/lib/tileArt.js b/src/lib/tileArt.js new file mode 100644 index 0000000..5b6e7a9 --- /dev/null +++ b/src/lib/tileArt.js @@ -0,0 +1,25 @@ +// Draws a numbered series tile to a PNG data URL for the lock screen, which +// can't render our CSS tile. Cached per number + hue. +const cache = new Map() + +export function tileArtwork({ num, hue }) { + const key = `${num}:${hue}` + if (cache.has(key)) return cache.get(key) + const c = document.createElement('canvas') + c.width = c.height = 512 + const g = c.getContext('2d') + const bg = g.createRadialGradient(80, 40, 0, 80, 40, 620) + bg.addColorStop(0, `hsl(${hue} 55% 40%)`) + bg.addColorStop(0.7, `hsl(${hue - 4} 48% 18%)`) + bg.addColorStop(1, `hsl(${hue - 8} 40% 11%)`) + g.fillStyle = bg + g.fillRect(0, 0, 512, 512) + g.fillStyle = 'rgba(250, 236, 212, .95)' + g.font = `800 ${num.length > 3 ? 170 : 230}px "Bricolage Grotesque Variable", system-ui, sans-serif` + if ('fontStretch' in g) g.fontStretch = 'condensed' + g.textBaseline = 'alphabetic' + g.fillText(num, 34, 472) + const url = c.toDataURL('image/png') + cache.set(key, url) + return url +} From 1d1016dd2fc6c7774f120af51c9af9f39a945b5e Mon Sep 17 00:00:00 2001 From: Enes Yilmaz Date: Tue, 29 Sep 2026 00:53:11 -0400 Subject: [PATCH 04/14] feat(sheets): shared action sheet that slides, drags down to dismiss and animates out --- src/components/AddToPlaylistSheet.jsx | 157 ++++++++++++------------ src/components/ConfirmModal.jsx | 7 +- src/components/PlaylistActionsSheet.jsx | 58 +++++---- src/components/PromptModal.jsx | 7 +- src/components/Sheet.jsx | 66 ++++++++++ src/lib/useDragDismiss.js | 95 ++++++++++++++ 6 files changed, 275 insertions(+), 115 deletions(-) create mode 100644 src/components/Sheet.jsx create mode 100644 src/lib/useDragDismiss.js diff --git a/src/components/AddToPlaylistSheet.jsx b/src/components/AddToPlaylistSheet.jsx index 71ce907..0a7016d 100644 --- a/src/components/AddToPlaylistSheet.jsx +++ b/src/components/AddToPlaylistSheet.jsx @@ -3,101 +3,104 @@ import { useUI } from '../state/UIProvider' import { usePlaylists } from '../state/useLibrary' import { usePlayer } from '../state/PlayerProvider' import { addToPlaylist, createPlaylist, toggleStar, deleteTrack, removeFromPlaylist } from '../lib/db' +import { displayTitle } from '../lib/series' +import Sheet from './Sheet' +import Artwork from './Artwork' +import Icon from './Icon' import PromptModal from './PromptModal' import ConfirmModal from './ConfirmModal' -import { useDialog } from '../lib/useDialog' -// Track actions sheet (opened from a TrackRow's ⋯). Quick playback actions on -// top (Play next / Add to queue / Favorite), then the "add to playlist" picker. +// Track sheet (hold a row, or ⋯ → Add to playlist in Now Playing): quick +// playback actions, the playlist picker, and delete. Rendered once at the root. export default function AddToPlaylistSheet() { const { addTarget, fromPlaylist, closeAddToPlaylist } = useUI() const playlists = usePlaylists() - const { playNext, addToQueue } = usePlayer() + const { playNext, addToQueue, current } = usePlayer() const [creating, setCreating] = useState(false) const [confirmDelete, setConfirmDelete] = useState(false) - // This host is always mounted (returns null when closed), so key the focus on - // whether a track is open, not on mount. - const dialog = useDialog(closeAddToPlaylist, { active: !!addTarget }) - if (!addTarget) return null + // The sheet unmounts on close, but the modals it opens outlive it. + const [target, setTarget] = useState(null) + if (!addTarget && !target) return null + const track = addTarget || target - const close = closeAddToPlaylist const addToList = async (id) => { - await addToPlaylist(id, addTarget.id) - close() + await addToPlaylist(id, track.id) } return ( <> -
-
e.stopPropagation()} - > -
-

{addTarget.title}

+ {addTarget && ( + + {(close) => ( + <> +
+
+ + {displayTitle(track)} +
+ {current && current.id !== track.id && ( + <> + + + + )} + + {fromPlaylist && ( + + )} +
- - - +
+

Add to playlist

+ + {(playlists || []).map((p) => { + const has = p.trackIds.includes(track.id) + return ( + + ) + })} +
- {fromPlaylist && ( - - )} - -

Add to playlist

- - {(playlists || []).map((p) => { - const has = p.trackIds.includes(addTarget.id) - return ( - - ) - })} + {track.srcType === 'idb' && ( +
+ +
+ )} - {addTarget.srcType === 'idb' && ( - +
+ +
+ )} - - -
-
+ + )} {creating && ( setCreating(false)} + onClose={() => { setCreating(false); setTarget(null) }} onSubmit={async (name) => { const id = await createPlaylist(name) - setCreating(false) await addToList(id) + setCreating(false) + setTarget(null) }} /> )} @@ -105,24 +108,16 @@ export default function AddToPlaylistSheet() { {confirmDelete && ( { - await deleteTrack(addTarget.id) + await deleteTrack(track.id) setConfirmDelete(false) - close() + setTarget(null) }} - onClose={() => setConfirmDelete(false)} + onClose={() => { setConfirmDelete(false); setTarget(null) }} /> )} ) } - -function Glyph({ d, filled }) { - return ( - - - - ) -} diff --git a/src/components/ConfirmModal.jsx b/src/components/ConfirmModal.jsx index cb0e67d..9825177 100644 --- a/src/components/ConfirmModal.jsx +++ b/src/components/ConfirmModal.jsx @@ -1,3 +1,4 @@ +import { createPortal } from 'react-dom' import { useDialog } from '../lib/useDialog' // Destructive confirm dialog (e.g. delete a playlist) — replaces window.confirm. @@ -10,7 +11,8 @@ export default function ConfirmModal({ }) { const dialog = useDialog(onClose) - return ( + // Portaled so it never inherits the page's Now Playing recede transform. + return createPortal(
{confirmLabel}
-
+ , + document.body, ) } diff --git a/src/components/PlaylistActionsSheet.jsx b/src/components/PlaylistActionsSheet.jsx index 08d8e36..aa36c38 100644 --- a/src/components/PlaylistActionsSheet.jsx +++ b/src/components/PlaylistActionsSheet.jsx @@ -1,34 +1,32 @@ -import { useDialog } from '../lib/useDialog' +import Sheet from './Sheet' +import Icon from './Icon' -// Bottom sheet for a single playlist — opened by long-pressing a playlist card -// or tapping ⋯ inside a playlist. Offers Rename and Delete. -export default function PlaylistActionsSheet({ playlist, onRename, onDelete, onClose }) { - const dialog = useDialog(onClose) +// Sheet for a single playlist, opened by holding a playlist row or tapping ⋯ +// inside a playlist. Offers Rename and Delete. +export default function PlaylistActionsSheet({ playlist, count, onRename, onDelete, onClose }) { return ( -
-
e.stopPropagation()} - > -
-

{playlist.name}

- - - - - -
-
+ + {(close) => ( + <> +
+
+ + {playlist.name} + {count} {count === 1 ? 'song' : 'songs'} + +
+ + +
+
+ +
+ + )} +
) } diff --git a/src/components/PromptModal.jsx b/src/components/PromptModal.jsx index 56b8df7..de871f0 100644 --- a/src/components/PromptModal.jsx +++ b/src/components/PromptModal.jsx @@ -1,4 +1,5 @@ import { useState, useEffect, useRef } from 'react' +import { createPortal } from 'react-dom' import { useDialog } from '../lib/useDialog' // A small centered text-entry modal — replaces window.prompt() so naming a @@ -31,7 +32,8 @@ export default function PromptModal({ if (v) onSubmit(v) } - return ( + // Portaled so it never inherits the page's Now Playing recede transform. + return createPortal(
{confirmLabel}
-
+ , + document.body, ) } diff --git a/src/components/Sheet.jsx b/src/components/Sheet.jsx new file mode 100644 index 0000000..707c455 --- /dev/null +++ b/src/components/Sheet.jsx @@ -0,0 +1,66 @@ +import { useCallback, useLayoutEffect, useRef } from 'react' +import { createPortal } from 'react-dom' +import { useDialog } from '../lib/useDialog' +import { useDragDismiss } from '../lib/useDragDismiss' +import { motionMs, EASE_SHEET } from '../lib/motion' + +// Bottom action sheet: slides up, drags down to dismiss, and animates out on +// every close path. Children may be a function receiving close(), so an +// action can dismiss with the same animation. +export default function Sheet({ onClose, label, children }) { + const panel = useRef(null) + const overlay = useRef(null) + const closed = useRef(false) + const onCloseRef = useRef(onClose) + onCloseRef.current = onClose + const dur = motionMs(380) + + const slide = (to, ms = dur) => { + const el = panel.current + if (!el) return + el.style.transition = ms ? `transform ${ms}ms ${EASE_SHEET}` : 'none' + el.style.transform = to + } + + useLayoutEffect(() => { + slide('translateY(calc(100% + 16px))', 0) + panel.current.getBoundingClientRect() // commit the start position + slide('') + }, []) + + const close = useCallback((then) => { + if (closed.current) return + closed.current = true + slide('translateY(calc(100% + 16px))', motionMs(280)) + overlay.current?.classList.add('is-closing') + setTimeout(() => { + onCloseRef.current?.() + if (typeof then === 'function') then() + }, motionMs(260)) + }, []) + + useDragDismiss(panel, { + onDrag: (d) => slide(`translateY(${d}px)`, 0), + onEnd: (d, v) => (d > 110 || v > 0.5 ? close() : slide('')), + }) + + const dialog = useDialog(() => close(), { autoFocus: true }) + + return createPortal( + <> +
close()} /> +
{ panel.current = el; dialog.ref.current = el }} + onKeyDown={dialog.onKeyDown} + role="dialog" + aria-modal="true" + aria-label={label} + tabIndex={-1} + > + {typeof children === 'function' ? children(close) : children} +
+ , + document.body, + ) +} diff --git a/src/lib/useDragDismiss.js b/src/lib/useDragDismiss.js new file mode 100644 index 0000000..03b5128 --- /dev/null +++ b/src/lib/useDragDismiss.js @@ -0,0 +1,95 @@ +import { useEffect, useRef } from 'react' + +// Drag-down-to-dismiss for sheets and Now Playing. Reports the finger's +// downward travel through onDrag(dy) and, on release, onEnd(dy, velocity in +// px/ms). A drag only starts downward, never on `exclude` targets, and inside a +// [data-scroll] area only when that area is scrolled to its top, so scrollers +// and scrubbers keep their own gestures. Touch drives it on the phone; the +// mouse path exists for desktop and headless tests. +export function useDragDismiss(ref, { onDrag, onEnd, exclude = 'input, textarea, [data-nodrag]' }) { + const cb = useRef() + cb.current = { onDrag, onEnd } + + useEffect(() => { + const el = ref.current + if (!el) return + let s = null + let justDragged = false + + const begin = (x, y, target) => { + if (target.closest?.(exclude)) return + const sc = target.closest?.('[data-scroll]') + s = { x, y, sc: sc && el.contains(sc) ? sc : null, dragging: false, samples: [] } + } + // Returns true when the event should be swallowed. + const move = (x, y) => { + if (!s) return false + const dx = x - s.x + const dy = y - s.y + if (!s.dragging) { + if (Math.abs(dx) < 3 && Math.abs(dy) < 3) return false + if (dy <= 0 || Math.abs(dx) > Math.abs(dy) || (s.sc && s.sc.scrollTop > 0)) { + s = null + return false + } + s.dragging = true + s.y = y + } + const d = Math.max(0, y - s.y) + s.samples.push({ d, t: performance.now() }) + if (s.samples.length > 5) s.samples.shift() + cb.current.onDrag?.(d) + return true + } + const end = () => { + if (s?.dragging) { + const a = s.samples[0] + const b = s.samples[s.samples.length - 1] + const v = a && b && b.t > a.t ? (b.d - a.d) / (b.t - a.t) : 0 + justDragged = true + setTimeout(() => { justDragged = false }, 50) + cb.current.onEnd?.(b ? b.d : 0, v) + } + s = null + } + + const onTouchStart = (e) => { + if (e.touches.length !== 1) { s = null; return } + begin(e.touches[0].clientX, e.touches[0].clientY, e.target) + } + const onTouchMove = (e) => { + if (move(e.touches[0].clientX, e.touches[0].clientY) && e.cancelable) e.preventDefault() + } + const onPointerDown = (e) => { + if (e.pointerType !== 'mouse' || e.button !== 0) return + begin(e.clientX, e.clientY, e.target) + const mm = (ev) => move(ev.clientX, ev.clientY) + const up = () => { + window.removeEventListener('pointermove', mm) + window.removeEventListener('pointerup', up) + end() + } + window.addEventListener('pointermove', mm) + window.addEventListener('pointerup', up) + } + // A drag that ends over a button must not also press it. + const onClick = (e) => { + if (justDragged) { e.stopPropagation(); e.preventDefault() } + } + + el.addEventListener('touchstart', onTouchStart, { passive: true }) + el.addEventListener('touchmove', onTouchMove, { passive: false }) + el.addEventListener('touchend', end) + el.addEventListener('touchcancel', end) + el.addEventListener('pointerdown', onPointerDown) + el.addEventListener('click', onClick, true) + return () => { + el.removeEventListener('touchstart', onTouchStart) + el.removeEventListener('touchmove', onTouchMove) + el.removeEventListener('touchend', end) + el.removeEventListener('touchcancel', end) + el.removeEventListener('pointerdown', onPointerDown) + el.removeEventListener('click', onClick, true) + } + }, [ref, exclude]) +} From c899cc8c205ceea5c12665447fd69737552988a9 Mon Sep 17 00:00:00 2001 From: Enes Yilmaz Date: Tue, 29 Sep 2026 00:53:11 -0400 Subject: [PATCH 05/14] feat(library): title-only rows and one options menu for import, backup and sort --- src/components/LibraryView.jsx | 195 +++++++++--------- src/components/TrackRow.jsx | 83 ++++---- src/lib/db.js | 4 +- src/lib/useBackup.jsx | 57 +++++ .../ImportButton.jsx => lib/useImporter.jsx} | 88 ++++---- 5 files changed, 231 insertions(+), 196 deletions(-) create mode 100644 src/lib/useBackup.jsx rename src/{components/ImportButton.jsx => lib/useImporter.jsx} (64%) diff --git a/src/components/LibraryView.jsx b/src/components/LibraryView.jsx index 2e40fba..8f955f6 100644 --- a/src/components/LibraryView.jsx +++ b/src/components/LibraryView.jsx @@ -1,130 +1,137 @@ import { useState } from 'react' import { useTracks } from '../state/useLibrary' -import { usePlayer } from '../state/PlayerProvider' -import { shuffle } from '../lib/shuffle' -import { summarize } from '../lib/format' +import { useImporter } from '../lib/useImporter' +import { useBackup } from '../lib/useBackup' import TrackRow from './TrackRow' -import ImportButton from './ImportButton' +import Sheet from './Sheet' +import Icon from './Icon' const SORT_KEY = 'melody:librarySort' +const FAV_KEY = 'melody:libraryFavorites' const SORTS = [ - { value: 'recent', label: 'Recent' }, + { value: 'recent', label: 'Recently added' }, { value: 'plays', label: 'Most played' }, - { value: 'alpha', label: 'A–Z' }, + { value: 'alpha', label: 'Title' }, ] // Reads must be try/catch-wrapped (private-mode Safari throws) and live in a // pure lazy initializer so StrictMode's double render stays side-effect free. -function readSavedSort() { +function readSaved(key, fallback) { try { - const v = localStorage.getItem(SORT_KEY) - return v === 'plays' || v === 'alpha' ? v : 'recent' + return localStorage.getItem(key) ?? fallback } catch { - return 'recent' + return fallback + } +} +function save(key, value) { + try { + localStorage.setItem(key, value) + } catch { + // Private-mode Safari: the choice still applies for this session. } } export default function LibraryView() { const tracks = useTracks() - const { playQueue } = usePlayer() - const [showStarred, setShowStarred] = useState(false) - const [sort, setSort] = useState(readSavedSort) - - const pickSort = (value) => { - setSort(value) - try { - localStorage.setItem(SORT_KEY, value) - } catch { - // Private-mode Safari: the choice still applies for this session. - } - } + const importer = useImporter() + const backup = useBackup() + const [menu, setMenu] = useState(false) + const [sort, setSort] = useState(() => { + const v = readSaved(SORT_KEY, 'recent') + return SORTS.some((o) => o.value === v) ? v : 'recent' + }) + const [favOnly, setFavOnly] = useState(() => readSaved(FAV_KEY, '0') === '1') - if (tracks === undefined) return

Loading…

+ const pickSort = (value) => { setSort(value); save(SORT_KEY, value) } + const toggleFav = () => { setFavOnly((f) => { save(FAV_KEY, f ? '0' : '1'); return !f }) } - const filtered = showStarred ? tracks.filter((t) => t.starred) : tracks + const filtered = tracks && (favOnly ? tracks.filter((t) => t.starred) : tracks) // useTracks already returns dateAdded-desc, so 'recent' is the array as-is. - // The others must copy first — live-query arrays must never be sorted in place. - const shown = - sort === 'plays' + // The others must copy first: live-query arrays must never be sorted in place. + const shown = !filtered + ? [] + : sort === 'plays' ? [...filtered].sort((a, b) => (b.playCount || 0) - (a.playCount || 0) || b.dateAdded - a.dateAdded) : sort === 'alpha' - ? [...filtered].sort((a, b) => (a.title || '').localeCompare(b.title || '', undefined, { sensitivity: 'base' })) + ? [...filtered].sort((a, b) => (a.title || '').localeCompare(b.title || '', undefined, { sensitivity: 'base', numeric: true })) : filtered + const source = { name: favOnly ? 'Favorites' : 'Library' } return (
-
-
-
-

Your music

-

Library

- {tracks.length > 0 &&

{summarize(tracks)}

} -
- {/* An empty library shows its own, bigger Import in the empty state. */} - {tracks.length > 0 && } -
- {tracks.length > 0 && ( -
-
- - -
- {/* One tap cycles the sort; a second segmented row cost a fifth of the screen. */} - -
- )} +
+
+ {importer.progress && ( +

+ Importing {importer.progress.done + 1} of {importer.progress.total} + +

+ )} - {shown.length > 0 ? ( - <> -
- - -
-
- {shown.map((t) => ( - - ))} -
- - ) : showStarred ? ( -

No favorites yet — tap the star on any track.

+ {tracks === undefined ? null : shown.length > 0 ? ( +
+ {shown.map((t) => ( + + ))} +
+ ) : favOnly && tracks.length > 0 ? ( +
+ +

No favorites yet

+

Tap the star in Now Playing to keep a song here.

+ +
) : (
- -

Nothing here yet. Import songs from the Files app to build your library.

- + +

Your library is empty

+

Download songs in a-Shell, then import them from the Files app.

+
)} -
- ) -} -function PlayGlyph() { - return -} -function ShuffleGlyph() { - return -} -function NotesGlyph() { - return ( - + {importer.input} + {backup.input} + + {menu && ( + // Pickers and the share sheet need the tap's user activation, so each + // action runs first and the sheet animates away after. + setMenu(false)} label="Library options"> + {(close) => ( + <> +
+ + + +
+
+ {SORTS.map((o) => ( + + ))} + +
+
+ +
+ + )} +
+ )} + ) } -function SortGlyph() { - return -} diff --git a/src/components/TrackRow.jsx b/src/components/TrackRow.jsx index b2fc90e..26a12b2 100644 --- a/src/components/TrackRow.jsx +++ b/src/components/TrackRow.jsx @@ -1,65 +1,52 @@ import Artwork from './Artwork' -import { formatTime, timeLeft } from '../lib/format' -import { isLongForm } from '../lib/podcasts' -import { toggleStar } from '../lib/db' +import Icon from './Icon' +import { formatTime } from '../lib/format' +import { displayTitle } from '../lib/series' +import { useLongPress } from '../lib/useLongPress' import { usePlayer } from '../state/PlayerProvider' import { useUI } from '../state/UIProvider' -// A single tappable track. Tap the row → play (within `list` as the queue). -// Star button toggles favorite; the ⋯ button opens the add-to-playlist sheet. -export default function TrackRow({ track, list, playOpts, playlist }) { +// A single track: tap plays it (within `list` as the queue), press and hold +// opens the track sheet (playlists, favorite, delete). Title only; the +// trailing edge carries a favorite star and the duration, or the eq bars on +// the playing row. +export default function TrackRow({ track, list, playOpts, playlist, sub }) { const { current, isPlaying, playTrack } = usePlayer() const { openAddToPlaylist } = useUI() + const lp = useLongPress(() => openAddToPlaylist(track, playlist)) const isCurrent = current?.id === track.id return ( -
- - - - {isLongForm(track) && track.position > 0 ? timeLeft(track.duration, track.position) : formatTime(track.duration)} + - - -
+ + {isCurrent ? ( + + ) : ( + <> + {!!track.starred && } + {formatTime(track.duration)} + + )} + + ) } -function EqBars() { +export function EqBars({ paused }) { return ( -
- {expanded && setExpanded(false)} />} + {open && setOpen(false)} />} ) } -function NowPlaying({ p, onClose }) { - const loopLabel = { off: 'Repeat off', all: 'Repeat all', one: 'Repeat one' }[p.loopMode] - const [showLyrics, setShowLyrics] = useState(false) +// Swipe up on the mini player opens Now Playing; a tap still works via click. +function useSwipeUp(ref, onOpen) { + const cb = useRef(onOpen) + cb.current = onOpen + useEffect(() => { + const el = ref.current + if (!el) return + let start = null + const down = (e) => { start = { x: e.clientX, y: e.clientY } } + const move = (e) => { + if (!start) return + const dy = e.clientY - start.y + if (dy < -28 && Math.abs(dy) > Math.abs(e.clientX - start.x)) { + start = null + cb.current() + } + } + const up = () => { start = null } + el.addEventListener('pointerdown', down) + el.addEventListener('pointermove', move) + el.addEventListener('pointerup', up) + el.addEventListener('pointercancel', up) + return () => { + el.removeEventListener('pointerdown', down) + el.removeEventListener('pointermove', move) + el.removeEventListener('pointerup', up) + el.removeEventListener('pointercancel', up) + } + }) +} + +// Page-behind recede: 1 = Now Playing fully up. Drives .app via --np. +function setBehind(v, animate) { + const root = document.documentElement + root.classList.toggle('np-anim', animate) + root.style.setProperty('--np', String(v)) +} + +function NowPlaying({ p, onClosed }) { + const sheet = useRef(null) + const closed = useRef(false) + const [lyricsOn, setLyricsOn] = useState(false) + const [menu, setMenu] = useState(false) + const { goToPlaylist, openAddToPlaylist } = useUI() const lyrics = useLyrics(p.current.id) - const live = useTrack(p.current.id) // live star state (snapshot can be stale) + const live = useTrack(p.current.id) // live star state (the queue snapshot can be stale) const starred = live ? live.starred : p.current.starred + const tile = seriesTile(p.current) + + const slide = (to, ms) => { + const el = sheet.current + if (!el) return + el.style.transition = ms ? `transform ${ms}ms ${EASE_SHEET}` : 'none' + el.style.transform = to + } + + useLayoutEffect(() => { + slide('translateY(100%)', 0) + sheet.current.getBoundingClientRect() + slide('', motionMs(420)) + setBehind(1, true) + return () => setBehind(0, false) + }, []) + + // Every close path animates down, then unmounts. + const close = useCallback((then) => { + if (closed.current) return + closed.current = true + const ms = motionMs(340) + slide('translateY(100%)', ms) + setBehind(0, true) + setTimeout(() => { + document.documentElement.classList.remove('np-anim') + onClosed() + if (typeof then === 'function') then() + }, ms) + }, [onClosed]) + + useDragDismiss(sheet, { + exclude: 'button, input, .scrub, [data-nodrag]', + onDrag: (d) => { + slide(`translateY(${d}px)`, 0) + setBehind(Math.max(0, 1 - d / (sheet.current.offsetHeight || 800)), false) + }, + onEnd: (d, v) => { + if (d > 140 || v > 0.55) close() + else { + slide('', motionMs(380)) + setBehind(1, true) + } + }, + }) + + // Escape closes (hardware keyboards), unless a sheet on top takes it. + useEffect(() => { + const onKey = (e) => { + if (e.key === 'Escape' && !menu && !document.querySelector('.sheet')) close() + } + window.addEventListener('keydown', onKey) + return () => window.removeEventListener('keydown', onKey) + }, [menu, close]) // Fetch + cache lyrics when the track changes (no-op if already cached). useEffect(() => { ensureLyrics(p.current).catch(() => {}) }, [p.current.id]) - const pct = p.duration ? (p.progress / p.duration) * 100 : 0 + const sourceName = p.source?.name || 'Library' + const loopIcon = p.loopMode === 'one' ? 'repeat1' : 'repeat' + const loopLabel = { off: 'Repeat off', all: 'Repeat all', one: 'Repeat one' }[p.loopMode] - return ( -
-
- - Now Playing - -
+ return createPortal( +
+ +