Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 99 additions & 0 deletions scripts/test-series.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,99 @@
// Numbered imports become an ordered playlist, and a playlist resumes on the
// track and spot where you left it, across a reload.
// Starts its own Vite dev server: node scripts/test-series.mjs
import { chromium, devices } from 'playwright'
import { createServer } from 'vite'
import { resolve, dirname } from 'node:path'
import { fileURLToPath } from 'node:url'

const here = dirname(fileURLToPath(import.meta.url))
const server = await createServer({ root: resolve(here, '..'), server: { port: 5193, strictPort: true }, logLevel: 'error' })
await server.listen()

const browser = await chromium.launch({ args: ['--autoplay-policy=no-user-gesture-required'] })
const page = await (await browser.newContext({ ...devices['iPhone 13'] })).newPage()
page.on('pageerror', (e) => console.log('PAGEERROR:', e.message))

let failed = 0
const check = (name, ok, detail = '') => {
console.log(`${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ` (${detail})` : ''}`)
if (!ok) failed++
}

// A quiet 90-second 8 kHz WAV with a faint tone so each file is distinct.
function wav(seconds, hz) {
const rate = 8000, n = seconds * rate
const b = Buffer.alloc(44 + n)
b.write('RIFF', 0); b.writeUInt32LE(36 + n, 4); b.write('WAVEfmt ', 8)
b.writeUInt32LE(16, 16); b.writeUInt16LE(1, 20); b.writeUInt16LE(1, 22)
b.writeUInt32LE(rate, 24); b.writeUInt32LE(rate, 28); b.writeUInt16LE(1, 32); b.writeUInt16LE(8, 34)
b.write('data', 36); b.writeUInt32LE(n, 40)
for (let i = 0; i < n; i++) b[44 + i] = 128 + Math.round(8 * Math.sin((2 * Math.PI * hz * i) / rate))
return b
}
const file = (name, hz) => ({ name, mimeType: 'audio/wav', buffer: wav(90, hz) })

