From 10974819785aede88acd471c0d1da6e027f3492f Mon Sep 17 00:00:00 2001 From: Enes Yilmaz Date: Fri, 25 Sep 2026 22:47:29 -0400 Subject: [PATCH 1/3] feat(import): numbered imports become an ordered series playlist --- src/components/ImportButton.jsx | 21 +++++++++++++++++-- src/lib/db.js | 37 +++++++++++++++++++++++++++++++++ 2 files changed, 56 insertions(+), 2 deletions(-) diff --git a/src/components/ImportButton.jsx b/src/components/ImportButton.jsx index 6b80ddc..c6e066d 100644 --- a/src/components/ImportButton.jsx +++ b/src/components/ImportButton.jsx @@ -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' @@ -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) @@ -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) @@ -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 @@ -71,6 +79,14 @@ 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.") @@ -78,6 +94,7 @@ export default function ImportButton() { 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) } } diff --git a/src/lib/db.js b/src/lib/db.js index bd2045a..daa40a8 100644 --- a/src/lib/db.js +++ b/src/lib/db.js @@ -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() } }) +} From 433da0fc752d1cf7730a3347419690e564a53e93 Mon Sep 17 00:00:00 2001 From: Enes Yilmaz Date: Fri, 25 Sep 2026 22:47:29 -0400 Subject: [PATCH 2/3] feat(playlists): resume a playlist on the track and spot you left --- src/components/PlaylistsView.jsx | 23 ++++++++++++++++++++--- src/components/TrackRow.jsx | 4 ++-- src/index.css | 12 ++++++++++++ src/state/PlayerProvider.jsx | 31 ++++++++++++++++++++++++++----- 4 files changed, 60 insertions(+), 10 deletions(-) diff --git a/src/components/PlaylistsView.jsx b/src/components/PlaylistsView.jsx index 516799d..1b8c6be 100644 --- a/src/components/PlaylistsView.jsx +++ b/src/components/PlaylistsView.jsx @@ -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' @@ -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 ( <> @@ -131,13 +135,26 @@ function PlaylistDetail({ playlist, onBack, onActions }) { <>

{summarize(tracks)}

- +
+ {resumeTrack && ( + + )}
{tracks.map((t) => (
- +
))} diff --git a/src/components/TrackRow.jsx b/src/components/TrackRow.jsx index 120d3de..af850cb 100644 --- a/src/components/TrackRow.jsx +++ b/src/components/TrackRow.jsx @@ -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 (
-