-
{p.current.title}
-
{p.current.artist}
+
+
+
toggleStar(p.current.id).catch(() => {})}
- aria-label="Favorite"
+ aria-label={starred ? 'Unfavorite' : 'Favorite'}
+ aria-pressed={!!starred}
>
-
+
+
+
setMenu(true)} aria-label="More">
+
{p.missing ? (
-
{missingLong(p.current)}
+
{missingLong(p.current)}
) : (
-
-
p.seek(Number(e.target.value))}
- style={{ background: `linear-gradient(to right, var(--accent) ${pct}%, var(--surface-2) ${pct}%)` }}
- />
-
- {formatTime(p.progress)}
- {formatTime(p.duration)}
-
-
+
)}
-
- {p.longForm ? (
-
p.skip(-15)} aria-label="Back 15 seconds">
- ) : (
-
+
+ {p.longForm && (
+
p.skip(-15)} aria-label="Back 15 seconds">
)}
-
- {p.isPlaying ? : }
+
+
+
- {p.longForm ? (
- p.skip(30)} aria-label="Forward 30 seconds">
- ) : (
-
+
+ {p.longForm && (
+ p.skip(30)} aria-label="Forward 30 seconds">
)}
- {p.longForm ? (
-
- {p.speed}×
+
+ setLyricsOn((s) => !s)} aria-label="Lyrics" aria-pressed={lyricsOn}>
+
- ) : (
-
- {loopLabel}
- {p.loopMode === 'one' && 1 }
+
+
+
+
+ {menu && (
+ setMenu(false)} label="Track options">
+ {(closeMenu) => (
+ <>
+
+
closeMenu(() => openAddToPlaylist(live || p.current))}>
+ Add to playlist
+
+ {p.source?.playlistId != null && (
+
closeMenu(() => close(() => goToPlaylist(p.source.playlistId)))}>
+ Go to {p.source.name}
+
+ )}
+ {/* Speed: no label, the values say what it is. Persists and applies to every track. */}
+
+
+ {SPEEDS.map((v) => (
+ p.pickSpeed(v)}
+ >
+ {v}
+
+ ))}
+
+
+
+
+ closeMenu()}>Cancel
+
+ >
+ )}
+
)}
+ ,
+ document.body,
+ )
+}
+
+// YouTube art is 16:9: fit it whole over a blurred, zoomed copy of itself.
+function ytChain(url) {
+ const id = ytThumbId(url)
+ if (!id) return url ? [url] : []
+ const base = `https://i.ytimg.com/vi/${id}`
+ return [`${base}/maxresdefault.jpg`, `${base}/mqdefault.jpg`, `${base}/hqdefault.jpg`]
+}
+
+function useChain(url) {
+ const chain = ytChain(url)
+ const [step, setStep] = useState(0)
+ useEffect(() => setStep(0), [url])
+ return {
+ src: chain[step],
+ onError: () => setStep((n) => n + 1),
+ // A missing maxres answers with a 120px grey placeholder, not an error.
+ onLoad: (e) => { if (chain[step]?.includes('maxresdefault') && e.currentTarget.naturalWidth <= 120) setStep((n) => n + 1) },
+ }
+}
+
+function Cover({ track, tile, paused }) {
+ const img = useChain(track.thumbnailUrl)
+ const cls = `cover ${paused ? 'cover--paused' : ''}`
+ if (img.src) {
+ return (
+
+
+
+
+ )
+ }
+ if (tile) {
+ return (
+
+
{tile.num}
+
{tile.label}
+
+ )
+ }
+ return
+}
+
+function Backdrop({ track, tile }) {
+ const img = useChain(track.thumbnailUrl)
+ let h = tile?.hue
+ if (h == null) {
+ let x = 0
+ for (const c of track.id || '') x = (x * 31 + c.charCodeAt(0)) % 360
+ h = 16 + (x % 30)
+ }
+ return (
+
+ {img.src ?
:
}
+
+ )
+}
+
+// One line; a title too long for it scrolls once in a while instead of
+// losing its end to an ellipsis.
+function Title({ text }) {
+ const box = useRef(null)
+ const inner = useRef(null)
+ const [over, setOver] = useState(0)
+ useLayoutEffect(() => {
+ const measure = () => setOver(Math.max(0, inner.current.scrollWidth - box.current.clientWidth))
+ measure()
+ document.fonts?.ready.then(measure)
+ }, [text])
+ const reduce = motionMs(1000) < 1000
+ const cls = over > 0 ? (reduce ? 'np-title np-title--clip' : 'np-title np-title--marquee np-title--clip') : 'np-title'
+ return (
+
+ {text}
+
+ )
+}
+
+// Custom scrubber: no thumb; it thickens while held and seeks on release.
+function Scrubber({ progress, duration, onSeek }) {
+ const track = useRef(null)
+ const [drag, setDrag] = useState(null) // seconds while dragging
+ const ratioAt = (x) => {
+ const r = track.current.getBoundingClientRect()
+ return Math.min(1, Math.max(0, (x - r.left) / r.width))
+ }
+ const t = drag ?? progress
+ const pct = duration ? Math.min(1, t / duration) : 0
+
+ const down = (e) => {
+ if (!duration) return
+ e.currentTarget.setPointerCapture(e.pointerId)
+ setDrag(ratioAt(e.clientX) * duration)
+ }
+ const move = (e) => { if (drag != null) setDrag(ratioAt(e.clientX) * duration) }
+ const up = () => {
+ if (drag == null) return
+ onSeek(drag)
+ setDrag(null)
+ }
+ const key = (e) => {
+ const step = { ArrowLeft: -5, ArrowRight: 5 }[e.key]
+ if (step && duration) {
+ e.preventDefault()
+ onSeek(Math.min(duration, Math.max(0, progress + step)))
+ }
+ }
+
+ return (
+
setDrag(null)}
+ onKeyDown={key}
+ >
+
+
+ {formatTime(t)}
+ -{formatTime(Math.max(0, (duration || 0) - t))}
+
)
}
function LyricsView({ lyrics, progress, onSeek, onResearch }) {
+ const box = useRef(null)
const activeRef = useRef(null)
+ const touchedAt = useRef(0)
const [researching, setResearching] = useState(false)
const synced = lyrics?.synced
const idx = activeLine(synced, progress)
- // Keep the active line centered as the song plays.
+ // Keep the active line centered, unless the reader scrolled in the last few
+ // seconds. Scrolls only this box (scrollIntoView would also move the sheet).
useEffect(() => {
- activeRef.current?.scrollIntoView({ block: 'center', behavior: 'smooth' })
+ const el = activeRef.current
+ const sc = box.current
+ if (!el || !sc || Date.now() - touchedAt.current < 3000) return
+ sc.scrollTo({ top: el.offsetTop - sc.clientHeight / 2 + el.offsetHeight / 2, behavior: motionMs(1) > 1 ? 'smooth' : 'auto' })
}, [idx])
+ const touched = () => { touchedAt.current = Date.now() }
const research = async () => {
setResearching(true)
@@ -168,14 +439,14 @@ function LyricsView({ lyrics, progress, onSeek, onResearch }) {
// "Wrong song?" footer so a bad match is one tap to fix.
const footer = lyrics !== undefined && (
- {researching ? 'Searching…' : 'Wrong lyrics? Re-search'}
+ {researching ? 'Searching…' : 'Wrong lyrics? Search again'}
)
if (lyrics === undefined) return
Loading lyrics…
if (synced && synced.length) {
return (
-
+
{synced.map((line, i) => (
{lyrics.plain}{footer}
+ return
{lyrics.plain}{footer}
}
return (
@@ -203,32 +474,10 @@ function LyricsView({ lyrics, progress, onSeek, onResearch }) {
// An episode that fails to load is usually just offline, not missing bytes.
function missingShort(item) {
- return item.kind === 'episode' ? "Can't stream right now" : 'Audio unavailable — re-import'
+ return item.kind === 'episode' ? "Can't stream right now" : 'Audio missing. Import it again'
}
function missingLong(item) {
return item.kind === 'episode'
? "Can't stream this episode right now. Download it while you have a connection to play it offline."
- : 'Audio unavailable — the file for this track is missing. Re-import it to play.'
-}
-
-/* icons */
-const s = { fill: 'currentColor' }
-function LyricsIcon() { return
}
-function PlayIcon({ big }) { const n = big ? 30 : 20; return
}
-function PauseIcon({ big }) { const n = big ? 30 : 20; return
}
-function NextIcon() { return
}
-function PrevIcon() { return
}
-function RepeatIcon() { return
}
-function ChevronDown() { return
}
-function StarIcon({ filled }) { return
}
-function SkipIcon({ back, n }) {
- return (
-
-
-
-
-
- {n}
-
- )
+ : 'The audio file for this track is missing. Import it again from Files to play it.'
}
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}
-
-
- Rename
-
-
-
- Delete playlist
-
-
-
-
Cancel
-
-
+
+ {(close) => (
+ <>
+
+
+
+ {playlist.name}
+ {count} {count === 1 ? 'song' : 'songs'}
+
+
+
{ onRename(); close() }}>
+ Rename
+
+
{ onDelete(); close() }}>
+ Delete playlist
+
+
+
+ close()}>Cancel
+
+ >
+ )}
+
)
}
diff --git a/src/components/PlaylistsView.jsx b/src/components/PlaylistsView.jsx
index affb850..ebc6a55 100644
--- a/src/components/PlaylistsView.jsx
+++ b/src/components/PlaylistsView.jsx
@@ -1,121 +1,79 @@
-import { useRef, useState } from 'react'
+import { useEffect, useRef, useState } from 'react'
import { usePlaylists, useTracks } from '../state/useLibrary'
-import { createPlaylist, renamePlaylist, deletePlaylist, exportBackup, importBackup } from '../lib/db'
+import { createPlaylist, renamePlaylist, deletePlaylist } from '../lib/db'
import { usePlayer } from '../state/PlayerProvider'
import { useUI } from '../state/UIProvider'
import { useLongPress } from '../lib/useLongPress'
-import { summarize, formatTime } from '../lib/format'
+import { formatTotalDuration, formatTime } from '../lib/format'
+import { displayTitle } from '../lib/series'
import { shuffle } from '../lib/shuffle'
import Artwork from './Artwork'
import TrackRow from './TrackRow'
+import Icon from './Icon'
import PromptModal from './PromptModal'
import ConfirmModal from './ConfirmModal'
import PlaylistActionsSheet from './PlaylistActionsSheet'
-export default function PlaylistsView() {
+export default function PlaylistsView({ scrollRef }) {
const playlists = usePlaylists()
- const allTracks = useTracks() // resolves each card's mosaic + duration meta
- const { showToast } = useUI()
- const [openId, setOpenId] = useState(null)
+ const allTracks = useTracks() // resolves each row's cover + duration meta
+ const { playlistId: openId, setPlaylistId: setOpenId } = useUI()
const [creating, setCreating] = useState(false)
const [actionsFor, setActionsFor] = useState(null) // playlist in the ⋯ sheet
const [renaming, setRenaming] = useState(null)
const [deleting, setDeleting] = useState(null)
- const restoreRef = useRef(null)
const open = playlists?.find((p) => p.id === openId)
-
- const onExport = async () => {
- try {
- const data = await exportBackup()
- const name = `melody-backup-${new Date().toISOString().slice(0, 10)}.json`
- const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' })
- // On iPhone the share sheet is the reliable way out ("Save to Files");
- // a home-screen app can't follow a download link.
- const file = new File([blob], name, { type: 'application/json' })
- if (navigator.canShare?.({ files: [file] })) {
- try {
- await navigator.share({ files: [file] })
- } catch (err) {
- if (err?.name !== 'AbortError') throw err
- }
- return
- }
- const url = URL.createObjectURL(blob)
- const a = document.createElement('a')
- a.href = url
- a.download = name
- a.click()
- // Revoke once the download has had a moment to start — leaked object
- // URLs pin their Blobs in memory for the app's whole lifetime.
- setTimeout(() => URL.revokeObjectURL(url), 5000)
- } catch {
- showToast("Couldn't export the backup.")
- }
- }
-
- const onRestore = async (e) => {
- const file = e.target.files[0]
- e.target.value = '' // reset so the same file can be re-picked later
- if (!file) return
- try {
- // Parse here, outside importBackup's Dexie transaction — awaiting
- // file.text() inside the transaction zone would kill it.
- const parsed = JSON.parse(await file.text())
- const { tracks, playlists: pls, podcasts } = await importBackup(parsed)
- showToast(`${tracks} songs · ${pls} playlists${podcasts ? ` · ${podcasts} shows` : ''} restored`)
- } catch {
- showToast("Couldn't read that backup file.")
- }
- }
-
- // Resolve ids the same way PlaylistDetail does, dropping dangling ids —
+ // Resolve ids the same way PlaylistDetail does, dropping dangling ids:
// deleted or restored-but-not-reimported tracks must not inflate the counts.
const byId = allTracks && new Map(allTracks.map((t) => [t.id, t]))
+ const resolve = (p) => p.trackIds.map((id) => byId.get(id)).filter(Boolean)
+
+ // A new view starts at the top.
+ useEffect(() => {
+ if (scrollRef?.current) scrollRef.current.scrollTop = 0
+ }, [openId, scrollRef])
+
+ if (playlists === undefined || allTracks === undefined) return
return (
- {playlists === undefined || allTracks === undefined ? (
- Loading…
- ) : open ? (
- setOpenId(null)} onActions={() => setActionsFor(open)} />
+ {open ? (
+ setOpenId(null)}
+ onActions={() => setActionsFor(open)}
+ />
) : (
<>
- {/* Same header shape the detail view uses, so nothing shifts. */}
-
- Playlists
- setCreating(true)}>+ New
-
+
+ setCreating(true)} aria-label="New playlist">
+
+
+
{playlists.length === 0 ? (
- No playlists yet. Create one, then add tracks with the ⋯ menu. Hold a playlist to rename or delete it.
+
+
+
No playlists yet
+
Make one, then press and hold any song to add it.
+
setCreating(true)}>New playlist
+
) : (
{playlists.map((p) => (
-
byId.get(id)).filter(Boolean)}
+ tracks={resolve(p)}
onOpen={() => setOpenId(p.id)}
onLongPress={() => setActionsFor(p)}
/>
))}
)}
-
- {/* Quiet backup corner. The file input lives here (not in App) so the
- Library tab keeps exactly one file input for the test harnesses. */}
-
- Export backup
- restoreRef.current?.click()}>Restore
-
-
>
)}
@@ -135,9 +93,10 @@ export default function PlaylistsView() {
{actionsFor && (
setActionsFor(null)}
- onRename={() => { setRenaming(actionsFor); setActionsFor(null) }}
- onDelete={() => { setDeleting(actionsFor); setActionsFor(null) }}
+ onRename={() => setRenaming(actionsFor)}
+ onDelete={() => setDeleting(actionsFor)}
/>
)}
@@ -154,7 +113,7 @@ export default function PlaylistsView() {
{deleting && (
setDeleting(null)}
onConfirm={async () => {
@@ -168,71 +127,107 @@ export default function PlaylistsView() {
)
}
-function PlaylistCard({ playlist, tracks, onOpen, onLongPress }) {
- const lp = useLongPress(onLongPress)
+/** "12 songs, 48 min" with the numbers in the numeric voice. */
+function Meta({ tracks }) {
+ const n = tracks.length
+ const dur = formatTotalDuration(tracks.reduce((s, t) => s + (t.duration || 0), 0))
return (
- { if (!lp.suppressClick()) onOpen() }}
- >
- {/* Spans only inside the card button — nested interactive elements broke
- iOS taps before (ad39cc8). Artwork renders a deterministic gradient
- for undefined tracks, which is the intended look for 0–3-song lists. */}
-
-
-
-
-
+ <>
+ {n} {n === 1 ? 'song' : 'songs'}{dur && <>, {dur}>}
+ >
+ )
+}
+
+// 2×2 of the first four covers when there are four; otherwise the first one.
+function Mosaic({ tracks, size = 56 }) {
+ if (tracks.length >= 4) {
+ return (
+
+ {tracks.slice(0, 4).map((t) => )}
-
- {playlist.name}
- {summarize(tracks)}
+ )
+ }
+ return (
+
+
+
+ )
+}
+
+function PlaylistRow({ playlist, tracks, onOpen, onLongPress }) {
+ const lp = useLongPress(onLongPress)
+ return (
+ { if (!lp.suppressClick()) onOpen() }}>
+
+
+ {playlist.name}
+
+
)
}
-function PlaylistDetail({ playlist, onBack, onActions }) {
- const allTracks = useTracks()
- const { playQueue } = usePlayer()
- if (allTracks === undefined) return Loading…
+function PlaylistDetail({ playlist, tracks, scrollRef, onBack, onActions }) {
+ const { playQueue, current, isPlaying } = usePlayer()
+ const [scrolled, setScrolled] = useState(false)
+ const bar = useRef(null)
+ const title = useRef(null)
+
+ // The compact bar shows the name only once the big title is mostly under
+ // it, so a short playlist never shows the name twice.
+ useEffect(() => {
+ const el = scrollRef?.current
+ if (!el) return
+ const on = () => {
+ if (!bar.current || !title.current) return
+ const t = title.current.getBoundingClientRect()
+ setScrolled(t.top + t.height / 2 <= bar.current.getBoundingClientRect().bottom)
+ }
+ on()
+ el.addEventListener('scroll', on, { passive: true })
+ return () => el.removeEventListener('scroll', on)
+ }, [scrollRef])
- // Resolve ids → track objects in saved order, dropping any since-deleted ids.
- const byId = new Map(allTracks.map((t) => [t.id, t]))
- const tracks = playlist.trackIds.map((id) => byId.get(id)).filter(Boolean)
- const shuffled = () => shuffle(tracks)
+ const source = { name: playlist.name, playlistId: playlist.id }
// Shuffle doesn't bookmark: the next Resume should follow playlist order.
- const inOrder = { playlistId: playlist.id }
+ const inOrder = { playlistId: playlist.id, source }
const resumeIdx = playlist.resume ? tracks.findIndex((t) => t.id === playlist.resume.trackId) : -1
const resumeTrack = resumeIdx >= 0 ? tracks[resumeIdx] : null
+ // No point offering to resume what is already playing.
+ const showResume = resumeTrack && !(isPlaying && current?.id === resumeTrack.id)
return (
<>
-
+
+
+ {playlist.name}
+
+
+
+
+
+
{playlist.name}
+ {tracks.length > 0 &&
}
+
{tracks.length > 0 ? (
<>
- {summarize(tracks)}
-
-
playQueue(tracks, 0, inOrder)}> Play
-
playQueue(shuffled(), 0)}> Shuffle
+
+ playQueue(tracks, 0, inOrder)}> Play
+ playQueue(shuffle(tracks), 0, { source })}> Shuffle
- {resumeTrack && (
+ {showResume && (
playQueue(tracks, resumeIdx, { ...inOrder, startAt: playlist.resume.position })}
>
-
-
- Resume
- {resumeTrack.title}
+
+
+ Continue
+ {displayTitle(resumeTrack)}
- {formatTime(playlist.resume.position)}
+ {formatTime(playlist.resume.position)}
)}
@@ -242,13 +237,8 @@ function PlaylistDetail({ playlist, onBack, onActions }) {
>
) : (
-
Empty playlist. Add tracks from Library or Search using the ⋯ menu.
+
Empty for now. Press and hold any song in Library or Search to add it here.
)}
>
)
}
-
-function PlayGlyph() { return
}
-function ShuffleGlyph() { return
}
-function ChevronLeft() { return
}
-function Dots() { return
}
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(
-
+ ,
+ document.body,
)
}
diff --git a/src/components/SearchView.jsx b/src/components/SearchView.jsx
index 7a59ffc..7e37657 100644
--- a/src/components/SearchView.jsx
+++ b/src/components/SearchView.jsx
@@ -3,18 +3,17 @@ import { useTracks, useSearch } from '../state/useLibrary'
import { parseYouTube, buildYtDlpCommand } from '../lib/youtube'
import TrackRow from './TrackRow'
import YouTubeLinkCard from './YouTubeLinkCard'
+import Icon from './Icon'
-export default function SearchView() {
- const [query, setQuery] = useState('')
+export default function SearchView({ query, setQuery }) {
const [autoCopied, setAutoCopied] = useState(false)
const tracks = useTracks()
// Defer the query fed to the (synchronous) fuzzy search so fast typing over a
- // large library doesn't drop input frames — React can skip intermediate list
- // renders and catch up when idle.
+ // large library doesn't drop input frames.
const deferredQuery = useDeferredValue(query)
const results = useSearch(tracks, deferredQuery)
- const q = query.trim() // immediate — drives the clear button
- const dq = deferredQuery.trim() // matches `results`, so the list/hint stay consistent
+ const q = query.trim() // immediate: drives the clear button
+ const dq = deferredQuery.trim() // matches `results`, so the list stays consistent
const yt = parseYouTube(query) // non-null when a YouTube link is pasted
// One tap: read the link from the clipboard AND copy the a-Shell command back,
@@ -25,7 +24,7 @@ export default function SearchView() {
try {
text = await navigator.clipboard.readText()
} catch {
- return // clipboard read blocked — user can type instead
+ return // clipboard read blocked: the user can type instead
}
if (!text) return
setQuery(text)
@@ -47,61 +46,51 @@ export default function SearchView() {
return (
-
-
-
-
+
+
- {q ? (
-
{ setQuery(''); setAutoCopied(false) }} aria-label="Clear">×
- ) : (
-
+ {q && (
+
{ setQuery(''); setAutoCopied(false) }} aria-label="Clear">
)}
{yt ? (
- ) : tracks === undefined ? (
-
Loading…
- ) : dq && results.length > 0 ? (
+ ) : tracks === undefined ? null : dq && results.length > 0 ? (
{results.map((t) => (
-
+
))}
+ ) : dq ? (
+
+
+
No results
+
Check the spelling, or paste a YouTube link.
+
) : (
-
- {dq ? `Nothing matches “${dq}”.` : 'Tap the paste icon to drop in a YouTube link, or type to search your library.'}
-
+ <>
+
+
+
+ Paste a YouTube link
+
+
+
Or type to find a song in your library.
+ >
)}
)
}
-
-function SearchIcon() {
- return (
-
-
-
- )
-}
-function PasteIcon() {
- return (
-
-
-
-
- )
-}
diff --git a/src/components/Sheet.jsx b/src/components/Sheet.jsx
new file mode 100644
index 0000000..463ab78
--- /dev/null
+++ b/src/components/Sheet.jsx
@@ -0,0 +1,74 @@
+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)
+ // Taps in the first moments belong to the gesture that opened the sheet
+ // (e.g. the finger lifting after a long-press), not to the sheet.
+ const openedAt = useRef(Date.now())
+ const tooSoon = () => Date.now() - openedAt.current < 350
+ const swallowEarly = (e) => {
+ if (tooSoon()) { e.preventDefault(); e.stopPropagation() }
+ }
+
+ 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(
+ <>
+
{ if (!tooSoon()) close() }} />
+
{ panel.current = el; dialog.ref.current = el }}
+ onKeyDown={dialog.onKeyDown}
+ onClickCapture={swallowEarly}
+ role="dialog"
+ aria-modal="true"
+ aria-label={label}
+ tabIndex={-1}
+ >
+ {typeof children === 'function' ? children(close) : children}
+
+ >,
+ document.body,
+ )
+}
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 (
-
-
playTrack(track, list, playOpts)}>
-
-
- {track.title}
- {track.artist}
-
- {isCurrent && isPlaying && }
-
-
-
- {isLongForm(track) && track.position > 0 ? timeLeft(track.duration, track.position) : formatTime(track.duration)}
+ {
+ if (!lp.suppressClick()) playTrack(track, list, playOpts)
+ }}
+ >
+
+
+ {displayTitle(track)}
+ {sub && {sub} }
-
- toggleStar(track.id).catch(() => {})}
- aria-label={track.starred ? 'Unfavorite' : 'Favorite'}
- >
-
-
-
- openAddToPlaylist(track, playlist)} aria-label="Add to playlist">
-
-
-
+
+ {isCurrent ? (
+
+ ) : (
+ <>
+ {!!track.starred && }
+ {formatTime(track.duration)}
+ >
+ )}
+
+
)
}
-function EqBars() {
+export function EqBars({ paused }) {
return (
-
-
+
+
)
}
-function StarIcon({ filled }) {
- return (
-
-
-
- )
-}
-function DotsIcon() {
- return (
-
-
-
- )
-}
diff --git a/src/components/YouTubeLinkCard.jsx b/src/components/YouTubeLinkCard.jsx
index 023e4dc..ff7905b 100644
--- a/src/components/YouTubeLinkCard.jsx
+++ b/src/components/YouTubeLinkCard.jsx
@@ -1,9 +1,10 @@
import { useState, useEffect } from 'react'
import { buildYtDlpCommand, fetchYouTubePreview } from '../lib/youtube'
+import Icon from './Icon'
// The thumbnail URL comes from a third-party (noembed) response, so validate it
// before using it as an : require https and a YouTube-owned host,
-// otherwise drop it (the card still shows the title/author).
+// otherwise drop it (the card still shows the title).
function safeThumb(url) {
try {
const u = new URL(url)
@@ -18,18 +19,18 @@ function safeThumb(url) {
return null
}
-// Shown in Search when a YouTube link is present. Previews the video and offers
-// the a-Shell command. The command box itself is the copy control (tap to copy)
-// — and pasting via the search bar pre-copies it, so usually it's already done.
+// Shown in Search when a YouTube link is present: previews the video and copies
+// the a-Shell command. Pasting via the search bar pre-copies it, so usually
+// it's already done.
export default function YouTubeLinkCard({ yt, copied }) {
- const [preview, setPreview] = useState(null)
+ const [preview, setPreview] = useState(undefined) // undefined = loading
const [tapCopied, setTapCopied] = useState(false)
const command = buildYtDlpCommand(yt.url)
const done = copied || tapCopied
useEffect(() => {
let alive = true
- setPreview(null)
+ setPreview(undefined)
setTapCopied(false)
fetchYouTubePreview(yt.id).then((p) => alive && setPreview(p))
return () => { alive = false }
@@ -41,30 +42,25 @@ export default function YouTubeLinkCard({ yt, copied }) {
setTapCopied(true)
setTimeout(() => setTapCopied(false), 1800)
} catch {
- /* ignore — box content is still readable */
+ /* ignore: the command stays readable below */
}
}
+ const thumb = preview && safeThumb(preview.thumbnail)
return (
- {preview ? (
-
- {safeThumb(preview.thumbnail) &&
}
-
-
{preview.title}
-
{preview.author}
-
-
- ) : (
-
Video {yt.id}
- )}
+
+ {thumb ?
:
}
+
{preview === undefined ? '' : preview?.title || `Video ${yt.id}`}
+
-
- {command}
- {done ? '✓ Copied — paste in a-Shell' : 'Tap to copy'}
+
+
+ {done ? 'Copied, paste it in a-Shell' : 'Copy for a-Shell'}
+ {command}
- Then come back and tap Import in Library.
+ Then come back, tap ⋯ in Library and choose Import from Files .
)
}
diff --git a/src/index.css b/src/index.css
index aac3a51..fdf8621 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,553 +83,553 @@ 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); }
+/* The name hands off to the bar: never both at once. */
+.hero h1 { transition: opacity var(--dur-fade); }
+.navbar--scrolled + .hero h1 { opacity: 0; }
+.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); }
-.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/db.js b/src/lib/db.js
index d8b691e..ccd1e02 100644
--- a/src/lib/db.js
+++ b/src/lib/db.js
@@ -118,7 +118,7 @@ export async function storageEstimate() {
* is already imported. Returns `{ id, duplicate }`: `duplicate` is true when an
* existing track matched and nothing was written.
*/
-export async function addLocalTrack({ title, artist, duration, blob, thumbnailUrl = null, youtubeId = null }) {
+export async function addLocalTrack({ title, artist, duration, blob, thumbnailUrl = null, youtubeId = null, dateAdded = Date.now() }) {
const nTitle = title || 'Unknown'
const nArtist = artist || 'Imported'
const nDuration = duration || 0
@@ -165,7 +165,7 @@ export async function addLocalTrack({ title, artist, duration, blob, thumbnailUr
starred: 0,
playCount: 0,
lastPlayedAt: null,
- dateAdded: Date.now(),
+ dateAdded,
})
result = { id, duplicate: false }
})
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/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
+}
diff --git a/src/lib/useBackup.jsx b/src/lib/useBackup.jsx
new file mode 100644
index 0000000..ac7c966
--- /dev/null
+++ b/src/lib/useBackup.jsx
@@ -0,0 +1,57 @@
+import { useRef } from 'react'
+import { exportBackup, importBackup } from './db'
+import { useUI } from '../state/UIProvider'
+
+// Export / restore the library as JSON. Returns the hidden restore to
+// render (it must stay mounted while the picker is up) plus the two actions.
+export function useBackup() {
+ const { showToast } = useUI()
+ const restoreRef = useRef(null)
+
+ const exportFile = async () => {
+ try {
+ const data = await exportBackup()
+ const name = `melody-backup-${new Date().toISOString().slice(0, 10)}.json`
+ const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' })
+ // On iPhone the share sheet is the reliable way out ("Save to Files");
+ // a home-screen app can't follow a download link.
+ const file = new File([blob], name, { type: 'application/json' })
+ if (navigator.canShare?.({ files: [file] })) {
+ try {
+ await navigator.share({ files: [file] })
+ } catch (err) {
+ if (err?.name !== 'AbortError') throw err
+ }
+ return
+ }
+ const url = URL.createObjectURL(blob)
+ const a = document.createElement('a')
+ a.href = url
+ a.download = name
+ a.click()
+ // Revoke once the download has had a moment to start: leaked object
+ // URLs pin their Blobs in memory for the app's whole lifetime.
+ setTimeout(() => URL.revokeObjectURL(url), 5000)
+ } catch {
+ showToast("Couldn't export the backup.")
+ }
+ }
+
+ const onRestore = async (e) => {
+ const file = e.target.files[0]
+ e.target.value = '' // reset so the same file can be re-picked later
+ if (!file) return
+ try {
+ // Parse here, outside importBackup's Dexie transaction: awaiting
+ // file.text() inside the transaction zone would kill it.
+ const parsed = JSON.parse(await file.text())
+ const { tracks, playlists, podcasts } = await importBackup(parsed)
+ showToast(`${tracks} songs · ${playlists} playlists${podcasts ? ` · ${podcasts} shows` : ''} restored`)
+ } catch {
+ showToast("Couldn't read that backup file.")
+ }
+ }
+
+ const input =
+ return { input, exportFile, restore: () => restoreRef.current?.click() }
+}
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])
+}
diff --git a/src/components/ImportButton.jsx b/src/lib/useImporter.jsx
similarity index 64%
rename from src/components/ImportButton.jsx
rename to src/lib/useImporter.jsx
index dc95216..627db8c 100644
--- a/src/components/ImportButton.jsx
+++ b/src/lib/useImporter.jsx
@@ -1,16 +1,17 @@
import { useRef, useState } from 'react'
-import { addLocalTrack, requestPersistentStorage, storageEstimate, seriesNumber, upsertSeriesPlaylist } from '../lib/db'
+import { addLocalTrack, requestPersistentStorage, storageEstimate, seriesNumber, upsertSeriesPlaylist } from './db'
import { useUI } from '../state/UIProvider'
-import { readDuration, parseFilename } from '../lib/audio'
-import { ensureLyrics } from '../lib/lyrics'
-import { fetchYouTubePreview } from '../lib/youtube'
+import { readDuration, parseFilename } from './audio'
+import { ensureLyrics } from './lyrics'
+import { fetchYouTubePreview } from './youtube'
// Imports audio files from the Files app via a native file picker (the only way
-// to read user files on iOS — no File System Access API). Each file's bytes are
+// to read user files on iOS: no File System Access API). Each file's bytes are
// stored in IndexedDB and added to the library; useLiveQuery refreshes the list.
-export default function ImportButton() {
+// Returns the hidden to render, open() to show the picker, and progress.
+export function useImporter() {
const inputRef = useRef(null)
- const [remaining, setRemaining] = useState(0)
+ const [progress, setProgress] = useState(null) // { done, total } while importing
const { showToast } = useUI() // results surface in the app-wide toast
const onPick = async (e) => {
@@ -22,8 +23,8 @@ export default function ImportButton() {
if (!picked.length) return
// Select-All in the a-Shell folder sweeps in .json/.txt sidecars alongside
- // the audio — the accept attr is advisory only on iOS, so filter here,
- // before the quota pre-flight and the remaining counter see the list.
+ // the audio. The accept attr is advisory only on iOS, so filter here,
+ // before the quota pre-flight and the progress counter see the list.
const files = picked.filter(
(f) => f.type.startsWith('audio/') || /\.(m4a|mp3|aac|wav|flac|ogg)$/i.test(f.name)
)
@@ -40,17 +41,20 @@ export default function ImportButton() {
const totalBytes = files.reduce((n, f) => n + (f.size || 0), 0)
const est = await storageEstimate()
if (est?.quota && est.usage + totalBytes > est.quota * 0.95) {
- showToast("Not enough storage to import these. Free up space and try again.")
+ showToast('Not enough storage to import these. Free up space and try again.')
return
}
- setRemaining(files.length)
+ setProgress({ done: 0, total: files.length })
+ // One timestamp per import, counting down in pick order, so "Recent"
+ // (newest first) still lists a series 1, 2, 3 rather than backwards.
+ const batchAt = Date.now()
let added = 0
let skipped = 0
let failed = 0
let outOfSpace = false
const series = new Map() // artist → ids of numbered tracks from this import
- for (const file of files) {
+ for (const [i, file] of files.entries()) {
try {
const { title, youtubeId } = parseFilename(file.name)
let { artist } = parseFilename(file.name)
@@ -67,7 +71,9 @@ export default function ImportButton() {
if (chan) artist = chan
}
- const { id, duplicate } = await addLocalTrack({ title, artist, duration, blob: file, thumbnailUrl, youtubeId })
+ const { id, duplicate } = await addLocalTrack({
+ title, artist, duration, blob: file, thumbnailUrl, youtubeId, dateAdded: batchAt - i,
+ })
if (artist !== 'Imported' && seriesNumber(title) != null) {
if (!series.has(artist)) series.set(artist, [])
series.get(artist).push(id)
@@ -76,7 +82,7 @@ export default function ImportButton() {
skipped++
continue
}
- // Counted explicitly rather than derived — the out-of-space break below
+ // Counted explicitly rather than derived: the out-of-space break below
// exits the loop early, which would make length arithmetic lie.
added++
// fetch lyrics in the background (cached for offline); don't block import
@@ -85,14 +91,14 @@ export default function ImportButton() {
failed++
if (err?.name === 'QuotaExceededError') {
outOfSpace = true
- break // storage is full — no point trying the rest
+ break // storage is full, no point trying the rest
}
console.error('import failed for', file.name, err)
} finally {
- setRemaining((n) => n - 1)
+ setProgress((p) => p && { ...p, done: p.done + 1 })
}
}
- setRemaining(0)
+ setProgress(null)
// Three or more numbered files from one show make (or extend) a playlist.
const made = []
@@ -102,12 +108,12 @@ export default function ImportButton() {
made.push(`Playlist “${name}” is ready, in order`)
}
- // Always leave a summary — a re-pick of the whole folder is a sync, and
+ // Always leave a summary: a re-pick of the whole folder is a sync, and
// "nothing happened" must still be an answer, never a silent no-op.
if (outOfSpace) {
- showToast("Ran out of storage — not all tracks were imported.")
+ showToast('Ran out of storage. Not all tracks were imported.')
} else if (added === 0 && failed === 0) {
- showToast(['No new songs — everything already in your library.', ...made].join(' · '))
+ showToast(['No new songs, everything is already in your library', ...made].join(' · '))
} else {
const parts = [`${added} added`]
if (skipped) parts.push(`${skipped} already in your library`)
@@ -116,37 +122,15 @@ export default function ImportButton() {
}
}
- const busy = remaining > 0
- return (
- <>
- inputRef.current?.click()}
- disabled={busy}
- >
- {busy ? : }
- {busy ? `Importing… (${remaining})` : 'Import'}
-
-
- >
+ const input = (
+
)
-}
-
-function TrayIcon() {
- return (
-
-
-
-
- )
-}
-function Spinner() {
- return
+ return { input, open: () => inputRef.current?.click(), progress }
}
diff --git a/src/lib/useLongPress.js b/src/lib/useLongPress.js
index fa4bd4f..8b3d656 100644
--- a/src/lib/useLongPress.js
+++ b/src/lib/useLongPress.js
@@ -48,9 +48,12 @@ export function useLongPress(onLongPress, delay = 450) {
return {
handlers: {
onTouchStart: (e) => begin(e, true),
- onTouchEnd: () => {
+ onTouchEnd: (e) => {
clear()
touchedAt.current = Date.now()
+ // After a hold, cancel the compat mousedown/click so lifting the
+ // finger doesn't tap whatever the sheet put under it.
+ if (fired.current && e.cancelable) e.preventDefault()
},
onTouchMove: move,
onTouchCancel: clear, // a system-interrupted touch must not leave the timer armed
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/src/state/PlayerProvider.jsx b/src/state/PlayerProvider.jsx
index 098ba31..a85e00d 100644
--- a/src/state/PlayerProvider.jsx
+++ b/src/state/PlayerProvider.jsx
@@ -2,6 +2,8 @@ import { createContext, useContext, useEffect, useRef, useState, useCallback } f
import { bumpPlayCount, getAudioBlob, getPosition, savePosition, setPlayed, savePlaylistResume } from '../lib/db'
import { isLongForm } from '../lib/podcasts'
import { ytThumbId } from '../lib/youtube'
+import { seriesTile, displayTitle } from '../lib/series'
+import { tileArtwork } from '../lib/tileArt'
const PlayerContext = createContext(null)
export const usePlayer = () => useContext(PlayerContext)
@@ -10,8 +12,8 @@ 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.
-export const SPEEDS = [1, 1.25, 1.5, 1.75, 2, 0.75]
+// Playback speeds offered in Now Playing's menu. Applies to everything.
+export const SPEEDS = [0.75, 1, 1.25, 1.5, 2]
const SKIP_BACK = 15
const SKIP_FORWARD = 30
@@ -55,18 +57,20 @@ export function PlayerProvider({ children }) {
// null (which flashed the Now Playing screen closed).
const [playToken, setPlayToken] = useState(0)
const [speed, setSpeed] = useState(loadSpeed)
+ // Where the queue came from, for "Playing from …": { name, playlistId }.
+ const [source, setSource] = useState(null)
const speedRef = useRef(speed)
speedRef.current = speed
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
}, [])
@@ -74,9 +78,11 @@ export function PlayerProvider({ children }) {
// ── Core: load + play a queue starting at a given index ──
// opts.playlistId: remember progress on that playlist as it plays.
// opts.startAt: begin the first track at this position (seconds).
+ // opts.source: { name, playlistId } shown as "Playing from ".
const playQueue = useCallback((tracks, startIndex = 0, opts = {}) => {
if (!tracks.length) return
sourceRef.current = opts.playlistId ?? null
+ setSource(opts.source ?? null)
startAtRef.current = opts.startAt ?? null
setQueue(tracks)
setIndex(startIndex)
@@ -263,8 +269,8 @@ export function PlayerProvider({ children }) {
a.currentTime = Math.max(0, a.duration ? Math.min(a.duration, t) : t)
}, [])
- const cycleSpeed = useCallback(() => {
- setSpeed((v) => SPEEDS[(SPEEDS.indexOf(v) + 1) % SPEEDS.length])
+ const pickSpeed = useCallback((v) => {
+ if (SPEEDS.includes(v)) setSpeed(v)
}, [])
// Write the listening position for long-form items. Skipped in the last
@@ -356,19 +362,25 @@ export function PlayerProvider({ children }) {
return
}
+ // The lock screen crops to a square too, so skip the letterboxed
+ // hqdefault; a numbered track without art gets its number tile drawn.
+ const yt = ytThumbId(current.thumbnailUrl)
+ const tile = seriesTile(current)
+ const artwork = yt
+ ? [
+ { src: `https://i.ytimg.com/vi/${yt}/maxresdefault.jpg`, sizes: '1280x720', type: 'image/jpeg' },
+ { src: `https://i.ytimg.com/vi/${yt}/mqdefault.jpg`, sizes: '320x180', type: 'image/jpeg' },
+ ]
+ : current.thumbnailUrl
+ ? [{ src: current.thumbnailUrl, sizes: 'any', type: 'image/jpeg' }]
+ : tile
+ ? [{ src: tileArtwork(tile), sizes: '512x512', type: 'image/png' }]
+ : []
ms.metadata = new window.MediaMetadata({
- title: current.title || 'Unknown',
- artist: current.artist || '',
- // The lock screen crops to a square too, so skip the letterboxed hqdefault.
- artwork: current.thumbnailUrl
- ? [{
- src: ytThumbId(current.thumbnailUrl)
- ? `https://i.ytimg.com/vi/${ytThumbId(current.thumbnailUrl)}/mqdefault.jpg`
- : current.thumbnailUrl,
- sizes: 'any',
- type: 'image/jpeg',
- }]
- : [],
+ title: displayTitle(current) || 'Unknown',
+ artist: '',
+ album: source?.name || '',
+ artwork,
})
const set = (action, handler) => {
@@ -393,7 +405,7 @@ export function PlayerProvider({ children }) {
// Clear handlers when the track changes or the provider unmounts, so no
// remote press ever fires a closure bound to the previous track.
return clearAll
- }, [current, longForm, play, pause, prev, next, seek, skip])
+ }, [current, source, longForm, play, pause, prev, next, seek, skip])
// Single source of truth for playbackState: 'none' when nothing is loaded,
// else mirror isPlaying. iOS uses this to decide whether a remote press maps
@@ -406,8 +418,8 @@ export function PlayerProvider({ children }) {
}, [isPlaying, current])
const value = {
- current, queue, index, isPlaying, loopMode, progress, duration, missing, longForm, speed,
- playTrack, playQueue, playNext, addToQueue, toggle, next, prev, seek, cycleLoop, skip, cycleSpeed,
+ current, queue, index, isPlaying, loopMode, progress, duration, missing, longForm, speed, source,
+ playTrack, playQueue, playNext, addToQueue, toggle, next, prev, seek, cycleLoop, skip, pickSpeed,
}
return (
diff --git a/src/state/UIProvider.jsx b/src/state/UIProvider.jsx
index c22a121..c17fc97 100644
--- a/src/state/UIProvider.jsx
+++ b/src/state/UIProvider.jsx
@@ -1,15 +1,17 @@
import { createContext, useContext, useState, useCallback } from 'react'
-// Tiny global UI state so any TrackRow can open the "Add to playlist" sheet
-// without prop-drilling. The sheet itself is rendered once at the app root.
-// The toast follows the same pattern: any component can announce a result and
-// App renders the single toast host inside the dock.
+// Tiny global UI state so any row can open the track sheet without
+// prop-drilling. The sheet itself is rendered once at the app root. The toast
+// follows the same pattern, and navigation lives here so Now Playing's
+// "Go to " can switch tabs and open a playlist.
const UIContext = createContext(null)
export const useUI = () => useContext(UIContext)
export function UIProvider({ children }) {
- const [addTarget, setAddTarget] = useState(null) // track being added, or null
+ const [addTarget, setAddTarget] = useState(null) // track in the sheet, or null
const [toast, setToast] = useState(null) // { id, message } or null
+ const [tab, setTab] = useState('library')
+ const [playlistId, setPlaylistId] = useState(null) // open playlist detail
// fromPlaylist: the playlist the row belongs to, so the sheet can offer removal.
const [fromPlaylist, setFromPlaylist] = useState(null)
@@ -19,13 +21,23 @@ export function UIProvider({ children }) {
}, [])
const closeAddToPlaylist = useCallback(() => setAddTarget(null), [])
- // id changes per call so App can key the Toast — remounting restarts the
+ // id changes per call so App can key the Toast: remounting restarts the
// auto-dismiss timer even when the same message fires twice in a row.
const showToast = useCallback((message) => setToast({ id: Date.now(), message }), [])
const clearToast = useCallback(() => setToast(null), [])
+ const goToPlaylist = useCallback((id) => {
+ setTab('playlists')
+ setPlaylistId(id)
+ }, [])
+
return (
-
+
{children}
)
diff --git a/src/state/useLibrary.js b/src/state/useLibrary.js
index e76b14c..d52abbd 100644
--- a/src/state/useLibrary.js
+++ b/src/state/useLibrary.js
@@ -27,8 +27,9 @@ export function useTrack(trackId) {
}
/**
- * Fuzzy, as-you-type search over a track list. Returns the full list (newest
- * first) when the query is empty, else Fuse-ranked matches on title + artist.
+ * As-you-type search over a track list. Returns the full list (newest first)
+ * when the query is empty. Plain substring hits on the title come first, then
+ * Fuse's typo-tolerant matches on title + artist fill in behind them.
*/
export function useSearch(tracks, query) {
const fuse = useMemo(
@@ -38,7 +39,8 @@ export function useSearch(tracks, query) {
{ name: 'title', weight: 0.7 },
{ name: 'artist', weight: 0.3 },
],
- threshold: 0.4, // 0 = exact, 1 = match anything; 0.4 is forgiving but sane
+ threshold: 0.3, // 0 = exact, 1 = match anything
+ minMatchCharLength: 2,
ignoreLocation: true,
}),
[tracks],
@@ -47,7 +49,10 @@ export function useSearch(tracks, query) {
return useMemo(() => {
const q = query.trim()
if (!q) return tracks || []
- return fuse.search(q).map((r) => r.item)
+ const needle = q.toLowerCase()
+ const exact = (tracks || []).filter((t) => (t.title || '').toLowerCase().includes(needle))
+ const seen = new Set(exact.map((t) => t.id))
+ return [...exact, ...fuse.search(q).map((r) => r.item).filter((t) => !seen.has(t.id))]
}, [fuse, query, tracks])
}
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.