try {
await page.goto('http://localhost:5193', { waitUntil: 'networkidle' })
// Picked out of order, and 10 sorts after 2 only when compared as numbers.
await page.locator('input[type=file]').setInputFiles([
file('Morning Show - 010 Wrap Up.wav', 300),
file('Morning Show - 002 Second.wav', 320),
file('Morning Show - 001 Welcome.wav', 340),
file('Morning Show - 003 Third.wav', 360),
])
await page.getByText('is ready, in order').waitFor({ timeout: 20000 })
check('import reports the series playlist', true)

await page.getByRole('button', { name: 'Playlists' }).click()
await page.getByText('Morning Show').click()
const titles = await page.locator('.row__title').allTextContents()
check('playlist is in episode order', titles.join(',') === '001 Welcome,002 Second,003 Third,010 Wrap Up', titles.join(','))

// Play the second episode, move 40s in, pause.
await page.locator('.row__main').nth(1).click()
await page.waitForFunction(() => document.querySelector('audio')?.currentTime > 0.3, null, { timeout: 10000 })
await page.evaluate(() => { document.querySelector('audio').currentTime = 40 })
await page.waitForTimeout(600)
await page.locator('.mini').getByLabel('Pause').click()
await page.waitForTimeout(500)

await page.reload({ waitUntil: 'networkidle' })
await page.getByRole('button', { name: 'Playlists' }).click()
await page.getByText('Morning Show').click()
const resume = page.locator('.resume')
await resume.waitFor({ timeout: 5000 })
const label = await resume.textContent()
check('Resume shows the right episode and time', label.includes('002 Second') && label.includes('0:40'), label)

await resume.click()
await page.waitForFunction(() => document.querySelector('audio')?.currentTime > 39, null, { timeout: 10000 }).catch(() => {})
const t = await page.evaluate(() => document.querySelector('audio').currentTime)
const title = await page.locator('.mini__title').textContent()
check('Resume plays that episode from that spot', title === '002 Second' && t >= 39, `${title} @ ${t.toFixed(1)}`)

// Finishing an episode moves the bookmark to the next one.
await page.evaluate(() => { const a = document.querySelector('audio'); a.currentTime = a.duration - 0.5 })
await page.waitForFunction(() => document.querySelector('.mini__title')?.textContent === '003 Third', null, { timeout: 10000 }).catch(() => {})
await page.waitForTimeout(800)
await page.locator('.mini').getByLabel('Pause').click()
await page.waitForTimeout(500)
const next = await page.locator('.resume').textContent()
check('bookmark follows playback to the next episode', next.includes('003 Third'), next)
await page.screenshot({ path: resolve(here, '../screenshots/14-series-resume.png') })

// Re-importing the same files adds nothing and keeps one playlist.
await page.getByRole('button', { name: 'Library' }).click()
await page.locator('input[type=file]').setInputFiles([file('Morning Show - 001 Welcome.wav', 340)])
await page.waitForTimeout(1500)
await page.getByRole('button', { name: 'Playlists' }).click()
check('no duplicate playlist on re-import', await page.getByText('Morning Show').count() === 1)
} catch (e) {
console.log('ERROR:', e.message)
failed++
} finally {
await browser.close()
await server.close()
}
console.log(failed ? `${failed} check(s) failed` : 'all checks passed')
process.exit(failed ? 1 : 0)
21 changes: 19 additions & 2 deletions src/components/ImportButton.jsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { useRef, useState } from 'react'
import { addLocalTrack, requestPersistentStorage, storageEstimate } from '../lib/db'
import { addLocalTrack, requestPersistentStorage, storageEstimate, seriesNumber, upsertSeriesPlaylist } from '../lib/db'
import { readDuration, parseFilename } from '../lib/audio'
import { ensureLyrics } from '../lib/lyrics'
import { fetchYouTubePreview } from '../lib/youtube'
Expand All @@ -13,7 +13,10 @@ export default function ImportButton() {
const [notice, setNotice] = useState(null) // user-facing result/errors

const onPick = async (e) => {
const files = [...e.target.files]
// Name order with numbers compared as numbers, so a numbered series lands
// in the library (and its playlist) in episode order.
const files = [...e.target.files].sort((a, b) =>
a.name.localeCompare(b.name, undefined, { numeric: true, sensitivity: 'base' }))
e.target.value = '' // reset so the same file can be re-picked later
if (!files.length) return
setNotice(null)
Expand All @@ -34,6 +37,7 @@ export default function ImportButton() {
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) {
try {
const { title, youtubeId } = parseFilename(file.name)
Expand All @@ -52,6 +56,10 @@ export default function ImportButton() {
}

const { id, duplicate } = await addLocalTrack({ title, artist, duration, blob: file, thumbnailUrl, youtubeId })
if (artist !== 'Imported' && seriesNumber(title) != null) {
if (!series.has(artist)) series.set(artist, [])
series.get(artist).push(id)
}
if (duplicate) {
skipped++
continue
Expand All @@ -71,13 +79,22 @@ export default function ImportButton() {
}
setRemaining(0)

// Three or more numbered files from one show make (or extend) a playlist.
const made = []
for (const [name, ids] of series) {
if (ids.length < 3) continue
await upsertSeriesPlaylist(name, ids).catch(() => {})
made.push(name)
}

// Only surface something when it's worth telling the user about.
if (outOfSpace) {
setNotice("Ran out of storage — not all tracks were imported.")
} else {
const parts = []
if (skipped) parts.push(`${skipped} already in your library`)
if (failed) parts.push(`${failed} couldn't be imported`)
for (const name of made) parts.push(`Playlist “${name}” is ready, in order`)
setNotice(parts.length ? parts.join(' · ') : null)
}
}
Expand Down
23 changes: 20 additions & 3 deletions src/components/PlaylistsView.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { usePlaylists, useTracks } from '../state/useLibrary'
import { createPlaylist, renamePlaylist, deletePlaylist, removeFromPlaylist } from '../lib/db'
import { usePlayer } from '../state/PlayerProvider'
import { useLongPress } from '../lib/useLongPress'
import { summarize } from '../lib/format'
import { summarize, formatTime } from '../lib/format'
import { shuffle } from '../lib/shuffle'
import TrackRow from './TrackRow'
import PromptModal from './PromptModal'
Expand Down Expand Up @@ -118,6 +118,10 @@ function PlaylistDetail({ playlist, onBack, onActions }) {
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)
// Shuffle doesn't bookmark: the next Resume should follow playlist order.
const inOrder = { playlistId: playlist.id }
const resumeIdx = playlist.resume ? tracks.findIndex((t) => t.id === playlist.resume.trackId) : -1
const resumeTrack = resumeIdx >= 0 ? tracks[resumeIdx] : null

return (
<>
Expand All @@ -131,13 +135,26 @@ function PlaylistDetail({ playlist, onBack, onActions }) {
<>
<p className="phead__meta dim">{summarize(tracks)}</p>
<div className="row-actions">
<button className="btn btn--accent playall" onClick={() => playQueue(tracks, 0)}><PlayGlyph /> Play</button>
<button className="btn btn--accent playall" onClick={() => playQueue(tracks, 0, inOrder)}><PlayGlyph /> Play</button>
<button className="btn btn--ghost playall" onClick={() => playQueue(shuffled(), 0)}><ShuffleGlyph /> Shuffle</button>
</div>
{resumeTrack && (
<button
className="resume"
onClick={() => playQueue(tracks, resumeIdx, { ...inOrder, startAt: playlist.resume.position })}
>
<PlayGlyph />
<span className="resume__meta">
<span className="resume__label">Resume</span>
<span className="resume__title">{resumeTrack.title}</span>
</span>
<span className="resume__time">{formatTime(playlist.resume.position)}</span>
</button>
)}
<div className="list">
{tracks.map((t) => (
<div key={t.id} className="plrow">
<TrackRow track={t} list={tracks} />
<TrackRow track={t} list={tracks} playOpts={inOrder} />
<button className="iconbtn" onClick={() => removeFromPlaylist(playlist.id, t.id)} aria-label="Remove from playlist">−</button>
</div>
))}
Expand Down
4 changes: 2 additions & 2 deletions src/components/TrackRow.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,14 @@ 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 }) {
export default function TrackRow({ track, list, playOpts }) {
const { current, isPlaying, playTrack } = usePlayer()
const { openAddToPlaylist } = useUI()
const isCurrent = current?.id === track.id

return (
<div className={`row ${isCurrent ? 'row--active' : ''}`}>
<button className="row__main" onClick={() => playTrack(track, list)}>
<button className="row__main" onClick={() => playTrack(track, list, playOpts)}>
<Artwork track={track} />
<span className="row__meta">
<span className="row__title">{track.title}</span>
Expand Down
12 changes: 12 additions & 0 deletions src/index.css
Original file line number Diff line number Diff line change
Expand Up @@ -513,3 +513,15 @@ body {
.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; }
37 changes: 37 additions & 0 deletions src/lib/db.js
Original file line number Diff line number Diff line change
Expand Up @@ -272,3 +272,40 @@ export async function removeEpisodeAudio(episodeId) {
await db.episodes.update(episodeId, { downloaded: 0 })
})
}

// ── Series (numbered episodes imported as files) ────────────────────────────
// "Show - 012 Episode title" style imports become one playlist, kept in
// episode-number order, so a whole series plays through without hand-sorting.

/** Leading episode number of a title ("012 Something" → 12), or null. */
export function seriesNumber(title) {
const m = /^(\d{1,4})(?:[\s.)_-]|$)/.exec(title || '')
return m ? Number(m[1]) : null
}

/**
* Create or extend the playlist named `name` with `trackIds`, ordered by each
* track's leading number. Returns the playlist id.
*/
export async function upsertSeriesPlaylist(name, trackIds) {
let id
await db.transaction('rw', db.playlists, db.tracks, async () => {
const existing = await db.playlists.where('name').equals(name).first()
const ids = [...new Set([...(existing?.trackIds || []), ...trackIds])]
const rows = await db.tracks.bulkGet(ids)
const num = new Map(rows.filter(Boolean).map((t) => [t.id, seriesNumber(t.title) ?? Infinity]))
const ordered = ids.filter((i) => num.has(i)).sort((a, b) => num.get(a) - num.get(b))
if (existing) {
await db.playlists.update(existing.id, { trackIds: ordered })
id = existing.id
} else {
id = await db.playlists.add({ name, trackIds: ordered, createdAt: Date.now() })
}
})
return id
}

/** Remember where a playlist was left: which track, and how far in. */
export async function savePlaylistResume(playlistId, trackId, position) {
await db.playlists.update(playlistId, { resume: { trackId, position, at: Date.now() } })
}
31 changes: 26 additions & 5 deletions src/state/PlayerProvider.jsx
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { createContext, useContext, useEffect, useRef, useState, useCallback } from 'react'
import { bumpPlayCount, getAudioBlob, getPosition, savePosition, setPlayed } from '../lib/db'
import { bumpPlayCount, getAudioBlob, getPosition, savePosition, setPlayed, savePlaylistResume } from '../lib/db'
import { isLongForm } from '../lib/podcasts'

const PlayerContext = createContext(null)
Expand Down Expand Up @@ -36,6 +36,9 @@ export function PlayerProvider({ children }) {
const countedRef = useRef(false) // so each play only bumps playCount once
const loadedRef = useRef(null) // the item the <audio> element is loaded with
const lastSavedRef = useRef(0) // position last written to the db (long-form only)
const sourceRef = useRef(null) // playlist id the queue came from, for resume
const startAtRef = useRef(null) // one-shot start position for the next load
const lastResumeRef = useRef(0)
const objectUrlRef = useRef(null) // current blob: URL, revoked when track changes
const [missing, setMissing] = useState(false) // audio bytes not found

Expand Down Expand Up @@ -68,8 +71,12 @@ export function PlayerProvider({ children }) {
}, [])

// ── Core: load + play a queue starting at a given index ──
const playQueue = useCallback((tracks, startIndex = 0) => {
// opts.playlistId: remember progress on that playlist as it plays.
// opts.startAt: begin the first track at this position (seconds).
const playQueue = useCallback((tracks, startIndex = 0, opts = {}) => {
if (!tracks.length) return
sourceRef.current = opts.playlistId ?? null
startAtRef.current = opts.startAt ?? null
setQueue(tracks)
setIndex(startIndex)
setPlayToken((t) => t + 1) // reload even if startIndex === the current index
Expand All @@ -94,10 +101,10 @@ export function PlayerProvider({ children }) {

// Convenience: play a single track, optionally within a list as its queue.
const playTrack = useCallback(
(track, list) => {
(track, list, opts) => {
const q = list && list.length ? list : [track]
const i = q.findIndex((t) => t.id === track.id)
playQueue(q, i < 0 ? 0 : i)
playQueue(q, i < 0 ? 0 : i, opts)
},
[playQueue],
)
Expand Down Expand Up @@ -146,14 +153,18 @@ export function PlayerProvider({ children }) {
}
return
}
const resumeAt = isLongForm(current) ? await getPosition(current).catch(() => 0) : 0
const startAt = startAtRef.current
startAtRef.current = null
const resumeAt = startAt != null ? startAt : isLongForm(current) ? await getPosition(current).catch(() => 0) : 0
if (cancelled) return
const url = blob ? URL.createObjectURL(blob) : current.src
revokePrev()
if (blob) objectUrlRef.current = url
audio.src = url
loadedRef.current = current // element is now this track → safe to count
lastSavedRef.current = resumeAt
lastResumeRef.current = resumeAt
if (sourceRef.current != null) savePlaylistResume(sourceRef.current, current.id, resumeAt).catch(() => {})
applyRate()
if (resumeAt > 5) {
// Pick up where you left off, unless that was the last few seconds.
Expand Down Expand Up @@ -266,6 +277,14 @@ export function PlayerProvider({ children }) {
savePosition(item, a.currentTime).catch(() => {})
}

// Playlist bookmark: the track and spot, for the Resume button.
const persistResume = (a) => {
const item = loadedRef.current
if (sourceRef.current == null || !item) return
lastResumeRef.current = a.currentTime
savePlaylistResume(sourceRef.current, item.id, a.currentTime).catch(() => {})
}

const cycleLoop = useCallback(() => {
setLoopMode((m) => LOOP_MODES[(LOOP_MODES.indexOf(m) + 1) % LOOP_MODES.length])
}, [])
Expand All @@ -283,6 +302,7 @@ export function PlayerProvider({ children }) {
bumpPlayCount(loadedRef.current.id).catch(() => {})
}
if (Math.abs(a.currentTime - lastSavedRef.current) >= 5) persistPosition(a)
if (Math.abs(a.currentTime - lastResumeRef.current) >= 5) persistResume(a)
// Feed the lock-screen scrubber on iOS.
if ('mediaSession' in navigator && navigator.mediaSession.setPositionState && a.duration && Number.isFinite(a.duration)) {
try {
Expand Down Expand Up @@ -396,6 +416,7 @@ export function PlayerProvider({ children }) {
onPause={(e) => {
setIsPlaying(false)
persistPosition(e.target)
persistResume(e.target)
}}
onError={(e) => {
// A stream that can't load (offline, feed host down). Code 1 is an
Expand Down
Loading